diff --git a/packages/app/src/Element/Deck/Articles.tsx b/packages/app/src/Element/Deck/Articles.tsx
new file mode 100644
index 00000000..fa9b4a98
--- /dev/null
+++ b/packages/app/src/Element/Deck/Articles.tsx
@@ -0,0 +1,18 @@
+import { NostrLink } from "@snort/system";
+import { useArticles } from "Feed/ArticlesFeed";
+import { orderDescending } from "SnortUtils";
+import Note from "../Event/Note";
+import { useReactions } from "Feed/Reactions";
+
+export default function Articles() {
+ const data = useArticles();
+ const related = useReactions("articles:reactions", data.data?.map(v => NostrLink.fromEvent(v)) ?? []);
+
+ return (
+ <>
+ {orderDescending(data.data ?? []).map(a => (
+
+ ))}
+ >
+ );
+}
diff --git a/packages/app/src/Element/Deck/Nav.css b/packages/app/src/Element/Deck/Nav.css
new file mode 100644
index 00000000..2c707da0
--- /dev/null
+++ b/packages/app/src/Element/Deck/Nav.css
@@ -0,0 +1,12 @@
+nav.deck {
+ width: 48px;
+ height: calc(100vh - 20px);
+ padding: 10px 8px;
+ border-right: 1px solid var(--border-color);
+ text-align: center;
+}
+
+nav.deck .avatar {
+ width: 40px;
+ height: 40px;
+}
diff --git a/packages/app/src/Element/Deck/Nav.tsx b/packages/app/src/Element/Deck/Nav.tsx
new file mode 100644
index 00000000..0915964f
--- /dev/null
+++ b/packages/app/src/Element/Deck/Nav.tsx
@@ -0,0 +1,41 @@
+import { useUserProfile } from "@snort/system-react";
+import Avatar from "Element/User/Avatar";
+import useLogin from "Hooks/useLogin";
+import "./Nav.css";
+import Icon from "Icons/Icon";
+import { Link } from "react-router-dom";
+import { profileLink } from "SnortUtils";
+
+export function DeckNav() {
+ const { publicKey } = useLogin();
+ const profile = useUserProfile(publicKey);
+
+ const unreadDms = 0;
+ const hasNotifications = false;
+
+ return (
+
+ );
+}
diff --git a/packages/app/src/Element/SpotlightMedia.css b/packages/app/src/Element/Deck/SpotlightMedia.css
similarity index 80%
rename from packages/app/src/Element/SpotlightMedia.css
rename to packages/app/src/Element/Deck/SpotlightMedia.css
index 4e5862e2..e24a159e 100644
--- a/packages/app/src/Element/SpotlightMedia.css
+++ b/packages/app/src/Element/Deck/SpotlightMedia.css
@@ -10,15 +10,15 @@
background: transparent;
}
-.modal.spotlight img,
-.modal.spotlight video {
+.spotlight img,
+.spotlight video {
max-width: 100vw;
- max-height: 100vh;
+ max-height: 99vh;
aspect-ratio: unset;
width: unset;
}
-.modal.spotlight .details {
+.spotlight .details {
text-align: right;
position: absolute;
top: 28px;
@@ -29,16 +29,17 @@
font-weight: 400;
line-height: 24px;
align-items: center;
+ user-select: none;
}
-.modal.spotlight .left {
+.spotlight .left {
position: absolute;
left: 24px;
top: 50vh;
transform: rotate(180deg);
}
-.modal.spotlight .right {
+.spotlight .right {
position: absolute;
right: 24px;
top: 50vh;
diff --git a/packages/app/src/Element/SpotlightMedia.tsx b/packages/app/src/Element/Deck/SpotlightMedia.tsx
similarity index 84%
rename from packages/app/src/Element/SpotlightMedia.tsx
rename to packages/app/src/Element/Deck/SpotlightMedia.tsx
index f617fe48..200d2109 100644
--- a/packages/app/src/Element/SpotlightMedia.tsx
+++ b/packages/app/src/Element/Deck/SpotlightMedia.tsx
@@ -37,7 +37,7 @@ export function SpotlightMedia(props: SpotlightMediaProps) {
});
}
return (
-
+
{idx + 1}/{props.images.length}
@@ -49,6 +49,14 @@ export function SpotlightMedia(props: SpotlightMediaProps) {
inc()} />
>
)}
+
+ );
+}
+
+export function SpotlightMediaModal(props: SpotlightMediaProps) {
+ return (
+
+
);
}
diff --git a/packages/app/src/Element/AppleMusicEmbed.tsx b/packages/app/src/Element/Embed/AppleMusicEmbed.tsx
similarity index 100%
rename from packages/app/src/Element/AppleMusicEmbed.tsx
rename to packages/app/src/Element/Embed/AppleMusicEmbed.tsx
diff --git a/packages/app/src/Element/Embed/CashuNuts.css b/packages/app/src/Element/Embed/CashuNuts.css
new file mode 100644
index 00000000..f82778b6
--- /dev/null
+++ b/packages/app/src/Element/Embed/CashuNuts.css
@@ -0,0 +1,8 @@
+.cashu {
+ background: var(--cashu-gradient);
+}
+
+.cashu h1 {
+ font-size: 44px;
+ line-height: 1em;
+}
diff --git a/packages/app/src/Element/Embed/CashuNuts.tsx b/packages/app/src/Element/Embed/CashuNuts.tsx
new file mode 100644
index 00000000..62eaa149
--- /dev/null
+++ b/packages/app/src/Element/Embed/CashuNuts.tsx
@@ -0,0 +1,137 @@
+import "./CashuNuts.css";
+import { useEffect, useState } from "react";
+import { FormattedMessage, FormattedNumber } from "react-intl";
+import { useUserProfile } from "@snort/system-react";
+
+import useLogin from "Hooks/useLogin";
+import Icon from "Icons/Icon";
+
+interface Token {
+ token: Array<{
+ mint: string;
+ proofs: Array<{
+ amount: number;
+ }>;
+ }>;
+ memo?: string;
+}
+
+export default function CashuNuts({ token }: { token: string }) {
+ const { publicKey } = useLogin(s => ({ publicKey: s.publicKey }));
+ const profile = useUserProfile(publicKey);
+
+ async function copyToken(e: React.MouseEvent
, token: string) {
+ e.stopPropagation();
+ await navigator.clipboard.writeText(token);
+ }
+ async function redeemToken(e: React.MouseEvent, token: string) {
+ e.stopPropagation();
+ const lnurl = profile?.lud16 ?? "";
+ const url = `https://redeem.cashu.me?token=${encodeURIComponent(token)}&lightning=${encodeURIComponent(
+ lnurl,
+ )}&autopay=yes`;
+ window.open(url, "_blank");
+ }
+
+ const [cashu, setCashu] = useState();
+ useEffect(() => {
+ try {
+ if (!token.startsWith("cashuA") || token.length < 10) {
+ return;
+ }
+ import("@cashu/cashu-ts").then(({ getDecodedToken }) => {
+ const tkn = getDecodedToken(token);
+ setCashu(tkn);
+ });
+ } catch {
+ // ignored
+ }
+ }, [token]);
+
+ if (!cashu) return <>{token}>;
+
+ const amount = cashu.token[0].proofs.reduce((acc, v) => acc + v.amount, 0);
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{c} ,
+ n: ,
+ }}
+ />
+
+
+ {c} ,
+ url: new URL(cashu.token[0].mint).hostname,
+ }}
+ />
+
+
+
+ copyToken(e, token)}>
+
+
+ redeemToken(e, token)}>
+
+
+
+
+ );
+}
diff --git a/packages/app/src/Element/Hashtag.css b/packages/app/src/Element/Embed/Hashtag.css
similarity index 100%
rename from packages/app/src/Element/Hashtag.css
rename to packages/app/src/Element/Embed/Hashtag.css
diff --git a/packages/app/src/Element/Hashtag.tsx b/packages/app/src/Element/Embed/Hashtag.tsx
similarity index 100%
rename from packages/app/src/Element/Hashtag.tsx
rename to packages/app/src/Element/Embed/Hashtag.tsx
diff --git a/packages/app/src/Element/Invoice.css b/packages/app/src/Element/Embed/Invoice.css
similarity index 94%
rename from packages/app/src/Element/Invoice.css
rename to packages/app/src/Element/Embed/Invoice.css
index e2eade8f..f31b107d 100644
--- a/packages/app/src/Element/Invoice.css
+++ b/packages/app/src/Element/Embed/Invoice.css
@@ -8,6 +8,12 @@
background: var(--invoice-gradient);
}
+.note-invoice.error {
+ padding: 8px 12px !important;
+ color: #aaa;
+ background: transparent;
+}
+
.note-invoice.expired {
background: var(--expired-invoice-gradient);
color: var(--font-secondary-color);
diff --git a/packages/app/src/Element/Invoice.tsx b/packages/app/src/Element/Embed/Invoice.tsx
similarity index 96%
rename from packages/app/src/Element/Invoice.tsx
rename to packages/app/src/Element/Embed/Invoice.tsx
index 31700cd8..73bbbd73 100644
--- a/packages/app/src/Element/Invoice.tsx
+++ b/packages/app/src/Element/Embed/Invoice.tsx
@@ -8,7 +8,7 @@ import SendSats from "Element/SendSats";
import Icon from "Icons/Icon";
import { useWallet } from "Wallet";
-import messages from "./messages";
+import messages from "../messages";
export interface InvoiceProps {
invoice: string;
@@ -75,7 +75,7 @@ export default function Invoice(props: InvoiceProps) {
{description && {description}
}
{isPaid ? (
-
+
) : (
diff --git a/packages/app/src/Element/LinkPreview.css b/packages/app/src/Element/Embed/LinkPreview.css
similarity index 76%
rename from packages/app/src/Element/LinkPreview.css
rename to packages/app/src/Element/Embed/LinkPreview.css
index 8ee8aa9e..11c8676c 100644
--- a/packages/app/src/Element/LinkPreview.css
+++ b/packages/app/src/Element/Embed/LinkPreview.css
@@ -40,12 +40,23 @@
margin: 0 0 15px 0 !important;
border-radius: 0 !important;
background-image: var(--img-url);
- min-height: 250px;
+ min-height: 220px;
max-height: 500px;
background-size: cover;
background-position: center;
}
.light .link-preview-container {
- background: #ddd;
+ background: #fff;
+ border: 1px solid var(--border-color);
+}
+
+.light .link-preview-container:hover {
+ box-shadow: rgba(0, 0, 0, 0.08) 0 1px 3px;
+}
+
+@media (min-width: 1025px) {
+ .link-preview-image {
+ min-height: 342px;
+ }
}
diff --git a/packages/app/src/Element/LinkPreview.tsx b/packages/app/src/Element/Embed/LinkPreview.tsx
similarity index 98%
rename from packages/app/src/Element/LinkPreview.tsx
rename to packages/app/src/Element/Embed/LinkPreview.tsx
index cb453446..5c8fb5a3 100644
--- a/packages/app/src/Element/LinkPreview.tsx
+++ b/packages/app/src/Element/Embed/LinkPreview.tsx
@@ -4,7 +4,7 @@ import { CSSProperties, useEffect, useState } from "react";
import Spinner from "Icons/Spinner";
import SnortApi, { LinkPreviewData } from "SnortApi";
import useImgProxy from "Hooks/useImgProxy";
-import { MediaElement } from "Element/MediaElement";
+import { MediaElement } from "Element/Embed/MediaElement";
async function fetchUrlPreviewInfo(url: string) {
const api = new SnortApi();
diff --git a/packages/app/src/Element/MagnetLink.tsx b/packages/app/src/Element/Embed/MagnetLink.tsx
similarity index 87%
rename from packages/app/src/Element/MagnetLink.tsx
rename to packages/app/src/Element/Embed/MagnetLink.tsx
index 4dca7d5e..016d6192 100644
--- a/packages/app/src/Element/MagnetLink.tsx
+++ b/packages/app/src/Element/Embed/MagnetLink.tsx
@@ -1,4 +1,4 @@
-import { FormattedMessage } from "react-intl";
+import FormattedMessage from "Element/FormattedMessage";
import { Magnet } from "SnortUtils";
diff --git a/packages/app/src/Element/MediaElement.tsx b/packages/app/src/Element/Embed/MediaElement.tsx
similarity index 100%
rename from packages/app/src/Element/MediaElement.tsx
rename to packages/app/src/Element/Embed/MediaElement.tsx
diff --git a/packages/app/src/Element/Mention.tsx b/packages/app/src/Element/Embed/Mention.tsx
similarity index 90%
rename from packages/app/src/Element/Mention.tsx
rename to packages/app/src/Element/Embed/Mention.tsx
index 216774a6..a54ec56c 100644
--- a/packages/app/src/Element/Mention.tsx
+++ b/packages/app/src/Element/Embed/Mention.tsx
@@ -4,7 +4,7 @@ import { HexKey } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import { profileLink } from "SnortUtils";
-import { getDisplayName } from "Element/ProfileImage";
+import { getDisplayName } from "Element/User/ProfileImage";
export default function Mention({ pubkey, relays }: { pubkey: HexKey; relays?: Array | string }) {
const user = useUserProfile(pubkey);
diff --git a/packages/app/src/Element/MixCloudEmbed.tsx b/packages/app/src/Element/Embed/MixCloudEmbed.tsx
similarity index 80%
rename from packages/app/src/Element/MixCloudEmbed.tsx
rename to packages/app/src/Element/Embed/MixCloudEmbed.tsx
index d3c5aa01..0a3cd3eb 100644
--- a/packages/app/src/Element/MixCloudEmbed.tsx
+++ b/packages/app/src/Element/Embed/MixCloudEmbed.tsx
@@ -4,8 +4,8 @@ import useLogin from "Hooks/useLogin";
const MixCloudEmbed = ({ link }: { link: string }) => {
const feedPath = (MixCloudRegex.test(link) && RegExp.$1) + "%2F" + (MixCloudRegex.test(link) && RegExp.$2);
- const lightTheme = useLogin().preferences.theme === "light";
- const lightParams = lightTheme ? "light=1" : "light=0";
+ const { theme } = useLogin(s => ({ theme: s.preferences.theme }));
+ const lightParams = theme === "light" ? "light=1" : "light=0";
return (
<>
diff --git a/packages/app/src/Element/NostrLink.tsx b/packages/app/src/Element/Embed/NostrLink.tsx
similarity index 91%
rename from packages/app/src/Element/NostrLink.tsx
rename to packages/app/src/Element/Embed/NostrLink.tsx
index bbbdbb6a..178f5a43 100644
--- a/packages/app/src/Element/NostrLink.tsx
+++ b/packages/app/src/Element/Embed/NostrLink.tsx
@@ -1,8 +1,8 @@
import { Link } from "react-router-dom";
import { NostrPrefix, tryParseNostrLink } from "@snort/system";
-import Mention from "Element/Mention";
-import NoteQuote from "Element/NoteQuote";
+import Mention from "Element/Embed/Mention";
+import NoteQuote from "Element/Event/NoteQuote";
export default function NostrLink({ link, depth }: { link: string; depth?: number }) {
const nav = tryParseNostrLink(link);
diff --git a/packages/app/src/Element/NostrNestsEmbed.tsx b/packages/app/src/Element/Embed/NostrNestsEmbed.tsx
similarity index 100%
rename from packages/app/src/Element/NostrNestsEmbed.tsx
rename to packages/app/src/Element/Embed/NostrNestsEmbed.tsx
diff --git a/packages/app/src/Element/PubkeyList.tsx b/packages/app/src/Element/Embed/PubkeyList.tsx
similarity index 91%
rename from packages/app/src/Element/PubkeyList.tsx
rename to packages/app/src/Element/Embed/PubkeyList.tsx
index 17648433..d604563d 100644
--- a/packages/app/src/Element/PubkeyList.tsx
+++ b/packages/app/src/Element/Embed/PubkeyList.tsx
@@ -3,14 +3,14 @@ import { FormattedMessage, FormattedNumber } from "react-intl";
import { LNURL } from "@snort/shared";
import { dedupe, hexToBech32 } from "SnortUtils";
-import FollowListBase from "Element/FollowListBase";
+import FollowListBase from "Element/User/FollowListBase";
import AsyncButton from "Element/AsyncButton";
import { useWallet } from "Wallet";
import { Toastore } from "Toaster";
-import { getDisplayName } from "Element/ProfileImage";
+import { getDisplayName } from "Element/User/ProfileImage";
import { UserCache } from "Cache";
import useLogin from "Hooks/useLogin";
-import useEventPublisher from "Feed/EventPublisher";
+import useEventPublisher from "Hooks/useEventPublisher";
import { WalletInvoiceState } from "Wallet";
export default function PubkeyList({ ev, className }: { ev: NostrEvent; className?: string }) {
@@ -34,7 +34,7 @@ export default function PubkeyList({ ev, className }: { ev: NostrEvent; classNam
pk,
Object.keys(login.relays.item),
undefined,
- `Zap from ${hexToBech32("note", ev.id)}`
+ `Zap from ${hexToBech32("note", ev.id)}`,
);
const invoice = await svc.getInvoice(amtSend, undefined, zap);
if (invoice.pr) {
diff --git a/packages/app/src/Element/SoundCloudEmded.tsx b/packages/app/src/Element/Embed/SoundCloudEmded.tsx
similarity index 100%
rename from packages/app/src/Element/SoundCloudEmded.tsx
rename to packages/app/src/Element/Embed/SoundCloudEmded.tsx
diff --git a/packages/app/src/Element/SpotifyEmbed.tsx b/packages/app/src/Element/Embed/SpotifyEmbed.tsx
similarity index 100%
rename from packages/app/src/Element/SpotifyEmbed.tsx
rename to packages/app/src/Element/Embed/SpotifyEmbed.tsx
diff --git a/packages/app/src/Element/TidalEmbed.tsx b/packages/app/src/Element/Embed/TidalEmbed.tsx
similarity index 100%
rename from packages/app/src/Element/TidalEmbed.tsx
rename to packages/app/src/Element/Embed/TidalEmbed.tsx
diff --git a/packages/app/src/Element/TwitchEmbed.tsx b/packages/app/src/Element/Embed/TwitchEmbed.tsx
similarity index 100%
rename from packages/app/src/Element/TwitchEmbed.tsx
rename to packages/app/src/Element/Embed/TwitchEmbed.tsx
diff --git a/packages/app/src/Element/WavlakeEmbed.tsx b/packages/app/src/Element/Embed/WavlakeEmbed.tsx
similarity index 100%
rename from packages/app/src/Element/WavlakeEmbed.tsx
rename to packages/app/src/Element/Embed/WavlakeEmbed.tsx
diff --git a/packages/app/src/Element/ZapstrEmbed.css b/packages/app/src/Element/Embed/ZapstrEmbed.css
similarity index 100%
rename from packages/app/src/Element/ZapstrEmbed.css
rename to packages/app/src/Element/Embed/ZapstrEmbed.css
diff --git a/packages/app/src/Element/ZapstrEmbed.tsx b/packages/app/src/Element/Embed/ZapstrEmbed.tsx
similarity index 78%
rename from packages/app/src/Element/ZapstrEmbed.tsx
rename to packages/app/src/Element/Embed/ZapstrEmbed.tsx
index 844b5b67..ab0cb71c 100644
--- a/packages/app/src/Element/ZapstrEmbed.tsx
+++ b/packages/app/src/Element/Embed/ZapstrEmbed.tsx
@@ -1,10 +1,10 @@
import "./ZapstrEmbed.css";
import { Link } from "react-router-dom";
-import { encodeTLV, NostrPrefix, NostrEvent } from "@snort/system";
+import { NostrEvent, NostrLink } from "@snort/system";
import { ProxyImg } from "Element/ProxyImg";
-import ProfileImage from "Element/ProfileImage";
-import { FormattedMessage } from "react-intl";
+import ProfileImage from "Element/User/ProfileImage";
+import FormattedMessage from "Element/FormattedMessage";
export default function ZapstrEmbed({ ev }: { ev: NostrEvent }) {
const media = ev.tags.find(a => a[0] === "media");
@@ -12,13 +12,7 @@ export default function ZapstrEmbed({ ev }: { ev: NostrEvent }) {
const subject = ev.tags.find(a => a[0] === "subject");
const refPersons = ev.tags.filter(a => a[0] === "p");
- const link = encodeTLV(
- NostrPrefix.Address,
- ev.tags.find(a => a[0] === "d")?.[1] ?? "",
- undefined,
- ev.kind,
- ev.pubkey
- );
+ const link = NostrLink.fromEvent(ev).encode();
return (
<>
diff --git a/packages/app/src/Element/NostrFileHeader.tsx b/packages/app/src/Element/Event/NostrFileHeader.tsx
similarity index 84%
rename from packages/app/src/Element/NostrFileHeader.tsx
rename to packages/app/src/Element/Event/NostrFileHeader.tsx
index b4efdc0d..0b0fa4b9 100644
--- a/packages/app/src/Element/NostrFileHeader.tsx
+++ b/packages/app/src/Element/Event/NostrFileHeader.tsx
@@ -1,11 +1,11 @@
-import { FormattedMessage } from "react-intl";
+import FormattedMessage from "Element/FormattedMessage";
import { NostrEvent, NostrLink } from "@snort/system";
import { findTag } from "SnortUtils";
-import useEventFeed from "Feed/EventFeed";
+import { useEventFeed } from "Feed/EventFeed";
import PageSpinner from "Element/PageSpinner";
-import Reveal from "Element/Reveal";
-import { MediaElement } from "Element/MediaElement";
+import Reveal from "Element/Event/Reveal";
+import { MediaElement } from "Element/Embed/MediaElement";
export default function NostrFileHeader({ link }: { link: NostrLink }) {
const ev = useEventFeed(link);
diff --git a/packages/app/src/Element/Note.css b/packages/app/src/Element/Event/Note.css
similarity index 98%
rename from packages/app/src/Element/Note.css
rename to packages/app/src/Element/Event/Note.css
index 4245f450..bbefbb49 100644
--- a/packages/app/src/Element/Note.css
+++ b/packages/app/src/Element/Event/Note.css
@@ -2,7 +2,7 @@
min-height: 110px;
display: flex;
flex-direction: column;
- gap: 12px;
+ gap: 16px;
}
.note:hover {
@@ -65,6 +65,7 @@
border: 1px solid var(--gray-superdark);
border-radius: 12px;
padding: 8px 16px 16px 16px;
+ margin-top: 16px;
}
.note .footer .footer-reactions {
@@ -163,10 +164,6 @@
min-height: unset;
}
-.hidden-note button {
- max-height: 30px;
-}
-
.expand-note {
padding: 0 0 16px 0;
font-weight: 400;
diff --git a/packages/app/src/Element/Note.tsx b/packages/app/src/Element/Event/Note.tsx
similarity index 73%
rename from packages/app/src/Element/Note.tsx
rename to packages/app/src/Element/Event/Note.tsx
index 02f58c3e..8cf17aa0 100644
--- a/packages/app/src/Element/Note.tsx
+++ b/packages/app/src/Element/Event/Note.tsx
@@ -3,15 +3,14 @@ import React, { useMemo, useState, ReactNode } from "react";
import { useNavigate, Link } from "react-router-dom";
import { useInView } from "react-intersection-observer";
import { useIntl, FormattedMessage } from "react-intl";
-import { TaggedNostrEvent, HexKey, EventKind, NostrPrefix, Lists, EventExt, parseZap } from "@snort/system";
+import { TaggedNostrEvent, HexKey, EventKind, NostrPrefix, Lists, EventExt, parseZap, NostrLink } from "@snort/system";
import { System } from "index";
-import useEventPublisher from "Feed/EventPublisher";
+import useEventPublisher from "Hooks/useEventPublisher";
import Icon from "Icons/Icon";
-import ProfileImage from "Element/ProfileImage";
+import ProfileImage from "Element/User/ProfileImage";
import Text from "Element/Text";
import {
- eventLink,
getReactions,
dedupeByPubkey,
tagFilterOfTextRepost,
@@ -21,23 +20,26 @@ import {
profileLink,
findTag,
} from "SnortUtils";
-import NoteFooter from "Element/NoteFooter";
-import NoteTime from "Element/NoteTime";
-import Reveal from "Element/Reveal";
+import NoteFooter from "Element/Event/NoteFooter";
+import NoteTime from "Element/Event/NoteTime";
+import Reveal from "Element/Event/Reveal";
import useModeration from "Hooks/useModeration";
import { UserCache } from "Cache";
-import Poll from "Element/Poll";
+import Poll from "Element/Event/Poll";
import useLogin from "Hooks/useLogin";
import { setBookmarked, setPinned } from "Login";
-import { NostrFileElement } from "Element/NostrFileHeader";
-import ZapstrEmbed from "Element/ZapstrEmbed";
-import PubkeyList from "Element/PubkeyList";
+import { NostrFileElement } from "Element/Event/NostrFileHeader";
+import ZapstrEmbed from "Element/Embed/ZapstrEmbed";
+import PubkeyList from "Element/Embed/PubkeyList";
import { LiveEvent } from "Element/LiveEvent";
-import { NoteContextMenu, NoteTranslation } from "Element/NoteContextMenu";
-import Reactions from "Element/Reactions";
-import { ZapGoal } from "Element/ZapGoal";
+import { NoteContextMenu, NoteTranslation } from "Element/Event/NoteContextMenu";
+import Reactions from "Element/Event/Reactions";
+import { ZapGoal } from "Element/Event/ZapGoal";
+import NoteReaction from "Element/Event/NoteReaction";
+import ProfilePreview from "Element/User/ProfilePreview";
+import { ProxyImg } from "Element/ProxyImg";
-import messages from "./messages";
+import messages from "../messages";
export interface NoteProps {
data: TaggedNostrEvent;
@@ -60,20 +62,21 @@ export interface NoteProps {
canUnpin?: boolean;
canUnbookmark?: boolean;
canClick?: boolean;
+ showMediaSpotlight?: boolean;
};
}
const HiddenNote = ({ children }: { children: React.ReactNode }) => {
const [show, setShow] = useState(false);
return show ? (
- <>{children}>
+ children
) : (
-
+
-
setShow(true)}>
+ setShow(true)}>
@@ -82,8 +85,10 @@ const HiddenNote = ({ children }: { children: React.ReactNode }) => {
};
export default function Note(props: NoteProps) {
- const { data: ev, related, highlight, options: opt, ignoreModeration = false, className } = props;
-
+ const { data: ev, className } = props;
+ if (ev.kind === EventKind.Repost) {
+ return
;
+ }
if (ev.kind === EventKind.FileHeader) {
return
;
}
@@ -96,16 +101,24 @@ export default function Note(props: NoteProps) {
if (ev.kind === EventKind.LiveEvent) {
return
;
}
+ if (ev.kind === EventKind.SetMetadata) {
+ return
>} pubkey={ev.pubkey} className="card" />;
+ }
if (ev.kind === (9041 as EventKind)) {
return ;
}
+ return ;
+}
+
+export function NoteInner(props: NoteProps) {
+ const { data: ev, related, highlight, options: opt, ignoreModeration = false, className } = props;
+
const baseClassName = `note card${className ? ` ${className}` : ""}`;
const navigate = useNavigate();
const [showReactions, setShowReactions] = useState(false);
const deletions = useMemo(() => getReactions(related, ev.id, EventKind.Deletion), [related]);
- const { isMuted } = useModeration();
- const isOpMuted = isMuted(ev?.pubkey);
+ const { isEventMuted } = useModeration();
const { ref, inView } = useInView({ triggerOnce: true });
const login = useLogin();
const { pinned, bookmarked } = login;
@@ -123,7 +136,7 @@ export default function Note(props: NoteProps) {
{
[Reaction.Positive]: [] as TaggedNostrEvent[],
[Reaction.Negative]: [] as TaggedNostrEvent[],
- }
+ },
);
return {
[Reaction.Positive]: dedupeByPubkey(result[Reaction.Positive]),
@@ -138,7 +151,7 @@ export default function Note(props: NoteProps) {
...getReactions(related, ev.id, EventKind.TextNote).filter(e => e.tags.some(tagFilterOfTextRepost(e, ev.id))),
...getReactions(related, ev.id, EventKind.Repost),
]),
- [related, ev]
+ [related, ev],
);
const zaps = useMemo(() => {
const sortedZaps = getReactions(related, ev.id, EventKind.ZapReceipt)
@@ -181,8 +194,49 @@ export default function Note(props: NoteProps) {
}
}
+ const innerContent = () => {
+ if (ev.kind === EventKind.LongFormTextNote) {
+ const title = findTag(ev, "title");
+ const summary = findTag(ev, "simmary");
+ const image = findTag(ev, "image");
+ return (
+
+
{title}
+
+
{summary}
+
+ {image &&
}
+
+
+ );
+ } else {
+ const body = ev?.content ?? "";
+ return (
+
+ );
+ }
+ };
+
const transformBody = () => {
- const body = ev?.content ?? "";
if (deletions?.length > 0) {
return (
@@ -196,40 +250,39 @@ export default function Note(props: NoteProps) {
-
+ {c} ,
+ }}
+ />
{contentWarning[1] && (
<>
-
+
{c} ,
reason: contentWarning[1],
}}
/>
>
)}
+
+
>
}>
-
+ {innerContent()}
);
}
- return (
-
- );
+ return innerContent();
};
function goToEvent(
e: React.MouseEvent,
eTarget: TaggedNostrEvent,
- isTargetAllowed: boolean = e.target === e.currentTarget
+ isTargetAllowed: boolean = e.target === e.currentTarget,
) {
if (!isTargetAllowed || opt?.canClick === false) {
return;
@@ -241,13 +294,13 @@ export default function Note(props: NoteProps) {
return;
}
- const link = eventLink(eTarget.id, eTarget.relays);
+ const link = NostrLink.fromEvent(eTarget);
// detect cmd key and open in new tab
if (e.metaKey) {
- window.open(link, "_blank");
+ window.open(`/e/${link.encode()}`, "_blank");
} else {
- navigate(link, {
- state: ev,
+ navigate(`/e/${link.encode()}`, {
+ state: eTarget,
});
}
}
@@ -259,8 +312,12 @@ export default function Note(props: NoteProps) {
}
const maxMentions = 2;
- const replyId = thread?.replyTo?.value ?? thread?.root?.value;
- const replyRelayHints = thread?.replyTo?.relay ?? thread.root?.relay;
+ 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);
@@ -293,23 +350,19 @@ export default function Note(props: NoteProps) {
{pubMentions} {others}
>
) : (
- replyId && (
-
- {hexToBech32(NostrPrefix.Event, replyId)?.substring(0, 12)}
-
- )
+ replyLink && {replyLink.encode().substring(0, 12)}
)}
);
}
- const canRenderAsTextNote = [EventKind.TextNote, EventKind.Polls];
+ const canRenderAsTextNote = [EventKind.TextNote, EventKind.Polls, EventKind.LongFormTextNote];
if (!canRenderAsTextNote.includes(ev.kind)) {
const alt = findTag(ev, "alt");
if (alt) {
return (
-
+
);
} else {
@@ -418,5 +471,5 @@ export default function Note(props: NoteProps) {
);
- return !ignoreModeration && isOpMuted ? {note} : note;
+ return !ignoreModeration && isEventMuted(ev) ? {note} : note;
}
diff --git a/packages/app/src/Element/NoteContextMenu.tsx b/packages/app/src/Element/Event/NoteContextMenu.tsx
similarity index 74%
rename from packages/app/src/Element/NoteContextMenu.tsx
rename to packages/app/src/Element/Event/NoteContextMenu.tsx
index c3c81b90..04e9988c 100644
--- a/packages/app/src/Element/NoteContextMenu.tsx
+++ b/packages/app/src/Element/Event/NoteContextMenu.tsx
@@ -1,23 +1,17 @@
import { FormattedMessage, useIntl } from "react-intl";
-import { HexKey, Lists, NostrPrefix, TaggedNostrEvent, encodeTLV } from "@snort/system";
+import { HexKey, Lists, NostrLink, TaggedNostrEvent } from "@snort/system";
import { Menu, MenuItem } from "@szhsin/react-menu";
-import { useDispatch, useSelector } from "react-redux";
import { TranslateHost } from "Const";
import { System } from "index";
import Icon from "Icons/Icon";
import { setPinned, setBookmarked } from "Login";
-import {
- setNote as setReBroadcastNote,
- setShow as setReBroadcastShow,
- reset as resetReBroadcast,
-} from "State/ReBroadcast";
import messages from "Element/messages";
import useLogin from "Hooks/useLogin";
import useModeration from "Hooks/useModeration";
-import useEventPublisher from "Feed/EventPublisher";
-import { RootState } from "State/Store";
-import { ReBroadcaster } from "./ReBroadcaster";
+import useEventPublisher from "Hooks/useEventPublisher";
+import { ReBroadcaster } from "../ReBroadcaster";
+import { useState } from "react";
export interface NoteTranslation {
text: string;
@@ -33,20 +27,16 @@ interface NosteContextMenuProps {
}
export function NoteContextMenu({ ev, ...props }: NosteContextMenuProps) {
- const dispatch = useDispatch();
const { formatMessage } = useIntl();
const login = useLogin();
- const { pinned, bookmarked, publicKey, preferences: prefs } = login;
const { mute, block } = useModeration();
const publisher = useEventPublisher();
- const showReBroadcastModal = useSelector((s: RootState) => s.reBroadcast.show);
- const reBroadcastNote = useSelector((s: RootState) => s.reBroadcast.note);
- const willRenderReBroadcast = showReBroadcastModal && reBroadcastNote && reBroadcastNote?.id === ev.id;
+ const [showBroadcast, setShowBroadcast] = useState(false);
const lang = window.navigator.language;
const langNames = new Intl.DisplayNames([...window.navigator.languages], {
type: "language",
});
- const isMine = ev.pubkey === publicKey;
+ const isMine = ev.pubkey === login.publicKey;
async function deleteEvent() {
if (window.confirm(formatMessage(messages.ConfirmDeletion, { id: ev.id.substring(0, 8) })) && publisher) {
@@ -56,7 +46,7 @@ export function NoteContextMenu({ ev, ...props }: NosteContextMenuProps) {
}
async function share() {
- const link = encodeTLV(NostrPrefix.Event, ev.id, ev.relays);
+ const link = NostrLink.fromEvent(ev).encode();
const url = `${window.location.protocol}//${window.location.host}/e/${link}`;
if ("share" in window.navigator) {
await window.navigator.share({
@@ -92,13 +82,13 @@ export function NoteContextMenu({ ev, ...props }: NosteContextMenuProps) {
}
async function copyId() {
- const link = encodeTLV(NostrPrefix.Event, ev.id, ev.relays);
+ const link = NostrLink.fromEvent(ev).encode();
await navigator.clipboard.writeText(link);
}
async function pin(id: HexKey) {
if (publisher) {
- const es = [...pinned.item, id];
+ const es = [...login.pinned.item, id];
const ev = await publisher.noteList(es, Lists.Pinned);
System.BroadcastEvent(ev);
setPinned(login, es, ev.created_at * 1000);
@@ -107,7 +97,7 @@ export function NoteContextMenu({ ev, ...props }: NosteContextMenuProps) {
async function bookmark(id: HexKey) {
if (publisher) {
- const es = [...bookmarked.item, id];
+ const es = [...login.bookmarked.item, id];
const ev = await publisher.noteList(es, Lists.Bookmarked);
System.BroadcastEvent(ev);
setBookmarked(login, es, ev.created_at * 1000);
@@ -119,12 +109,7 @@ export function NoteContextMenu({ ev, ...props }: NosteContextMenuProps) {
}
const handleReBroadcastButtonClick = () => {
- if (reBroadcastNote?.id !== ev.id) {
- dispatch(resetReBroadcast());
- }
-
- dispatch(setReBroadcastNote(ev));
- dispatch(setReBroadcastShow(!showReBroadcastModal));
+ setShowBroadcast(true);
};
function menuItems() {
@@ -145,13 +130,13 @@ export function NoteContextMenu({ ev, ...props }: NosteContextMenuProps) {
- {!pinned.item.includes(ev.id) && (
+ {!login.pinned.item.includes(ev.id) && !login.readonly && (
pin(ev.id)}>
)}
- {!bookmarked.item.includes(ev.id) && (
+ {!login.bookmarked.item.includes(ev.id) && !login.readonly && (
bookmark(ev.id)}>
@@ -161,23 +146,23 @@ export function NoteContextMenu({ ev, ...props }: NosteContextMenuProps) {
- mute(ev.pubkey)}>
-
-
-
- {prefs.enableReactions && (
+ {!login.readonly && (
+ mute(ev.pubkey)}>
+
+
+
+ )}
+ {login.preferences.enableReactions && !login.readonly && (
props.react("-")}>
)}
- {ev.pubkey === publicKey && (
-
-
-
-
- )}
- {ev.pubkey !== publicKey && (
+
+
+
+
+ {ev.pubkey !== login.publicKey && !login.readonly && (
block(ev.pubkey)}>
@@ -187,13 +172,13 @@ export function NoteContextMenu({ ev, ...props }: NosteContextMenuProps) {
- {prefs.showDebugMenus && (
+ {login.preferences.showDebugMenus && (
copyEvent()}>
)}
- {isMine && (
+ {isMine && !login.readonly && (
deleteEvent()}>
@@ -214,7 +199,7 @@ export function NoteContextMenu({ ev, ...props }: NosteContextMenuProps) {
menuClassName="ctx-menu">
{menuItems()}
- {willRenderReBroadcast && }
+ {showBroadcast && setShowBroadcast(false)} />}
>
);
}
diff --git a/packages/app/src/Element/NoteCreator.css b/packages/app/src/Element/Event/NoteCreator.css
similarity index 67%
rename from packages/app/src/Element/NoteCreator.css
rename to packages/app/src/Element/Event/NoteCreator.css
index e879558e..017a9e35 100644
--- a/packages/app/src/Element/NoteCreator.css
+++ b/packages/app/src/Element/Event/NoteCreator.css
@@ -2,18 +2,25 @@
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,
+ 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 {
+.note-creator-modal .modal-body > div {
+ display: flex;
+ flex-direction: column;
gap: 16px;
}
.note-creator-modal .note.card {
+ padding: 0;
+ border: none;
+}
+
+.note-creator-modal .note.card.note-quote {
+ border: 1px solid var(--gray);
padding: 8px 12px;
- border-radius: 12px;
- background-color: var(--gray-dark);
}
.note-creator-modal h4 {
@@ -80,13 +87,13 @@
.note-create-button {
width: 48px;
height: 48px;
- background-color: var(--highlight);
color: white;
+ background: linear-gradient(90deg, rgba(239, 150, 68, 1) 0%, rgba(123, 65, 246, 1) 100%);
border: none;
border-radius: 100%;
position: fixed;
- bottom: 50px;
- right: calc(((100vw - 640px) / 2) - 60px);
+ bottom: 40px;
+ right: calc(((100vw - 640px) / 2) - 75px);
display: flex;
align-items: center;
justify-content: center;
@@ -97,3 +104,14 @@
right: 16px;
}
}
+
+.light .note-creator textarea {
+ background-color: #fff;
+}
+
+.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;
+}
diff --git a/packages/app/src/Element/Event/NoteCreator.tsx b/packages/app/src/Element/Event/NoteCreator.tsx
new file mode 100644
index 00000000..37a207ae
--- /dev/null
+++ b/packages/app/src/Element/Event/NoteCreator.tsx
@@ -0,0 +1,512 @@
+import "./NoteCreator.css";
+import { FormattedMessage, useIntl } from "react-intl";
+import {
+ EventKind,
+ NostrPrefix,
+ TaggedNostrEvent,
+ EventBuilder,
+ tryParseNostrLink,
+ NostrLink,
+ NostrEvent,
+} from "@snort/system";
+
+import Icon from "Icons/Icon";
+import useEventPublisher from "Hooks/useEventPublisher";
+import { openFile } from "SnortUtils";
+import Textarea from "Element/Textarea";
+import Modal from "Element/Modal";
+import ProfileImage from "Element/User/ProfileImage";
+import useFileUpload from "Upload";
+import Note from "Element/Event/Note";
+
+import { ClipboardEventHandler } from "react";
+import useLogin from "Hooks/useLogin";
+import { System, WasmPowWorker } from "index";
+import AsyncButton from "Element/AsyncButton";
+import { AsyncIcon } from "Element/AsyncIcon";
+import { fetchNip05Pubkey } from "@snort/shared";
+import { ZapTarget } from "Zapper";
+import { useNoteCreator } from "State/NoteCreator";
+
+export function NoteCreator() {
+ const { formatMessage } = useIntl();
+ const uploader = useFileUpload();
+ const login = useLogin(s => ({ relays: s.relays, publicKey: s.publicKey, pow: s.preferences.pow }));
+ const publisher = login.pow ? useEventPublisher()?.pow(login.pow, new WasmPowWorker()) : useEventPublisher();
+ const note = useNoteCreator();
+ const relays = login.relays;
+
+ async function buildNote() {
+ try {
+ note.update(v => (v.error = ""));
+ if (note && publisher) {
+ let extraTags: Array> | undefined;
+ if (note.zapSplits) {
+ const parsedSplits = [] as Array;
+ 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}",
+ },
+ {
+ 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}",
+ },
+ {
+ input: s.value,
+ },
+ ),
+ );
+ }
+ } else {
+ throw new Error(
+ formatMessage(
+ {
+ defaultMessage: "Invalid zap split: {input}",
+ },
+ {
+ 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]));
+ }
+ const hk = (eb: EventBuilder) => {
+ 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 sendEventToRelays(ev: NostrEvent) {
+ if (note.selectedCustomRelays) {
+ await Promise.all(note.selectedCustomRelays.map(r => System.WriteOnceToRelay(r, ev)));
+ } else {
+ System.BroadcastEvent(ev);
+ }
+ }
+
+ async function sendNote() {
+ const ev = await buildNote();
+ if (ev) {
+ await sendEventToRelays(ev);
+ for (const oe of note.otherEvents ?? []) {
+ await sendEventToRelays(oe);
+ }
+ note.update(v => {
+ v.reset();
+ v.show = false;
+ });
+ }
+ }
+
+ 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 | Blob) {
+ 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()}`;
+ 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}`;
+ } 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) {
+ const { value } = ev.target;
+ note.update(n => (n.note = 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();
+ note.update(v => (v.preview = tmpNote));
+ }
+ }
+
+ function getPreviewNote() {
+ if (note.preview) {
+ return (
+
+ );
+ }
+ }
+
+ function renderPollOptions() {
+ if (note.pollOptions) {
+ return (
+ <>
+
+
+
+ {note.pollOptions?.map((a, i) => (
+
+
+
+
+
+ changePollOption(i, e.target.value)} />
+ {i > 1 && (
+ removePollOption(i)} className="ml5">
+
+
+ )}
+
+
+ ))}
+ note.update(v => (v.pollOptions = [...(note.pollOptions ?? []), ""]))}>
+
+
+ >
+ );
+ }
+ }
+
+ 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 (
+
+ {Object.keys(relays.item || {})
+ .filter(el => relays.item[el].write)
+ .map((r, i, a) => (
+
+
{r}
+
+ {
+ 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),
+ )),
+ );
+ }}
+ />
+
+
+ ))}
+
+ );
+ }
+
+ /*function listAccounts() {
+ return LoginStore.getSessions().map(a => (
+ {
+ ev.stopPropagation = true;
+ LoginStore.switchAccount(a);
+ }}>
+
+
+ ));
+ }*/
+
+ const handlePaste: ClipboardEventHandler = evt => {
+ if (evt.clipboardData) {
+ const clipboardItems = evt.clipboardData.items;
+ const items: DataTransferItem[] = Array.from(clipboardItems).filter(function (item: DataTransferItem) {
+ // Filter the image items only
+ return /^image\//.test(item.type);
+ });
+ if (items.length === 0) {
+ return;
+ }
+
+ const item = items[0];
+ const blob = item.getAsFile();
+ if (blob) {
+ uploadFile(blob);
+ }
+ }
+ };
+
+ if (!note.show) return null;
+ return (
+ note.update(v => (v.show = false))}>
+ {note.replyTo && (
+
+ )}
+ {note.preview && getPreviewNote()}
+ {!note.preview && (
+
+
+ )}
+
+
+
+ {note.pollOptions === undefined && !note.replyTo && (
+
+ note.update(v => (v.pollOptions = ["A", "B"]))} size={24} />
+
+ )}
+
+
note.update(v => (v.advanced = !v.advanced))}>
+
+
+
+
+
+
+
+
+ {note.replyTo ? : }
+
+
+
+ {note.error && {note.error} }
+ {note.advanced && (
+ <>
+
+
+
+
+
+
+
+
+
+
+ {renderRelayCustomisation()}
+
+
+
+
+
+
+
+ {[...(note.zapSplits ?? [])].map((v, i, arr) => (
+
+
+
+
+
+
+ note.update(
+ v => (v.zapSplits = arr.map((vv, ii) => (ii === i ? { ...vv, value: e.target.value } : vv))),
+ )
+ }
+ placeholder={formatMessage({ defaultMessage: "npub / nprofile / nostr address" })}
+ />
+
+
+
+
+
+
+ note.update(
+ v =>
+ (v.zapSplits = arr.map((vv, ii) =>
+ ii === i ? { ...vv, weight: Number(e.target.value) } : vv,
+ )),
+ )
+ }
+ />
+
+
+
+
note.update(v => (v.zapSplits = (v.zapSplits ?? []).filter((_v, ii) => ii !== i)))}
+ />
+
+
+ ))}
+
+ note.update(v => (v.zapSplits = [...(v.zapSplits ?? []), { type: "pubkey", value: "", weight: 1 }]))
+ }>
+
+
+
+
+
+
+
+
+
+
+
+
+ note.update(v => (v.sensitive = e.target.value))}
+ maxLength={50}
+ minLength={1}
+ placeholder={formatMessage({
+ defaultMessage: "Reason",
+ })}
+ />
+
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/packages/app/src/Element/NoteFooter.tsx b/packages/app/src/Element/Event/NoteFooter.tsx
similarity index 64%
rename from packages/app/src/Element/NoteFooter.tsx
rename to packages/app/src/Element/Event/NoteFooter.tsx
index 6e74f1f1..eed07d57 100644
--- a/packages/app/src/Element/NoteFooter.tsx
+++ b/packages/app/src/Element/Event/NoteFooter.tsx
@@ -1,19 +1,15 @@
-import React, { HTMLProps, useEffect, useState } from "react";
-import { useSelector, useDispatch } from "react-redux";
+import React, { HTMLProps, useContext, useEffect, useState } from "react";
import { useIntl } from "react-intl";
import { useLongPress } from "use-long-press";
-import { TaggedNostrEvent, HexKey, u256, ParsedZap, countLeadingZeros } from "@snort/system";
-import { LNURL } from "@snort/shared";
-import { useUserProfile } from "@snort/system-react";
+import { TaggedNostrEvent, ParsedZap, countLeadingZeros, NostrLink } from "@snort/system";
+import { SnortContext, useUserProfile } from "@snort/system-react";
import { formatShort } from "Number";
-import useEventPublisher from "Feed/EventPublisher";
-import { delay, findTag, normalizeReaction, unwrap } from "SnortUtils";
-import { NoteCreator } from "Element/NoteCreator";
+import useEventPublisher from "Hooks/useEventPublisher";
+import { delay, findTag, normalizeReaction } from "SnortUtils";
+import { NoteCreator } from "Element/Event/NoteCreator";
import SendSats from "Element/SendSats";
-import { ZapsSummary } from "Element/Zap";
-import { RootState } from "State/Store";
-import { setReplyTo, setShow, reset } from "State/NoteCreator";
+import { ZapsSummary } from "Element/Event/Zap";
import { AsyncIcon } from "Element/AsyncIcon";
import { useWallet } from "Wallet";
@@ -21,8 +17,11 @@ import useLogin from "Hooks/useLogin";
import { useInteractionCache } from "Hooks/useInteractionCache";
import { ZapPoolController } from "ZapPoolController";
import { System } from "index";
+import { Zapper, ZapTarget } from "Zapper";
+import { getDisplayName } from "../User/ProfileImage";
+import { useNoteCreator } from "State/NoteCreator";
-import messages from "./messages";
+import messages from "../messages";
let isZapperBusy = false;
const barrierZapper = async (then: () => Promise): Promise => {
@@ -46,21 +45,24 @@ export interface NoteFooterProps {
export default function NoteFooter(props: NoteFooterProps) {
const { ev, positive, reposts, zaps } = props;
- const dispatch = useDispatch();
+ const system = useContext(SnortContext);
const { formatMessage } = useIntl();
- const login = useLogin();
- const { publicKey, preferences: prefs, relays } = login;
+ const {
+ publicKey,
+ preferences: prefs,
+ readonly,
+ } = useLogin(s => ({ preferences: s.preferences, publicKey: s.publicKey, readonly: s.readonly }));
const author = useUserProfile(ev.pubkey);
const interactionCache = useInteractionCache(publicKey, ev.id);
const publisher = useEventPublisher();
- const showNoteCreatorModal = useSelector((s: RootState) => s.noteCreator.show);
- const replyTo = useSelector((s: RootState) => s.noteCreator.replyTo);
- const willRenderNoteCreator = showNoteCreatorModal && replyTo?.id === ev.id;
+ const note = useNoteCreator(n => ({ show: n.show, replyTo: n.replyTo, update: n.update }));
+ const willRenderNoteCreator = note.show && note.replyTo?.id === ev.id;
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);
@@ -71,7 +73,7 @@ export default function NoteFooter(props: NoteFooterProps) {
},
{
captureEvent: true,
- }
+ },
);
function hasReacted(emoji: string) {
@@ -103,27 +105,36 @@ export default function NoteFooter(props: NoteFooterProps) {
}
}
- function getLNURL() {
- return ev.tags.find(a => a[0] === "zap")?.[1] || author?.lud16 || author?.lud06;
- }
+ function getZapTarget(): Array | undefined {
+ if (ev.tags.some(v => v[0] === "zap")) {
+ return Zapper.fromEvent(ev);
+ }
- function getTargetName() {
- const zapTarget = ev.tags.find(a => a[0] === "zap")?.[1];
- if (zapTarget) {
- return new LNURL(zapTarget).name;
- } else {
- return author?.display_name || author?.name;
+ 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 = getLNURL();
- if (wallet?.isReady() && lnurl) {
+ const lnurl = getZapTarget();
+ if (canFastZap && lnurl) {
setZapping(true);
try {
- await fastZapInner(lnurl, prefs.defaultZapAmount, ev.pubkey, ev.id);
+ await fastZapInner(lnurl, prefs.defaultZapAmount);
} catch (e) {
console.warn("Fast zap failed", e);
if (!(e instanceof Error) || e.message !== "User rejected") {
@@ -137,30 +148,29 @@ export default function NoteFooter(props: NoteFooterProps) {
}
}
- async function fastZapInner(lnurl: string, amount: number, key: HexKey, id?: u256) {
- // only allow 1 invoice req/payment at a time to avoid hitting rate limits
- await barrierZapper(async () => {
- const handler = new LNURL(lnurl);
- await handler.load();
-
- const zr = Object.keys(relays.item);
- const zap = handler.canZap && publisher ? await publisher.zap(amount * 1000, key, zr, id) : undefined;
- const invoice = await handler.getInvoice(amount, undefined, zap);
- await wallet?.payInvoice(unwrap(invoice.pr));
- ZapPoolController.allocate(amount);
-
- await interactionCache.zap();
- });
+ async function fastZapInner(targets: Array, 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) {
+ ZapPoolController.allocate(totalSent);
+ await interactionCache.zap();
+ }
+ });
+ }
}
useEffect(() => {
if (prefs.autoZap && !didZap && !isMine && !zapping) {
- const lnurl = getLNURL();
+ const lnurl = getZapTarget();
if (wallet?.isReady() && lnurl) {
setZapping(true);
queueMicrotask(async () => {
try {
- await fastZapInner(lnurl, prefs.defaultZapAmount, ev.pubkey, ev.id);
+ await fastZapInner(lnurl, prefs.defaultZapAmount);
} catch {
// ignored
} finally {
@@ -181,14 +191,14 @@ export default function NoteFooter(props: NoteFooterProps) {
}
function tipButton() {
- const service = getLNURL();
- if (service) {
+ const targets = getZapTarget();
+ if (targets) {
return (
fastZap(e)}
/>
@@ -198,13 +208,17 @@ export default function NoteFooter(props: NoteFooterProps) {
}
function repostIcon() {
+ if (readonly) return;
return (
repost()}
+ onClick={async () => {
+ if (readonly) return;
+ await repost();
+ }}
/>
);
}
@@ -220,15 +234,19 @@ export default function NoteFooter(props: NoteFooterProps) {
iconName={reacted ? "heart-solid" : "heart"}
title={formatMessage({ defaultMessage: "Like" })}
value={positive.length}
- onClick={() => react(prefs.reactionEmoji)}
+ onClick={async () => {
+ if (readonly) return;
+ await react(prefs.reactionEmoji);
+ }}
/>
);
}
function replyIcon() {
+ if (readonly) return;
return (
{
- if (replyTo?.id !== ev.id) {
- dispatch(reset());
- }
-
- dispatch(setReplyTo(ev));
- dispatch(setShow(!showNoteCreatorModal));
+ note.update(v => {
+ if (v.replyTo?.id !== ev.id) {
+ v.reset();
+ }
+ v.show = true;
+ v.replyTo = ev;
+ });
};
return (
<>
- {tipButton()}
- {reactionIcon()}
- {repostIcon()}
{replyIcon()}
+ {repostIcon()}
+ {reactionIcon()}
+ {tipButton()}
{powIcon()}
- {willRenderNoteCreator &&
}
-
setTip(false)}
- show={tip}
- author={author?.pubkey}
- target={getTargetName()}
- note={ev.id}
- allocatePool={true}
- />
+ {willRenderNoteCreator && }
+ setTip(false)} show={tip} note={ev.id} allocatePool={true} />
>
diff --git a/packages/app/src/Element/NoteGhost.tsx b/packages/app/src/Element/Event/NoteGhost.tsx
similarity index 89%
rename from packages/app/src/Element/NoteGhost.tsx
rename to packages/app/src/Element/Event/NoteGhost.tsx
index 1604f51c..fcdfac73 100644
--- a/packages/app/src/Element/NoteGhost.tsx
+++ b/packages/app/src/Element/Event/NoteGhost.tsx
@@ -1,5 +1,5 @@
import "./Note.css";
-import ProfileImage from "Element/ProfileImage";
+import ProfileImage from "Element/User/ProfileImage";
interface NoteGhostProps {
className?: string;
diff --git a/packages/app/src/Element/NoteQuote.tsx b/packages/app/src/Element/Event/NoteQuote.tsx
similarity index 83%
rename from packages/app/src/Element/NoteQuote.tsx
rename to packages/app/src/Element/Event/NoteQuote.tsx
index 262b1ca6..cba78dcb 100644
--- a/packages/app/src/Element/NoteQuote.tsx
+++ b/packages/app/src/Element/Event/NoteQuote.tsx
@@ -1,6 +1,6 @@
-import useEventFeed from "Feed/EventFeed";
+import { useEventFeed } from "Feed/EventFeed";
import { NostrLink } from "@snort/system";
-import Note from "Element/Note";
+import Note from "Element/Event/Note";
import PageSpinner from "Element/PageSpinner";
export default function NoteQuote({ link, depth }: { link: NostrLink; depth?: number }) {
diff --git a/packages/app/src/Element/NoteReaction.css b/packages/app/src/Element/Event/NoteReaction.css
similarity index 100%
rename from packages/app/src/Element/NoteReaction.css
rename to packages/app/src/Element/Event/NoteReaction.css
diff --git a/packages/app/src/Element/NoteReaction.tsx b/packages/app/src/Element/Event/NoteReaction.tsx
similarity index 71%
rename from packages/app/src/Element/NoteReaction.tsx
rename to packages/app/src/Element/Event/NoteReaction.tsx
index 060c7f1c..a398c335 100644
--- a/packages/app/src/Element/NoteReaction.tsx
+++ b/packages/app/src/Element/Event/NoteReaction.tsx
@@ -1,24 +1,27 @@
import "./NoteReaction.css";
import { Link } from "react-router-dom";
import { useMemo } from "react";
-import { EventKind, NostrEvent, TaggedNostrEvent, NostrPrefix } from "@snort/system";
+import { EventKind, NostrEvent, TaggedNostrEvent, NostrPrefix, EventExt } from "@snort/system";
-import Note from "Element/Note";
-import { getDisplayName } from "Element/ProfileImage";
+import Note from "Element/Event/Note";
+import { getDisplayName } from "Element/User/ProfileImage";
import { eventLink, hexToBech32 } from "SnortUtils";
import useModeration from "Hooks/useModeration";
-import { FormattedMessage } from "react-intl";
+import FormattedMessage from "Element/FormattedMessage";
import Icon from "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 profile = useUserProfile(ev.pubkey);
+ const { inView, ref } = useInView({ triggerOnce: true });
+ const profile = useUserProfile(inView ? ev.pubkey : "");
const refEvent = useMemo(() => {
if (ev) {
@@ -43,9 +46,15 @@ export default function NoteReaction(props: NoteReactionProps) {
* 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);
@@ -54,7 +63,11 @@ export default function NoteReaction(props: NoteReactionProps) {
return props.root;
}
- const root = extractRoot();
+ const root = useMemo(() => extractRoot(), [ev, props.root, inView]);
+
+ if (!inView) {
+ return
;
+ }
const isOpMuted = root && isMuted(root.pubkey);
const shouldNotBeRendered = isOpMuted || root?.kind !== EventKind.TextNote;
const opt = {
@@ -73,7 +86,7 @@ export default function NoteReaction(props: NoteReactionProps) {
}}
/>
- {root ? : null}
+ {root ? : null}
{!root && refEvent ? (
diff --git a/packages/app/src/Element/NoteTime.tsx b/packages/app/src/Element/Event/NoteTime.tsx
similarity index 100%
rename from packages/app/src/Element/NoteTime.tsx
rename to packages/app/src/Element/Event/NoteTime.tsx
diff --git a/packages/app/src/Element/Poll.tsx b/packages/app/src/Element/Event/Poll.tsx
similarity index 65%
rename from packages/app/src/Element/Poll.tsx
rename to packages/app/src/Element/Event/Poll.tsx
index 128077a0..950dbf46 100644
--- a/packages/app/src/Element/Poll.tsx
+++ b/packages/app/src/Element/Event/Poll.tsx
@@ -4,8 +4,7 @@ import { useState } from "react";
import { FormattedMessage, FormattedNumber, useIntl } from "react-intl";
import { useUserProfile } from "@snort/system-react";
-import Text from "Element/Text";
-import useEventPublisher from "Feed/EventPublisher";
+import useEventPublisher from "Hooks/useEventPublisher";
import { useWallet } from "Wallet";
import { unwrap } from "SnortUtils";
import { formatShort } from "Number";
@@ -18,12 +17,15 @@ interface PollProps {
zaps: Array;
}
+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();
const pollerProfile = useUserProfile(props.ev.pubkey);
+ const [tallyBy, setTallyBy] = useState("pubkeys");
const [error, setError] = useState("");
const [invoice, setInvoice] = useState("");
const [voting, setVoting] = useState();
@@ -31,7 +33,10 @@ export default function Poll(props: PollProps) {
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]));
+ 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;
@@ -46,15 +51,15 @@ export default function Poll(props: PollProps) {
},
{
amount,
- }
- )
+ },
+ ),
);
}
setVoting(opt);
const r = Object.keys(relays.item);
const zap = await publisher.zap(amount * 1000, props.ev.pubkey, r, props.ev.id, undefined, eb =>
- eb.tag(["poll_option", opt.toString()])
+ eb.tag(["poll_option", opt.toString()]),
);
const lnurl = props.ev.tags.find(a => a[0] === "zap")?.[1] || pollerProfile?.lud16 || pollerProfile?.lud06;
@@ -67,7 +72,7 @@ export default function Poll(props: PollProps) {
throw new Error(
formatMessage({
defaultMessage: "Can't vote because LNURL service does not support zaps",
- })
+ }),
);
}
@@ -84,7 +89,7 @@ export default function Poll(props: PollProps) {
setError(
formatMessage({
defaultMessage: "Failed to send vote",
- })
+ }),
);
}
} finally {
@@ -92,33 +97,57 @@ export default function Poll(props: PollProps) {
}
}
- const allTotal = props.zaps.filter(a => a.pollOption !== undefined).reduce((acc, v) => (acc += v.amount), 0);
+ 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 (
<>
-
-
-
+
+
+
+
+ setTallyBy(s => (s !== "zaps" ? "zaps" : "pubkeys"))}>
+
+ ) : (
+
+ ),
+ }}
+ />
+
+
{options.map(a => {
const opt = Number(a[1]);
const desc = a[2];
const zapsOnOption = props.zaps.filter(b => b.pollOption === opt);
- const total = zapsOnOption.reduce((acc, v) => (acc += v.amount), 0);
- const weight = allTotal === 0 ? 0 : total / allTotal;
+ 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 (
zapVote(e, opt)}>
-
- {opt === voting ? (
-
- ) : (
-
- )}
-
+
{opt === voting ? : <>{desc}>}
{showResults && (
<>
diff --git a/packages/app/src/Element/Reactions.css b/packages/app/src/Element/Event/Reactions.css
similarity index 77%
rename from packages/app/src/Element/Reactions.css
rename to packages/app/src/Element/Event/Reactions.css
index 24f93276..1ce3ca5f 100644
--- a/packages/app/src/Element/Reactions.css
+++ b/packages/app/src/Element/Event/Reactions.css
@@ -29,11 +29,31 @@
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: 32px;
+ 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 {
@@ -47,8 +67,11 @@
.reactions-modal .modal-body .reactions-body {
overflow: scroll;
height: 40vh;
- -ms-overflow-style: none; /* for Internet Explorer, Edge */
- scrollbar-width: none; /* Firefox */
+ -ms-overflow-style: none;
+ /* for Internet Explorer, Edge */
+ scrollbar-width: none;
+ /* Firefox */
+ margin-top: 12px;
}
.reactions-modal .modal-body .reactions-body::-webkit-scrollbar {
diff --git a/packages/app/src/Element/Reactions.tsx b/packages/app/src/Element/Event/Reactions.tsx
similarity index 95%
rename from packages/app/src/Element/Reactions.tsx
rename to packages/app/src/Element/Event/Reactions.tsx
index cd755b26..a30d4d85 100644
--- a/packages/app/src/Element/Reactions.tsx
+++ b/packages/app/src/Element/Event/Reactions.tsx
@@ -7,11 +7,11 @@ import { TaggedNostrEvent, ParsedZap } from "@snort/system";
import { formatShort } from "Number";
import Icon from "Icons/Icon";
import { Tab } from "Element/Tabs";
-import ProfileImage from "Element/ProfileImage";
+import ProfileImage from "Element/User/ProfileImage";
import Tabs from "Element/Tabs";
import Modal from "Element/Modal";
-import messages from "./messages";
+import messages from "../messages";
interface ReactionsProps {
show: boolean;
@@ -60,7 +60,7 @@ const Reactions = ({ show, setShow, positive, negative, reposts, zaps }: Reactio
value: 3,
},
]
- : []
+ : [],
);
const [tab, setTab] = useState(tabs[0]);
@@ -72,7 +72,7 @@ const Reactions = ({ show, setShow, positive, negative, reposts, zaps }: Reactio
}, [show]);
return show ? (
-
+
diff --git a/packages/app/src/Element/Reveal.tsx b/packages/app/src/Element/Event/Reveal.tsx
similarity index 71%
rename from packages/app/src/Element/Reveal.tsx
rename to packages/app/src/Element/Event/Reveal.tsx
index 01f6cc7d..0eb2d9f3 100644
--- a/packages/app/src/Element/Reveal.tsx
+++ b/packages/app/src/Element/Event/Reveal.tsx
@@ -1,3 +1,5 @@
+import "../Reveal.css";
+import Icon from "Icons/Icon";
import { useState } from "react";
interface RevealProps {
@@ -15,8 +17,9 @@ export default function Reveal(props: RevealProps): JSX.Element {
e.stopPropagation();
setReveal(true);
}}
- className="note-invoice">
- {props.message}
+ className="note-notice flex g8">
+
+ {props.message}
);
} else {
diff --git a/packages/app/src/Element/RevealMedia.tsx b/packages/app/src/Element/Event/RevealMedia.tsx
similarity index 71%
rename from packages/app/src/Element/RevealMedia.tsx
rename to packages/app/src/Element/Event/RevealMedia.tsx
index bf7e39d2..e55e175f 100644
--- a/packages/app/src/Element/RevealMedia.tsx
+++ b/packages/app/src/Element/Event/RevealMedia.tsx
@@ -1,9 +1,10 @@
-import { FormattedMessage } from "react-intl";
+import FormattedMessage from "Element/FormattedMessage";
import { FileExtensionRegex } from "Const";
-import Reveal from "Element/Reveal";
+import Reveal from "Element/Event/Reveal";
import useLogin from "Hooks/useLogin";
-import { MediaElement } from "Element/MediaElement";
+import { MediaElement } from "Element/Embed/MediaElement";
+import { Link } from "react-router-dom";
interface RevealMediaProps {
creator: string;
@@ -51,7 +52,16 @@ export default function RevealMedia(props: RevealMediaProps) {
if (hideMedia) {
return (
}>
+ message={
+
{i} ,
+ a: a => {a},
+ link: hostname,
+ }}
+ />
+ }>
);
diff --git a/packages/app/src/Element/ShowMore.css b/packages/app/src/Element/Event/ShowMore.css
similarity index 100%
rename from packages/app/src/Element/ShowMore.css
rename to packages/app/src/Element/Event/ShowMore.css
diff --git a/packages/app/src/Element/ShowMore.tsx b/packages/app/src/Element/Event/ShowMore.tsx
similarity index 94%
rename from packages/app/src/Element/ShowMore.tsx
rename to packages/app/src/Element/Event/ShowMore.tsx
index 5d4bcf78..ce120626 100644
--- a/packages/app/src/Element/ShowMore.tsx
+++ b/packages/app/src/Element/Event/ShowMore.tsx
@@ -1,7 +1,7 @@
import "./ShowMore.css";
import { useIntl } from "react-intl";
-import messages from "./messages";
+import messages from "../messages";
interface ShowMoreProps {
text?: string;
diff --git a/packages/app/src/Element/Thread.css b/packages/app/src/Element/Event/Thread.css
similarity index 79%
rename from packages/app/src/Element/Thread.css
rename to packages/app/src/Element/Event/Thread.css
index 4d2c6d2e..a11f6a24 100644
--- a/packages/app/src/Element/Thread.css
+++ b/packages/app/src/Element/Event/Thread.css
@@ -8,7 +8,8 @@
}
.thread-root.note > .body .text {
- font-size: 19px;
+ font-size: 18px;
+ line-height: 27px;
}
.thread-root.note > .footer {
@@ -55,54 +56,46 @@
position: absolute;
left: calc(48px / 2 + 16px);
top: 48px;
- border-left: 1px solid var(--gray-superdark);
+ border-left: 1px solid var(--border-color);
height: 100%;
- z-index: -1;
+ z-index: 1;
}
.subthread-container.subthread-mid:not(.subthread-last) .line-container:before {
content: "";
position: absolute;
- border-left: 1px solid var(--gray-superdark);
+ border-left: 1px solid var(--border-color);
left: calc(48px / 2 + 16px);
top: 0;
height: 48px;
- z-index: -1;
+ z-index: 1;
}
.subthread-container.subthread-last .line-container:before {
content: "";
position: absolute;
- border-left: 1px solid var(--gray-superdark);
+ border-left: 1px solid var(--border-color);
left: calc(48px / 2 + 16px);
top: 0;
height: 48px;
- z-index: -1;
-}
-
-.divider-container {
- margin-right: 16px;
+ z-index: 1;
}
.divider {
height: 1px;
- background: var(--gray-superdark);
+ background: var(--border-color);
}
.divider.divider-small {
margin-left: calc(16px + 61px);
+ margin-right: 16px;
}
.thread-container .collapsed,
.thread-container .show-more-container {
- background: var(--gray-superdark);
min-height: 48px;
}
-.thread-container .collapsed {
- background-color: var(--gray-superdark);
-}
-
.thread-container .hidden-note {
padding-left: 48px;
}
diff --git a/packages/app/src/Element/Thread.tsx b/packages/app/src/Element/Event/Thread.tsx
similarity index 63%
rename from packages/app/src/Element/Thread.tsx
rename to packages/app/src/Element/Event/Thread.tsx
index ee6512b5..6fd8192c 100644
--- a/packages/app/src/Element/Thread.tsx
+++ b/packages/app/src/Element/Event/Thread.tsx
@@ -1,25 +1,17 @@
import "./Thread.css";
-import { useMemo, useState, ReactNode } from "react";
+import { useMemo, useState, ReactNode, useContext } from "react";
import { useIntl } from "react-intl";
-import { useNavigate, useLocation, Link, useParams } from "react-router-dom";
-import {
- TaggedNostrEvent,
- u256,
- EventKind,
- NostrPrefix,
- EventExt,
- Thread as ThreadInfo,
- parseNostrLink,
-} from "@snort/system";
+import { useNavigate, useParams } from "react-router-dom";
+import { TaggedNostrEvent, u256, NostrPrefix, EventExt, parseNostrLink } from "@snort/system";
-import { eventLink, unwrap, getReactions, getAllReactions, findTag } from "SnortUtils";
+import { getReactions, getAllReactions } from "SnortUtils";
import BackButton from "Element/BackButton";
-import Note from "Element/Note";
-import NoteGhost from "Element/NoteGhost";
+import Note from "Element/Event/Note";
+import NoteGhost from "Element/Event/NoteGhost";
import Collapsed from "Element/Collapsed";
-import useThreadFeed from "Feed/ThreadFeed";
+import { ThreadContext, ThreadContextWrapper, chainKey } from "Hooks/useThreadContext";
-import messages from "./messages";
+import messages from "../messages";
interface DividerProps {
variant?: "regular" | "small";
@@ -213,110 +205,39 @@ const TierThree = ({ active, isLastSubthread, notes, related, chains, onNavigate
);
};
-export default function Thread() {
+export function ThreadRoute() {
const params = useParams();
- const location = useLocation();
-
const link = parseNostrLink(params.id ?? "", NostrPrefix.Note);
- const thread = useThreadFeed(link);
- const [currentId, setCurrentId] = useState(link.id);
+ return (
+
+
+
+ );
+}
+
+export function Thread(props: { onBack?: () => void; disableSpotlight?: boolean }) {
+ const thread = useContext(ThreadContext);
const navigate = useNavigate();
- const isSingleNote = thread.data?.filter(a => a.kind === EventKind.TextNote).length === 1;
+ const isSingleNote = thread.chains?.size === 1 && [thread.chains.values].every(v => v.length === 0);
const { formatMessage } = useIntl();
function navigateThread(e: TaggedNostrEvent) {
- setCurrentId(e.id);
- //const link = encodeTLV(e.id, NostrPrefix.Event, e.relays);
+ thread.setCurrent(e.id);
+ //router.navigate(`/e/${NostrLink.fromEvent(e).encode()}`, { replace: true })
}
- const chains = useMemo(() => {
- const chains = new Map>();
- if (thread.data) {
- thread.data
- ?.filter(a => a.kind === EventKind.TextNote)
- .sort((a, b) => b.created_at - a.created_at)
- .forEach(v => {
- const t = EventExt.extractThread(v);
- let replyTo = t?.replyTo?.value ?? t?.root?.value;
- if (t?.root?.key === "a" && t?.root?.value) {
- const parsed = t.root.value.split(":");
- replyTo = thread.data?.find(
- a => a.kind === Number(parsed[0]) && a.pubkey === parsed[1] && findTag(a, "d") === parsed[2]
- )?.id;
- }
- if (replyTo) {
- if (!chains.has(replyTo)) {
- chains.set(replyTo, [v]);
- } else {
- unwrap(chains.get(replyTo)).push(v);
- }
- }
- });
- }
- return chains;
- }, [thread.data]);
-
- // Root is the parent of the current note or the current note if its a root note or the root of the thread
- const root = useMemo(() => {
- const currentNote =
- thread.data?.find(
- ne =>
- ne.id === currentId ||
- (link.type === NostrPrefix.Address && findTag(ne, "d") === currentId && ne.pubkey === link.author)
- ) ?? (location.state && "sig" in location.state ? (location.state as TaggedNostrEvent) : undefined);
- if (currentNote) {
- const currentThread = EventExt.extractThread(currentNote);
- const isRoot = (ne?: ThreadInfo) => ne === undefined;
-
- if (isRoot(currentThread)) {
- return currentNote;
- }
- const replyTo = currentThread?.replyTo ?? currentThread?.root;
-
- // sometimes the root event ID is missing, and we can only take the happy path if the root event ID exists
- if (replyTo) {
- if (replyTo.key === "a" && replyTo.value) {
- const parsed = replyTo.value.split(":");
- return thread.data?.find(
- a => a.kind === Number(parsed[0]) && a.pubkey === parsed[1] && findTag(a, "d") === parsed[2]
- );
- }
- if (replyTo.value) {
- return thread.data?.find(a => a.id === replyTo.value);
- }
- }
-
- const possibleRoots = thread.data?.filter(a => {
- const thread = EventExt.extractThread(a);
- return isRoot(thread);
- });
- if (possibleRoots) {
- // worst case we need to check every possible root to see which one contains the current note as a child
- for (const ne of possibleRoots) {
- const children = chains.get(ne.id) ?? [];
-
- if (children.find(ne => ne.id === currentId)) {
- return ne;
- }
- }
- }
- }
- }, [thread.data, currentId, location]);
-
const parent = useMemo(() => {
- if (root) {
- const currentThread = EventExt.extractThread(root);
+ if (thread.root) {
+ const currentThread = EventExt.extractThread(thread.root);
return (
currentThread?.replyTo?.value ??
currentThread?.root?.value ??
(currentThread?.root?.key === "a" && currentThread.root?.value)
);
}
- }, [root]);
-
- const brokenChains = Array.from(chains?.keys()).filter(a => !thread.data?.some(b => b.id === a));
+ }, [thread.root]);
function renderRoot(note: TaggedNostrEvent) {
const className = `thread-root${isSingleNote ? " thread-root-single" : ""}`;
@@ -326,8 +247,8 @@ export default function Thread() {
className={className}
key={note.id}
data={note}
- related={getReactions(thread.data, note.id)}
- options={{ showReactionsLink: true }}
+ related={getReactions(thread.reactions, note.id)}
+ options={{ showReactionsLink: true, showMediaSpotlight: !props.disableSpotlight }}
onClick={navigateThread}
/>
);
@@ -337,20 +258,20 @@ export default function Thread() {
}
function renderChain(from: u256): ReactNode {
- if (!from || !chains) {
+ if (!from || thread.chains.size === 0) {
return;
}
- const replies = chains.get(from);
- if (replies && currentId) {
+ const replies = thread.chains.get(from);
+ if (replies && thread.current) {
return (
a.id)
+ thread.reactions,
+ replies.map(a => a.id),
)}
- chains={chains}
+ chains={thread.chains}
onNavigate={navigateThread}
/>
);
@@ -359,7 +280,9 @@ export default function Thread() {
function goBack() {
if (parent) {
- setCurrentId(parent);
+ thread.setCurrent(parent);
+ } else if (props.onBack) {
+ props.onBack();
} else {
navigate(-1);
}
@@ -373,26 +296,36 @@ export default function Thread() {
defaultMessage: "Back",
description: "Navigate back button on threads view",
});
+
+ const debug = window.location.search.includes("debug=true");
return (
<>
+ {debug && (
+
+
Chains
+
+ {JSON.stringify(
+ Object.fromEntries([...thread.chains.entries()].map(([k, v]) => [k, v.map(c => c.id)])),
+ undefined,
+ " ",
+ )}
+
+
Current
+
{JSON.stringify(thread.current)}
+
Root
+
{JSON.stringify(thread.root, undefined, " ")}
+
Data
+
{JSON.stringify(thread.data, undefined, " ")}
+
Reactions
+
{JSON.stringify(thread.reactions, undefined, " ")}
+
+ )}
- {root && renderRoot(root)}
- {root && renderChain(root.id)}
-
- {brokenChains.length > 0 &&
Other replies }
- {brokenChains.map(a => {
- return (
-
-
- Missing event {a.substring(0, 8)}
-
- {renderChain(a)}
-
- );
- })}
+ {thread.root && renderRoot(thread.root)}
+ {thread.root && renderChain(chainKey(thread.root))}
>
);
diff --git a/packages/app/src/Element/Zap.css b/packages/app/src/Element/Event/Zap.css
similarity index 100%
rename from packages/app/src/Element/Zap.css
rename to packages/app/src/Element/Event/Zap.css
diff --git a/packages/app/src/Element/Zap.tsx b/packages/app/src/Element/Event/Zap.tsx
similarity index 81%
rename from packages/app/src/Element/Zap.tsx
rename to packages/app/src/Element/Event/Zap.tsx
index 84e82895..13232836 100644
--- a/packages/app/src/Element/Zap.tsx
+++ b/packages/app/src/Element/Event/Zap.tsx
@@ -6,30 +6,26 @@ import { FormattedMessage, useIntl } from "react-intl";
import { unwrap } from "SnortUtils";
import { formatShort } from "Number";
import Text from "Element/Text";
-import ProfileImage from "Element/ProfileImage";
+import ProfileImage from "Element/User/ProfileImage";
import useLogin from "Hooks/useLogin";
-import messages from "./messages";
+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 ? (
-
-
+
+
{receiver !== pubKey && showZapped &&
}
-
-
-
-
-
+
+
+
{(content?.length ?? 0) > 0 && sender && (
-
-
-
+
)}
) : null;
@@ -63,6 +59,7 @@ export const ZapsSummary = ({ zaps }: ZapsSummaryProps) => {
{sender && (
)}
diff --git a/packages/app/src/Element/ZapButton.css b/packages/app/src/Element/Event/ZapButton.css
similarity index 100%
rename from packages/app/src/Element/ZapButton.css
rename to packages/app/src/Element/Event/ZapButton.css
diff --git a/packages/app/src/Element/ZapButton.tsx b/packages/app/src/Element/Event/ZapButton.tsx
similarity index 64%
rename from packages/app/src/Element/ZapButton.tsx
rename to packages/app/src/Element/Event/ZapButton.tsx
index d5ea6111..873a72f8 100644
--- a/packages/app/src/Element/ZapButton.tsx
+++ b/packages/app/src/Element/Event/ZapButton.tsx
@@ -5,6 +5,7 @@ import { useUserProfile } from "@snort/system-react";
import SendSats from "Element/SendSats";
import Icon from "Icons/Icon";
+import { ZapTarget } from "Zapper";
const ZapButton = ({
pubkey,
@@ -24,16 +25,22 @@ const ZapButton = ({
return (
<>
-
setZap(true)}>
-
+ setZap(true)}>
+
{children}
-
+
setZap(false)}
- author={pubkey}
note={event}
/>
>
diff --git a/packages/app/src/Element/ZapGoal.css b/packages/app/src/Element/Event/ZapGoal.css
similarity index 100%
rename from packages/app/src/Element/ZapGoal.css
rename to packages/app/src/Element/Event/ZapGoal.css
diff --git a/packages/app/src/Element/ZapGoal.tsx b/packages/app/src/Element/Event/ZapGoal.tsx
similarity index 63%
rename from packages/app/src/Element/ZapGoal.tsx
rename to packages/app/src/Element/Event/ZapGoal.tsx
index c78d41db..a871e5f9 100644
--- a/packages/app/src/Element/ZapGoal.tsx
+++ b/packages/app/src/Element/Event/ZapGoal.tsx
@@ -1,13 +1,16 @@
import "./ZapGoal.css";
-import { NostrEvent, NostrPrefix, createNostrLink } from "@snort/system";
+import { CSSProperties, useState } from "react";
+import { NostrEvent, NostrLink } from "@snort/system";
import useZapsFeed from "Feed/ZapsFeed";
import { formatShort } from "Number";
import { findTag } from "SnortUtils";
-import { CSSProperties } from "react";
-import ZapButton from "./ZapButton";
+import Icon from "Icons/Icon";
+import SendSats from "../SendSats";
+import { Zapper } from "Zapper";
export function ZapGoal({ ev }: { ev: NostrEvent }) {
- const zaps = useZapsFeed(createNostrLink(NostrPrefix.Note, ev.id));
+ 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 = 100 * (amount / target);
@@ -16,7 +19,10 @@ export function ZapGoal({ ev }: { ev: NostrEvent }) {
{ev.content}
-
+
setZap(true)}>
+
+
+
setZap(false)} />
diff --git a/packages/app/src/Element/LoadMore.tsx b/packages/app/src/Element/Feed/LoadMore.tsx
similarity index 89%
rename from packages/app/src/Element/LoadMore.tsx
rename to packages/app/src/Element/Feed/LoadMore.tsx
index 8982c375..eb4634bc 100644
--- a/packages/app/src/Element/LoadMore.tsx
+++ b/packages/app/src/Element/Feed/LoadMore.tsx
@@ -1,8 +1,8 @@
import { useEffect, useState } from "react";
-import { FormattedMessage } from "react-intl";
+import FormattedMessage from "Element/FormattedMessage";
import { useInView } from "react-intersection-observer";
-import messages from "./messages";
+import messages from "../messages";
export default function LoadMore({
onLoadMore,
diff --git a/packages/app/src/Element/Timeline.css b/packages/app/src/Element/Feed/Timeline.css
similarity index 89%
rename from packages/app/src/Element/Timeline.css
rename to packages/app/src/Element/Feed/Timeline.css
index 199cffbe..37e69b73 100644
--- a/packages/app/src/Element/Timeline.css
+++ b/packages/app/src/Element/Feed/Timeline.css
@@ -10,16 +10,16 @@
.latest-notes-fixed {
position: fixed;
- left: calc(50% - 261px / 2 + 0.5px);
+ left: 45%;
top: 12px;
- width: 261px;
- left: calc(50% - 261px / 2 + 0.5px);
+ width: auto;
z-index: 42;
opacity: 0.9;
box-shadow: 0px 0px 15px rgba(78, 0, 255, 0.6);
color: white;
background: var(--highlight);
border-radius: 100px;
+ border: none;
}
@media (max-width: 520px) {
@@ -36,6 +36,7 @@
margin: 0;
margin-right: -26px;
}
+
.latest-notes .pfp:last-of-type {
margin-right: -8px;
}
diff --git a/packages/app/src/Element/Timeline.tsx b/packages/app/src/Element/Feed/Timeline.tsx
similarity index 67%
rename from packages/app/src/Element/Timeline.tsx
rename to packages/app/src/Element/Feed/Timeline.tsx
index 4a48bf60..9b90fe9c 100644
--- a/packages/app/src/Element/Timeline.tsx
+++ b/packages/app/src/Element/Feed/Timeline.tsx
@@ -1,19 +1,15 @@
import "./Timeline.css";
-import { FormattedMessage } from "react-intl";
+import FormattedMessage from "Element/FormattedMessage";
import { useCallback, useMemo } from "react";
import { useInView } from "react-intersection-observer";
-import { TaggedNostrEvent, EventKind, u256, parseZap } from "@snort/system";
+import { TaggedNostrEvent, EventKind, u256 } from "@snort/system";
import Icon from "Icons/Icon";
-import { dedupeByPubkey, findTag, tagFilterOfTextRepost } from "SnortUtils";
-import ProfileImage from "Element/ProfileImage";
+import { dedupeByPubkey, findTag } from "SnortUtils";
+import ProfileImage from "Element/User/ProfileImage";
import useTimelineFeed, { TimelineFeed, TimelineSubject } from "Feed/TimelineFeed";
-import Zap from "Element/Zap";
-import Note from "Element/Note";
-import NoteReaction from "Element/NoteReaction";
+import Note from "Element/Event/Note";
import useModeration from "Hooks/useModeration";
-import ProfilePreview from "Element/ProfilePreview";
-import { UserCache } from "Cache";
import { LiveStreams } from "Element/LiveStreams";
export interface TimelineProps {
@@ -28,7 +24,7 @@ export interface TimelineProps {
}
/**
- * A list of notes by pubkeys
+ * A list of notes by "subject"
*/
const Timeline = (props: TimelineProps) => {
const feedOptions = useMemo(() => {
@@ -51,7 +47,7 @@ const Timeline = (props: TimelineProps) => {
?.filter(a => (props.postsOnly ? !a.tags.some(b => b[0] === "e") : true))
.filter(a => props.ignoreModeration || !isMuted(a.pubkey));
},
- [props.postsOnly, muted, props.ignoreModeration]
+ [props.postsOnly, muted, props.ignoreModeration],
);
const mainFeed = useMemo(() => {
@@ -64,50 +60,16 @@ const Timeline = (props: TimelineProps) => {
(id: u256) => {
return (feed.related ?? []).filter(a => findTag(a, "e") === id);
},
- [feed.related]
+ [feed.related],
);
const liveStreams = useMemo(() => {
return (feed.main ?? []).filter(a => a.kind === EventKind.LiveEvent && findTag(a, "status") === "live");
}, [feed]);
- const findRelated = useCallback(
- (id?: u256) => {
- if (!id) return undefined;
- return (feed.related ?? []).find(a => a.id === id);
- },
- [feed.related]
- );
const latestAuthors = useMemo(() => {
return dedupeByPubkey(latestFeed).map(e => e.pubkey);
}, [latestFeed]);
- function eventElement(e: TaggedNostrEvent) {
- switch (e.kind) {
- case EventKind.SetMetadata: {
- return
>} pubkey={e.pubkey} className="card" />;
- }
- case EventKind.Polls:
- case EventKind.TextNote: {
- const eRef = e.tags.find(tagFilterOfTextRepost(e))?.at(1);
- if (eRef) {
- return ;
- }
- return (
-
- );
- }
- case EventKind.ZapReceipt: {
- const zap = parseZap(e, UserCache);
- return zap.event ? null : ;
- }
- case EventKind.Reaction:
- case EventKind.Repost: {
- const eRef = findTag(e, "e");
- return ;
- }
- }
- }
-
function onShowLatest(scrollToTop = false) {
feed.showLatest();
if (scrollToTop) {
@@ -144,7 +106,9 @@ const Timeline = (props: TimelineProps) => {
)}
>
)}
- {mainFeed.map(eventElement)}
+ {mainFeed.map(e => (
+
+ ))}
{(props.loadMore === undefined || props.loadMore === true) && (
feed.loadMore()}>
diff --git a/packages/app/src/Element/Feed/TimelineFollows.tsx b/packages/app/src/Element/Feed/TimelineFollows.tsx
new file mode 100644
index 00000000..46b519fb
--- /dev/null
+++ b/packages/app/src/Element/Feed/TimelineFollows.tsx
@@ -0,0 +1,140 @@
+import "./Timeline.css";
+import { ReactNode, useCallback, useContext, useMemo, useState, useSyncExternalStore } from "react";
+import FormattedMessage from "Element/FormattedMessage";
+import { TaggedNostrEvent, EventKind, u256, NostrEvent, NostrLink } from "@snort/system";
+import { unixNow } from "@snort/shared";
+import { SnortContext } from "@snort/system-react";
+import { useInView } from "react-intersection-observer";
+
+import { dedupeByPubkey, findTag, orderDescending } from "SnortUtils";
+import Note from "Element/Event/Note";
+import useModeration from "Hooks/useModeration";
+import { FollowsFeed } from "Cache";
+import { LiveStreams } from "Element/LiveStreams";
+import { useReactions } from "Feed/Reactions";
+import AsyncButton from "../AsyncButton";
+import useLogin from "Hooks/useLogin";
+import ProfileImage from "Element/User/ProfileImage";
+import Icon from "Icons/Icon";
+
+export interface TimelineFollowsProps {
+ postsOnly: boolean;
+ liveStreams?: boolean;
+ noteFilter?: (ev: NostrEvent) => boolean;
+ noteRenderer?: (ev: NostrEvent) => ReactNode;
+ noteOnClick?: (ev: NostrEvent) => void;
+}
+
+/**
+ * A list of notes by "subject"
+ */
+const TimelineFollows = (props: TimelineFollowsProps) => {
+ const [latest, setLatest] = useState(unixNow());
+ const feed = useSyncExternalStore(
+ cb => FollowsFeed.hook(cb, "*"),
+ () => FollowsFeed.snapshot(),
+ );
+ const reactions = useReactions(
+ "follows-feed-reactions",
+ feed.map(a => NostrLink.fromEvent(a)),
+ );
+ const system = useContext(SnortContext);
+ const login = useLogin();
+ const { muted, isMuted } = useModeration();
+ const { ref, inView } = useInView();
+
+ const sortedFeed = useMemo(() => orderDescending(feed), [feed]);
+
+ const filterPosts = useCallback(
+ function (nts: Array) {
+ const a = nts.filter(a => a.kind !== EventKind.LiveEvent);
+ return a
+ ?.filter(a => (props.postsOnly ? !a.tags.some(b => b[0] === "e") : true))
+ .filter(a => !isMuted(a.pubkey) && login.follows.item.includes(a.pubkey) && (props.noteFilter?.(a) ?? true));
+ },
+ [props.postsOnly, muted, login.follows.timestamp],
+ );
+
+ const mainFeed = useMemo(() => {
+ return filterPosts((sortedFeed ?? []).filter(a => a.created_at <= latest));
+ }, [sortedFeed, filterPosts, latest, login.follows.timestamp]);
+
+ const latestFeed = useMemo(() => {
+ return filterPosts((sortedFeed ?? []).filter(a => a.created_at > latest));
+ }, [sortedFeed, latest]);
+
+ const relatedFeed = useCallback(
+ (id: u256) => {
+ return (reactions?.data ?? []).filter(a => findTag(a, "e") === id);
+ },
+ [reactions],
+ );
+
+ const liveStreams = useMemo(() => {
+ return (sortedFeed ?? []).filter(a => a.kind === EventKind.LiveEvent && findTag(a, "status") === "live");
+ }, [sortedFeed]);
+
+ const latestAuthors = useMemo(() => {
+ return dedupeByPubkey(latestFeed).map(e => e.pubkey);
+ }, [latestFeed]);
+
+ function onShowLatest(scrollToTop = false) {
+ setLatest(unixNow());
+ if (scrollToTop) {
+ window.scrollTo(0, 0);
+ }
+ }
+
+ return (
+ <>
+ {(props.liveStreams ?? true) && }
+ {latestFeed.length > 0 && (
+ <>
+ onShowLatest()} ref={ref}>
+ {latestAuthors.slice(0, 3).map(p => {
+ return
;
+ })}
+
+
+
+ {!inView && (
+ onShowLatest(true)}>
+ {latestAuthors.slice(0, 3).map(p => {
+ return
;
+ })}
+
+
+
+ )}
+ >
+ )}
+ {mainFeed.map(
+ a =>
+ props.noteRenderer?.(a) ?? (
+
+ ),
+ )}
+
+
{
+ await FollowsFeed.loadMore(system, login, sortedFeed[sortedFeed.length - 1].created_at);
+ }}>
+
+
+
+ >
+ );
+};
+export default TimelineFollows;
diff --git a/packages/app/src/Element/TrendingPosts.tsx b/packages/app/src/Element/Feed/TrendingPosts.tsx
similarity index 73%
rename from packages/app/src/Element/TrendingPosts.tsx
rename to packages/app/src/Element/Feed/TrendingPosts.tsx
index b3e0d52b..6c9bfcb5 100644
--- a/packages/app/src/Element/TrendingPosts.tsx
+++ b/packages/app/src/Element/Feed/TrendingPosts.tsx
@@ -1,14 +1,14 @@
import { useEffect, useState } from "react";
-import { NostrEvent, TaggedNostrEvent } from "@snort/system";
+import { NostrEvent, NostrLink, TaggedNostrEvent } from "@snort/system";
import PageSpinner from "Element/PageSpinner";
-import Note from "Element/Note";
+import Note from "Element/Event/Note";
import NostrBandApi from "External/NostrBand";
-import { useReactions } from "Feed/FeedReactions";
+import { useReactions } from "Feed/Reactions";
export default function TrendingNotes() {
const [posts, setPosts] = useState>();
- const related = useReactions("trending", posts?.map(a => a.id) ?? []);
+ const related = useReactions("trending", posts?.map(a => NostrLink.fromEvent(a)) ?? []);
async function loadTrendingNotes() {
const api = new NostrBandApi();
diff --git a/packages/app/src/Element/FormattedMessage.tsx b/packages/app/src/Element/FormattedMessage.tsx
new file mode 100644
index 00000000..e2978482
--- /dev/null
+++ b/packages/app/src/Element/FormattedMessage.tsx
@@ -0,0 +1,22 @@
+import { useState, useEffect, FC, ComponentProps } from "react";
+import { useIntl, FormattedMessage } from "react-intl";
+
+type ExtendedProps = ComponentProps;
+
+const ExtendedFormattedMessage: FC = props => {
+ const { id, defaultMessage, values } = props;
+ const { formatMessage } = useIntl();
+
+ const [processedMessage, setProcessedMessage] = useState(null);
+
+ useEffect(() => {
+ const translatedMessage = formatMessage({ id, defaultMessage }, values);
+ if (typeof translatedMessage === "string") {
+ setProcessedMessage(translatedMessage.replace("Snort", process.env.APP_NAME_CAPITALIZED || "Snort"));
+ }
+ }, [id, defaultMessage, values, formatMessage]);
+
+ return <>{processedMessage}>;
+};
+
+export default ExtendedFormattedMessage;
diff --git a/packages/app/src/Element/HyperText.tsx b/packages/app/src/Element/HyperText.tsx
index 32b4548c..83c6e5a5 100644
--- a/packages/app/src/Element/HyperText.tsx
+++ b/packages/app/src/Element/HyperText.tsx
@@ -13,23 +13,24 @@ import {
WavlakeRegex,
} from "Const";
import { magnetURIDecode } from "SnortUtils";
-import SoundCloudEmbed from "Element/SoundCloudEmded";
-import MixCloudEmbed from "Element/MixCloudEmbed";
-import SpotifyEmbed from "Element/SpotifyEmbed";
-import TidalEmbed from "Element/TidalEmbed";
-import TwitchEmbed from "Element/TwitchEmbed";
-import AppleMusicEmbed from "Element/AppleMusicEmbed";
-import WavlakeEmbed from "Element/WavlakeEmbed";
-import LinkPreview from "Element/LinkPreview";
-import NostrLink from "Element/NostrLink";
-import MagnetLink from "Element/MagnetLink";
+import SoundCloudEmbed from "Element/Embed/SoundCloudEmded";
+import MixCloudEmbed from "Element/Embed/MixCloudEmbed";
+import SpotifyEmbed from "Element/Embed/SpotifyEmbed";
+import TidalEmbed from "Element/Embed/TidalEmbed";
+import TwitchEmbed from "Element/Embed/TwitchEmbed";
+import AppleMusicEmbed from "Element/Embed/AppleMusicEmbed";
+import WavlakeEmbed from "Element/Embed/WavlakeEmbed";
+import LinkPreview from "Element/Embed/LinkPreview";
+import NostrLink from "Element/Embed/NostrLink";
+import MagnetLink from "Element/Embed/MagnetLink";
interface HypeTextProps {
link: string;
depth?: number;
+ showLinkPreview?: boolean;
}
-export default function HyperText({ link, depth }: HypeTextProps) {
+export default function HyperText({ link, depth, showLinkPreview }: HypeTextProps) {
const a = link;
try {
const url = new URL(a);
@@ -91,7 +92,7 @@ export default function HyperText({ link, depth }: HypeTextProps) {
if (parsed) {
return ;
}
- } else {
+ } else if (showLinkPreview ?? true) {
return ;
}
} catch {
diff --git a/packages/app/src/Element/LiveEvent.tsx b/packages/app/src/Element/LiveEvent.tsx
index d64ed907..fa446918 100644
--- a/packages/app/src/Element/LiveEvent.tsx
+++ b/packages/app/src/Element/LiveEvent.tsx
@@ -1,25 +1,84 @@
-import { NostrEvent, NostrPrefix, encodeTLV } from "@snort/system";
-import { findTag, unwrap } from "SnortUtils";
-import { FormattedMessage } from "react-intl";
+import { NostrEvent, NostrLink } from "@snort/system";
+import FormattedMessage from "Element/FormattedMessage";
import { Link } from "react-router-dom";
+import { findTag } from "SnortUtils";
+import ProfileImage from "./User/ProfileImage";
+import Icon from "Icons/Icon";
+
export function LiveEvent({ ev }: { ev: NostrEvent }) {
const title = findTag(ev, "title");
- const d = unwrap(findTag(ev, "d"));
- return (
-
-
-
-
{title}
-
-
-
-
-
+ const status = findTag(ev, "status");
+ const starts = Number(findTag(ev, "starts"));
+ const host = ev.tags.find(a => a[0] === "p" && a[3] === "host")?.[1] ?? ev.pubkey;
+
+ function statusLine() {
+ switch (status) {
+ case "live": {
+ return (
+
+
+
+
+
+
+ );
+ }
+ case "ended": {
+ return (
+
+
+
+ );
+ }
+ case "planned": {
+ return (
+
+ {new Intl.DateTimeFormat(undefined, { dateStyle: "full", timeStyle: "short" }).format(
+ new Date(starts * 1000),
+ )}
+
+ );
+ }
+ }
+ }
+
+ function cta() {
+ const link = `https://zap.stream/${NostrLink.fromEvent(ev).encode()}`;
+ switch (status) {
+ case "live": {
+ return (
+
+
+
+ );
+ }
+ case "ended": {
+ if (findTag(ev, "recording")) {
+ return (
+
+
+
+
+
+ );
+ }
+ }
+ }
+ }
+
+ return (
+
+
+
+
+
{title}
+ {statusLine()}
+
{cta()}
);
}
diff --git a/packages/app/src/Element/LiveStreams.tsx b/packages/app/src/Element/LiveStreams.tsx
index f639a2de..20e2a2e2 100644
--- a/packages/app/src/Element/LiveStreams.tsx
+++ b/packages/app/src/Element/LiveStreams.tsx
@@ -1,5 +1,5 @@
import "./LiveStreams.css";
-import { NostrEvent, NostrPrefix, encodeTLV } from "@snort/system";
+import { NostrEvent, NostrLink } from "@snort/system";
import { findTag } from "SnortUtils";
import { CSSProperties, useMemo } from "react";
import { Link } from "react-router-dom";
@@ -32,7 +32,7 @@ function LiveStreamEvent({ ev }: { ev: NostrEvent }) {
const image = findTag(ev, "image");
const status = findTag(ev, "status");
- const link = encodeTLV(NostrPrefix.Address, findTag(ev, "d") ?? "", undefined, ev.kind, ev.pubkey);
+ const link = NostrLink.fromEvent(ev).encode();
const imageProxy = proxy(image ?? "");
return (
diff --git a/packages/app/src/Element/Logo.tsx b/packages/app/src/Element/Logo.tsx
index 38e946b8..d33e3f95 100644
--- a/packages/app/src/Element/Logo.tsx
+++ b/packages/app/src/Element/Logo.tsx
@@ -4,7 +4,7 @@ const Logo = () => {
const navigate = useNavigate();
return (
navigate("/")}>
- Snort
+ {process.env.APP_NAME}
);
};
diff --git a/packages/app/src/Element/LogoutButton.tsx b/packages/app/src/Element/LogoutButton.tsx
index 4a941434..e4dea754 100644
--- a/packages/app/src/Element/LogoutButton.tsx
+++ b/packages/app/src/Element/LogoutButton.tsx
@@ -1,4 +1,4 @@
-import { FormattedMessage } from "react-intl";
+import FormattedMessage from "Element/FormattedMessage";
import { useNavigate } from "react-router-dom";
import { logout } from "Login";
@@ -7,15 +7,15 @@ import messages from "./messages";
export default function LogoutButton() {
const navigate = useNavigate();
- const publicKey = useLogin().publicKey;
+ const login = useLogin(s => ({ publicKey: s.publicKey, id: s.id }));
- if (!publicKey) return;
+ if (!login.publicKey) return;
return (
{
- logout(publicKey);
+ logout(login.id);
navigate("/");
}}>
diff --git a/packages/app/src/Element/Modal.css b/packages/app/src/Element/Modal.css
index 003aab7b..bb02e1b7 100644
--- a/packages/app/src/Element/Modal.css
+++ b/packages/app/src/Element/Modal.css
@@ -13,15 +13,24 @@
.modal-body {
background-color: var(--gray-superdark);
- padding: 16px 24px;
- border-radius: 12px;
+ padding: 24px;
+ border-radius: 16px;
display: flex;
flex-direction: column;
width: 500px;
margin-top: auto;
margin-bottom: auto;
+ --border-color: var(--gray);
}
.modal-body button.secondary:hover {
background-color: var(--gray);
}
+
+.light .modal-body {
+ background-color: #fff;
+}
+
+.modal.spotlight {
+ color: #fff;
+}
diff --git a/packages/app/src/Element/Modal.tsx b/packages/app/src/Element/Modal.tsx
index 8b882f43..576f9cca 100644
--- a/packages/app/src/Element/Modal.tsx
+++ b/packages/app/src/Element/Modal.tsx
@@ -1,25 +1,23 @@
import "./Modal.css";
-import { useEffect, MouseEventHandler, ReactNode } from "react";
+import { ReactNode, useEffect } from "react";
export interface ModalProps {
+ id: string;
className?: string;
- onClose?: MouseEventHandler;
+ onClose?: (e: React.MouseEvent) => void;
children: ReactNode;
}
export default function Modal(props: ModalProps) {
- const onClose = props.onClose || (() => undefined);
- const className = props.className || "";
-
useEffect(() => {
document.body.classList.add("scroll-lock");
return () => document.body.classList.remove("scroll-lock");
}, []);
return (
-
-
e.stopPropagation()}>
- {props.children}
+
+
+
e.stopPropagation()}>{props.children}
);
diff --git a/packages/app/src/Element/Nip5Service.tsx b/packages/app/src/Element/Nip5Service.tsx
index 19318901..434dfa9a 100644
--- a/packages/app/src/Element/Nip5Service.tsx
+++ b/packages/app/src/Element/Nip5Service.tsx
@@ -18,7 +18,7 @@ import AsyncButton from "Element/AsyncButton";
import SendSats from "Element/SendSats";
import Copy from "Element/Copy";
import { useUserProfile } from "@snort/system-react";
-import useEventPublisher from "Feed/EventPublisher";
+import useEventPublisher from "Hooks/useEventPublisher";
import { debounce } from "SnortUtils";
import useLogin from "Hooks/useLogin";
import SnortServiceProvider from "Nip05/SnortServiceProvider";
@@ -43,8 +43,8 @@ export default function Nip5Service(props: Nip05ServiceProps) {
const navigate = useNavigate();
const { helpText = true } = props;
const { formatMessage } = useIntl();
- const pubkey = useLogin().publicKey;
- const user = useUserProfile(pubkey);
+ const { publicKey } = useLogin(s => ({ publicKey: s.publicKey }));
+ const user = useUserProfile(publicKey);
const publisher = useEventPublisher();
const svc = useMemo(() => new ServiceProvider(props.service), [props.service]);
const [serviceConfig, setServiceConfig] = useState
();
@@ -179,11 +179,11 @@ export default function Nip5Service(props: Nip05ServiceProps) {
}
async function startBuy(handle: string, domain: string) {
- if (!pubkey) {
+ if (!publicKey) {
return;
}
- const rsp = await svc.RegisterHandle(handle, domain, pubkey);
+ const rsp = await svc.RegisterHandle(handle, domain, publicKey);
if ("error" in rsp) {
setError(rsp);
} else {
@@ -193,7 +193,7 @@ export default function Nip5Service(props: Nip05ServiceProps) {
}
async function claimForSubscription(handle: string, domain: string, sub: string) {
- if (!pubkey || !publisher) {
+ if (!publicKey || !publisher) {
return;
}
@@ -261,9 +261,7 @@ export default function Nip5Service(props: Nip05ServiceProps) {
/>
@
- {serviceConfig?.domains.map(a => (
- {a.name}
- ))}
+ {serviceConfig?.domains.map(a => {a.name} )}
)}
diff --git a/packages/app/src/Element/NoteCreator.tsx b/packages/app/src/Element/NoteCreator.tsx
deleted file mode 100644
index d9a5ab85..00000000
--- a/packages/app/src/Element/NoteCreator.tsx
+++ /dev/null
@@ -1,419 +0,0 @@
-/* eslint-disable @typescript-eslint/no-unused-vars */
-import "./NoteCreator.css";
-import { FormattedMessage, useIntl } from "react-intl";
-import { useDispatch, useSelector } from "react-redux";
-import { encodeTLV, EventKind, NostrPrefix, TaggedNostrEvent, EventBuilder } from "@snort/system";
-import { LNURL } from "@snort/shared";
-
-import Icon from "Icons/Icon";
-import useEventPublisher from "Feed/EventPublisher";
-import { openFile } from "SnortUtils";
-import Textarea from "Element/Textarea";
-import Modal from "Element/Modal";
-import ProfileImage from "Element/ProfileImage";
-import useFileUpload from "Upload";
-import Note from "Element/Note";
-import {
- setShow,
- setNote,
- setError,
- setActive,
- setPreview,
- setShowAdvanced,
- setSelectedCustomRelays,
- setZapForward,
- setSensitive,
- reset,
- setPollOptions,
- setOtherEvents,
-} from "State/NoteCreator";
-import type { RootState } from "State/Store";
-
-import messages from "./messages";
-import { ClipboardEventHandler, useState } from "react";
-import Spinner from "Icons/Spinner";
-import { Menu, MenuItem } from "@szhsin/react-menu";
-import { LoginStore } from "Login";
-import { getCurrentSubscription } from "Subscription";
-import useLogin from "Hooks/useLogin";
-import { System } from "index";
-import AsyncButton from "Element/AsyncButton";
-import { AsyncIcon } from "Element/AsyncIcon";
-
-export function NoteCreator() {
- const { formatMessage } = useIntl();
- const publisher = useEventPublisher();
- const uploader = useFileUpload();
- const {
- note,
- zapForward,
- sensitive,
- pollOptions,
- replyTo,
- otherEvents,
- preview,
- active,
- show,
- showAdvanced,
- selectedCustomRelays,
- error,
- } = useSelector((s: RootState) => s.noteCreator);
- const dispatch = useDispatch();
- const sub = getCurrentSubscription(LoginStore.allSubscriptions());
- const login = useLogin();
- const relays = login.relays;
-
- async function sendNote() {
- if (note && publisher) {
- let extraTags: Array
> | undefined;
- if (zapForward) {
- try {
- const svc = new LNURL(zapForward);
- await svc.load();
- extraTags = [svc.getZapTag()];
- } catch {
- dispatch(
- setError(
- formatMessage({
- defaultMessage: "Invalid LNURL",
- })
- )
- );
- return;
- }
- }
-
- if (sensitive) {
- extraTags ??= [];
- extraTags.push(["content-warning", sensitive]);
- }
- const kind = pollOptions ? EventKind.Polls : EventKind.TextNote;
- if (pollOptions) {
- extraTags ??= [];
- extraTags.push(...pollOptions.map((a, i) => ["poll_option", i.toString(), a]));
- }
- const hk = (eb: EventBuilder) => {
- extraTags?.forEach(t => eb.tag(t));
- eb.kind(kind);
- return eb;
- };
- const ev = replyTo ? await publisher.reply(replyTo, note, hk) : await publisher.note(note, hk);
- if (selectedCustomRelays) selectedCustomRelays.forEach(r => System.WriteOnceToRelay(r, ev));
- else System.BroadcastEvent(ev);
- dispatch(reset());
- for (const oe of otherEvents) {
- if (selectedCustomRelays) selectedCustomRelays.forEach(r => System.WriteOnceToRelay(r, oe));
- else System.BroadcastEvent(oe);
- }
- dispatch(reset());
- }
- }
-
- async function attachFile() {
- try {
- const file = await openFile();
- if (file) {
- uploadFile(file);
- }
- } catch (error: unknown) {
- if (error instanceof Error) {
- dispatch(setError(error?.message));
- }
- }
- }
-
- async function uploadFile(file: File | Blob) {
- try {
- if (file) {
- const rx = await uploader.upload(file, file.name);
- if (rx.header) {
- const link = `nostr:${encodeTLV(NostrPrefix.Event, rx.header.id, undefined, rx.header.kind)}`;
- dispatch(setNote(`${note ? `${note}\n` : ""}${link}`));
- dispatch(setOtherEvents([...otherEvents, rx.header]));
- } else if (rx.url) {
- dispatch(setNote(`${note ? `${note}\n` : ""}${rx.url}`));
- } else if (rx?.error) {
- dispatch(setError(rx.error));
- }
- }
- } catch (error: unknown) {
- if (error instanceof Error) {
- dispatch(setError(error?.message));
- }
- }
- }
-
- function onChange(ev: React.ChangeEvent) {
- const { value } = ev.target;
- dispatch(setNote(value));
- if (value) {
- dispatch(setActive(true));
- } else {
- dispatch(setActive(false));
- }
- }
-
- function cancel() {
- dispatch(reset());
- }
-
- async function onSubmit(ev: React.MouseEvent) {
- ev.stopPropagation();
- await sendNote();
- }
-
- async function loadPreview() {
- if (preview) {
- dispatch(setPreview(undefined));
- } else if (publisher) {
- const tmpNote = await publisher.note(note);
- if (tmpNote) {
- dispatch(setPreview(tmpNote));
- }
- }
- }
-
- function getPreviewNote() {
- if (preview) {
- return (
-
- );
- }
- }
-
- function renderPollOptions() {
- if (pollOptions) {
- return (
- <>
-
-
-
- {pollOptions?.map((a, i) => (
-
-
-
-
-
- changePollOption(i, e.target.value)} />
- {i > 1 && (
- removePollOption(i)} className="ml5">
-
-
- )}
-
-
- ))}
- dispatch(setPollOptions([...pollOptions, ""]))}>
-
-
- >
- );
- }
- }
-
- function changePollOption(i: number, v: string) {
- if (pollOptions) {
- const copy = [...pollOptions];
- copy[i] = v;
- dispatch(setPollOptions(copy));
- }
- }
-
- function removePollOption(i: number) {
- if (pollOptions) {
- const copy = [...pollOptions];
- copy.splice(i, 1);
- dispatch(setPollOptions(copy));
- }
- }
-
- function renderRelayCustomisation() {
- return (
-
- {Object.keys(relays.item || {})
- .filter(el => relays.item[el].write)
- .map((r, i, a) => (
-
-
{r}
-
-
- dispatch(
- setSelectedCustomRelays(
- // set false if all relays selected
- e.target.checked && selectedCustomRelays && selectedCustomRelays.length == a.length - 1
- ? false
- : // otherwise return selectedCustomRelays with target relay added / removed
- a.filter(el =>
- el === r ? e.target.checked : !selectedCustomRelays || selectedCustomRelays.includes(el)
- )
- )
- )
- }
- />
-
-
- ))}
-
- );
- }
-
- function listAccounts() {
- return LoginStore.getSessions().map(a => (
- {
- ev.stopPropagation = true;
- LoginStore.switchAccount(a);
- }}>
-
-
- ));
- }
-
- const handlePaste: ClipboardEventHandler = 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);
- }
- }
- };
-
- return (
- <>
- {show && (
- dispatch(setShow(false))}>
- {replyTo && (
-
- )}
- {preview && getPreviewNote()}
- {!preview && (
-
-
- )}
-
-
-
- {pollOptions === undefined && !replyTo && (
-
- dispatch(setPollOptions(["A", "B"]))} size={24} />
-
- )}
-
-
dispatch(setShowAdvanced(!showAdvanced))}>
-
-
-
-
-
-
-
-
- {replyTo ? : }
-
-
-
- {error && {error} }
- {showAdvanced && (
- <>
-
-
-
-
-
-
-
-
-
-
- {renderRelayCustomisation()}
-
-
-
-
-
-
- dispatch(setZapForward(e.target.value))}
- />
-
-
-
-
-
-
-
-
-
- dispatch(setSensitive(e.target.value))}
- maxLength={50}
- minLength={1}
- placeholder={formatMessage({
- defaultMessage: "Reason",
- })}
- />
-
-
-
-
- >
- )}
-
- )}
- >
- );
-}
diff --git a/packages/app/src/Element/PinPrompt.css b/packages/app/src/Element/PinPrompt.css
new file mode 100644
index 00000000..98ff74a9
--- /dev/null
+++ b/packages/app/src/Element/PinPrompt.css
@@ -0,0 +1,7 @@
+.pin-box {
+ border: 1px solid var(--border-color);
+ padding: 12px 16px;
+ font-size: 80px;
+ height: 1em;
+ border-radius: 12px;
+}
diff --git a/packages/app/src/Element/PinPrompt.tsx b/packages/app/src/Element/PinPrompt.tsx
new file mode 100644
index 00000000..572558f0
--- /dev/null
+++ b/packages/app/src/Element/PinPrompt.tsx
@@ -0,0 +1,169 @@
+import useLogin from "Hooks/useLogin";
+import "./PinPrompt.css";
+import { ReactNode, useRef, useState } from "react";
+import { FormattedMessage, useIntl } from "react-intl";
+import { unwrap } from "@snort/shared";
+import { EventPublisher, InvalidPinError, PinEncrypted, PinEncryptedPayload } from "@snort/system";
+
+import useEventPublisher from "Hooks/useEventPublisher";
+import { LoginStore, createPublisher, sessionNeedsPin } from "Login";
+import Modal from "./Modal";
+import AsyncButton from "./AsyncButton";
+import { WasmPowWorker } from "index";
+
+export function PinPrompt({
+ onResult,
+ onCancel,
+ subTitle,
+}: {
+ onResult: (v: string) => Promise;
+ onCancel: () => void;
+ subTitle?: ReactNode;
+}) {
+ const [pin, setPin] = useState("");
+ const [error, setError] = useState("");
+ const { formatMessage } = useIntl();
+ const submitButtonRef = useRef(null);
+
+ async function submitPin() {
+ if (pin.length < 4) {
+ setError(
+ formatMessage({
+ defaultMessage: "Pin too short",
+ }),
+ );
+ return;
+ }
+ setError("");
+
+ try {
+ await onResult(pin);
+ } catch (e) {
+ console.error(e);
+ if (e instanceof InvalidPinError) {
+ setError(
+ formatMessage({
+ defaultMessage: "Incorrect pin",
+ }),
+ );
+ } else if (e instanceof Error) {
+ setError(e.message);
+ }
+ } finally {
+ setPin("");
+ }
+ }
+
+ return (
+ onCancel()}>
+
+
+ );
+}
+
+export function LoginUnlock() {
+ const login = useLogin();
+ const publisher = useEventPublisher();
+
+ async function encryptMigration(pin: string) {
+ const k = unwrap(login.privateKey);
+ const newPin = await PinEncrypted.create(k, pin);
+
+ const pub = EventPublisher.privateKey(k);
+ if (login.preferences.pow) {
+ pub.pow(login.preferences.pow, new WasmPowWorker());
+ }
+ LoginStore.setPublisher(login.id, pub);
+ LoginStore.updateSession({
+ ...login,
+ readonly: false,
+ privateKeyData: newPin,
+ privateKey: undefined,
+ });
+ }
+
+ async function unlockSession(pin: string) {
+ const key = new PinEncrypted(unwrap(login.privateKeyData) as PinEncryptedPayload);
+ await key.decrypt(pin);
+ const pub = createPublisher(login, key);
+ if (pub) {
+ if (login.preferences.pow) {
+ pub.pow(login.preferences.pow, new WasmPowWorker());
+ }
+ LoginStore.setPublisher(login.id, pub);
+ LoginStore.updateSession({
+ ...login,
+ readonly: false,
+ privateKeyData: key,
+ });
+ }
+ }
+
+ function makeSessionReadonly() {
+ LoginStore.updateSession({
+ ...login,
+ readonly: true,
+ });
+ }
+
+ if (login.publicKey && !publisher && sessionNeedsPin(login) && !login.readonly) {
+ if (login.privateKey !== undefined) {
+ return (
+
+
+
+ }
+ onResult={encryptMigration}
+ onCancel={() => {
+ // nothing
+ }}
+ />
+ );
+ }
+ return (
+
+
+
+ }
+ onResult={unlockSession}
+ onCancel={() => {
+ makeSessionReadonly();
+ }}
+ />
+ );
+ }
+}
diff --git a/packages/app/src/Element/ProxyImg.tsx b/packages/app/src/Element/ProxyImg.tsx
index dc625e57..91bc6a64 100644
--- a/packages/app/src/Element/ProxyImg.tsx
+++ b/packages/app/src/Element/ProxyImg.tsx
@@ -1,6 +1,6 @@
import useImgProxy from "Hooks/useImgProxy";
import React, { useState } from "react";
-import { FormattedMessage } from "react-intl";
+import FormattedMessage from "Element/FormattedMessage";
import { getUrlHostname } from "SnortUtils";
interface ProxyImgProps extends React.DetailedHTMLProps, HTMLImageElement> {
diff --git a/packages/app/src/Element/ReBroadcaster.tsx b/packages/app/src/Element/ReBroadcaster.tsx
index b3ad9dab..92fa89ea 100644
--- a/packages/app/src/Element/ReBroadcaster.tsx
+++ b/packages/app/src/Element/ReBroadcaster.tsx
@@ -1,63 +1,43 @@
-import { FormattedMessage } from "react-intl";
-import { useDispatch, useSelector } from "react-redux";
-import useEventPublisher from "Feed/EventPublisher";
+import { useContext, useState } from "react";
+import FormattedMessage from "Element/FormattedMessage";
+import { TaggedNostrEvent } from "@snort/system";
+import { SnortContext } from "@snort/system-react";
+
import Modal from "Element/Modal";
-import type { RootState } from "State/Store";
-import { setShow, reset, setSelectedCustomRelays } from "State/ReBroadcast";
import messages from "./messages";
import useLogin from "Hooks/useLogin";
-import { System } from "index";
+import AsyncButton from "./AsyncButton";
-export function ReBroadcaster() {
- const publisher = useEventPublisher();
- const { note, show, selectedCustomRelays } = useSelector((s: RootState) => s.reBroadcast);
- const dispatch = useDispatch();
+export function ReBroadcaster({ onClose, ev }: { onClose: () => void; ev: TaggedNostrEvent }) {
+ const [selected, setSelected] = useState>();
+ const system = useContext(SnortContext);
+ const { relays } = useLogin(s => ({ relays: s.relays }));
async function sendReBroadcast() {
- if (note && publisher) {
- if (selectedCustomRelays) selectedCustomRelays.forEach(r => System.WriteOnceToRelay(r, note));
- else System.BroadcastEvent(note);
- dispatch(reset());
+ if (selected) {
+ await Promise.all(selected.map(r => system.WriteOnceToRelay(r, ev)));
+ } else {
+ system.BroadcastEvent(ev);
}
}
- function cancel() {
- dispatch(reset());
- }
-
- function onSubmit(ev: React.MouseEvent) {
- ev.stopPropagation();
- sendReBroadcast().catch(console.warn);
- }
-
- const login = useLogin();
- const relays = login.relays;
-
function renderRelayCustomisation() {
return (
-
+
{Object.keys(relays.item || {})
.filter(el => relays.item[el].write)
.map((r, i, a) => (
-
-
+
+
{r}
- dispatch(
- setSelectedCustomRelays(
- // set false if all relays selected
- e.target.checked && selectedCustomRelays && selectedCustomRelays.length == a.length - 1
- ? false
- : // otherwise return selectedCustomRelays with target relay added / removed
- a.filter(el =>
- el === r ? e.target.checked : !selectedCustomRelays || selectedCustomRelays.includes(el)
- )
- )
+ setSelected(
+ e.target.checked && selected && selected.length == a.length - 1
+ ? undefined
+ : a.filter(el => (el === r ? e.target.checked : !selected || selected.includes(el))),
)
}
/>
@@ -70,19 +50,17 @@ export function ReBroadcaster() {
return (
<>
- {show && (
-
dispatch(setShow(false))}>
- {renderRelayCustomisation()}
-
-
-
-
-
-
-
-
-
- )}
+
+ {renderRelayCustomisation()}
+
+
>
);
}
diff --git a/packages/app/src/Element/Relay.css b/packages/app/src/Element/Relay/Relay.css
similarity index 100%
rename from packages/app/src/Element/Relay.css
rename to packages/app/src/Element/Relay/Relay.css
diff --git a/packages/app/src/Element/Relay.tsx b/packages/app/src/Element/Relay/Relay.tsx
similarity index 90%
rename from packages/app/src/Element/Relay.tsx
rename to packages/app/src/Element/Relay/Relay.tsx
index ab3d3de9..a576e6b4 100644
--- a/packages/app/src/Element/Relay.tsx
+++ b/packages/app/src/Element/Relay/Relay.tsx
@@ -1,17 +1,18 @@
import "./Relay.css";
import { useMemo } from "react";
-import { FormattedMessage } from "react-intl";
+import FormattedMessage from "Element/FormattedMessage";
import { useNavigate } from "react-router-dom";
import { RelaySettings } from "@snort/system";
+import { unixNowMs } from "@snort/shared";
import useRelayState from "Feed/RelayState";
import { System } from "index";
-import { getRelayName, unixNowMs, unwrap } from "SnortUtils";
+import { getRelayName, unwrap } from "SnortUtils";
import useLogin from "Hooks/useLogin";
import { setRelays } from "Login";
import Icon from "Icons/Icon";
-import messages from "./messages";
+import messages from "../messages";
export interface RelayProps {
addr: string;
@@ -21,7 +22,7 @@ export default function Relay(props: RelayProps) {
const navigate = useNavigate();
const login = useLogin();
const relaySettings = unwrap(
- login.relays.item[props.addr] ?? System.Sockets.find(a => a.address === props.addr)?.settings ?? {}
+ login.relays.item[props.addr] ?? System.Sockets.find(a => a.address === props.addr)?.settings ?? {},
);
const state = useRelayState(props.addr);
const name = useMemo(() => getRelayName(props.addr), [props.addr]);
@@ -33,7 +34,7 @@ export default function Relay(props: RelayProps) {
...login.relays.item,
[props.addr]: o,
},
- unixNowMs()
+ unixNowMs(),
);
}
diff --git a/packages/app/src/Element/RelaysMetadata.css b/packages/app/src/Element/Relay/RelaysMetadata.css
similarity index 100%
rename from packages/app/src/Element/RelaysMetadata.css
rename to packages/app/src/Element/Relay/RelaysMetadata.css
diff --git a/packages/app/src/Element/RelaysMetadata.tsx b/packages/app/src/Element/Relay/RelaysMetadata.tsx
similarity index 100%
rename from packages/app/src/Element/RelaysMetadata.tsx
rename to packages/app/src/Element/Relay/RelaysMetadata.tsx
diff --git a/packages/app/src/Element/Reveal.css b/packages/app/src/Element/Reveal.css
new file mode 100644
index 00000000..c305313e
--- /dev/null
+++ b/packages/app/src/Element/Reveal.css
@@ -0,0 +1,15 @@
+.note-notice {
+ color: var(--font-tertiary-color);
+ border: 1px solid var(--border-color);
+ padding: 8px 16px;
+ border-radius: 12px;
+}
+
+.note-notice i {
+ font-style: normal;
+ color: var(--font-color);
+}
+
+.note-notice svg {
+ color: var(--warning);
+}
diff --git a/packages/app/src/Element/RootTabs.css b/packages/app/src/Element/RootTabs.css
new file mode 100644
index 00000000..24df7d05
--- /dev/null
+++ b/packages/app/src/Element/RootTabs.css
@@ -0,0 +1,23 @@
+.root-type {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.root-type > button {
+ background: white;
+ color: var(--bg-color);
+ font-size: 16px;
+ padding: 10px 16px;
+ border-radius: 1000px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ border: 1px solid var(--gray-superdark);
+}
+
+.light .root-type > button {
+ color: var(--font-color);
+ border: 1px solid var(--border-color);
+}
diff --git a/packages/app/src/Element/RootTabs.tsx b/packages/app/src/Element/RootTabs.tsx
new file mode 100644
index 00000000..81f15e4d
--- /dev/null
+++ b/packages/app/src/Element/RootTabs.tsx
@@ -0,0 +1,156 @@
+import "./RootTabs.css";
+import { useState, ReactNode, useEffect } from "react";
+import { useLocation, useNavigate } from "react-router-dom";
+import { Menu, MenuItem } from "@szhsin/react-menu";
+import FormattedMessage from "Element/FormattedMessage";
+
+import useLogin from "Hooks/useLogin";
+import Icon from "Icons/Icon";
+
+export type RootTab =
+ | "following"
+ | "conversations"
+ | "trending-notes"
+ | "trending-people"
+ | "suggested"
+ | "tags"
+ | "global";
+
+export function RootTabs({ base }: { base?: string }) {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const { publicKey: pubKey, tags } = useLogin();
+ const [rootType, setRootType] = useState
("following");
+
+ const menuItems = [
+ {
+ tab: "following",
+ path: `${base}/notes`,
+ show: Boolean(pubKey),
+ element: (
+ <>
+
+
+ >
+ ),
+ },
+ {
+ tab: "trending-notes",
+ path: `${base}/trending/notes`,
+ show: true,
+ element: (
+ <>
+
+
+ >
+ ),
+ },
+ {
+ tab: "conversations",
+ path: `${base}/conversations`,
+ show: Boolean(pubKey),
+ element: (
+ <>
+
+
+ >
+ ),
+ },
+ {
+ tab: "trending-people",
+ path: `${base}/trending/people`,
+ show: true,
+ element: (
+ <>
+
+
+ >
+ ),
+ },
+ {
+ tab: "suggested",
+ path: `${base}/suggested`,
+ show: Boolean(pubKey),
+ element: (
+ <>
+
+
+ >
+ ),
+ },
+ {
+ tab: "global",
+ path: `${base}/global`,
+ show: true,
+ element: (
+ <>
+
+
+ >
+ ),
+ },
+ ] as Array<{
+ tab: RootTab;
+ path: string;
+ show: boolean;
+ element: ReactNode;
+ }>;
+
+ useEffect(() => {
+ const currentTab = menuItems.find(a => a.path === location.pathname)?.tab;
+ if (currentTab) {
+ setRootType(currentTab);
+ }
+ }, [location]);
+
+ function currentMenuItem() {
+ if (location.pathname.startsWith(`${base}/t/`)) {
+ return (
+ <>
+
+ {location.pathname.split("/").slice(-1)}
+ >
+ );
+ }
+ return menuItems.find(a => a.tab === rootType)?.element;
+ }
+
+ return (
+
+
+ {currentMenuItem()}
+
+
+ }
+ align="center"
+ menuClassName={() => "ctx-menu"}>
+
+ {menuItems
+ .filter(a => a.show)
+ .map(a => (
+ {
+ navigate(a.path);
+ }}>
+ {a.element}
+
+ ))}
+ {tags.item.map(v => (
+ {
+ navigate(`${base}/t/${v}`);
+ }}>
+
+ {v}
+
+ ))}
+
+
+ );
+}
diff --git a/packages/app/src/Element/SendSats.css b/packages/app/src/Element/SendSats.css
index f686f06e..23b8fbe2 100644
--- a/packages/app/src/Element/SendSats.css
+++ b/packages/app/src/Element/SendSats.css
@@ -1,31 +1,28 @@
.lnurl-modal .modal-body {
- padding: 0;
- max-width: 470px;
+ padding: 12px 24px;
+ max-width: 500px;
}
-.lnurl-modal .lnurl-tip .pfp .avatar {
+.lnurl-modal .pfp .avatar {
width: 48px;
height: 48px;
}
-.lnurl-tip {
- padding: 24px 32px;
- background-color: #1b1b1b;
- border-radius: 16px;
- position: relative;
-}
-
@media (max-width: 720px) {
- .lnurl-tip {
+ .lnurl-modal {
padding: 12px 16px;
}
}
-.light .lnurl-tip {
- background-color: var(--gray-superdark);
+.lnurl-modal h2 {
+ margin: 0;
+ font-weight: 600;
+ font-size: 16px;
+ line-height: 19px;
}
-.lnurl-tip h3 {
+.lnurl-modal h3 {
+ margin: 0;
color: var(--font-secondary-color);
font-size: 11px;
letter-spacing: 0.11em;
@@ -34,43 +31,14 @@
text-transform: uppercase;
}
-.lnurl-tip .close {
- position: absolute;
- top: 12px;
- right: 16px;
- color: var(--font-secondary-color);
- cursor: pointer;
-}
-
-.lnurl-tip .close:hover {
- color: var(--font-tertiary-color);
-}
-
-.lnurl-tip .lnurl-header {
- display: flex;
- flex-direction: row;
- align-items: center;
- margin-bottom: 32px;
-}
-
-.lnurl-tip .lnurl-header h2 {
- margin: 0;
- flex-grow: 1;
- font-weight: 600;
- font-size: 16px;
- line-height: 19px;
-}
-
.amounts {
- display: flex;
- width: 100%;
- margin-bottom: 16px;
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 12px;
}
.sat-amount {
- flex: 1 1 auto;
text-align: center;
- display: inline-block;
background-color: #2a2a2a;
color: var(--font-color);
padding: 12px 16px;
@@ -79,83 +47,28 @@
font-weight: 600;
font-size: 14px;
line-height: 17px;
+ cursor: pointer;
}
.light .sat-amount {
background-color: var(--gray);
}
-.sat-amount:not(:last-child) {
- margin-right: 8px;
-}
-
-.sat-amount:hover {
- cursor: pointer;
-}
-
.sat-amount.active {
font-weight: bold;
color: var(--gray-superdark);
background-color: var(--font-color);
}
-.lnurl-tip .invoice {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
-}
-
-.lnurl-tip .invoice .actions {
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- text-align: center;
-}
-
-.lnurl-tip .invoice .actions .copy-action {
- margin: 10px auto;
-}
-
-.lnurl-tip .invoice .actions .wallet-action {
- width: 100%;
- height: 40px;
-}
-
-.lnurl-tip .zap-action {
- margin-top: 16px;
- width: 100%;
- height: 40px;
-}
-
-.lnurl-tip .zap-action svg {
- margin-right: 10px;
-}
-
-.lnurl-tip .zap-action-container {
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-.lnurl-tip .custom-amount {
- margin-bottom: 16px;
-}
-
-.lnurl-tip .custom-amount button {
- padding: 12px 18px;
- border-radius: 100px;
-}
-
-.lnurl-tip canvas {
+.lnurl-modal canvas {
border-radius: 10px;
}
-.lnurl-tip .success-action .paid {
+.lnurl-modal .success-action .paid {
font-size: 19px;
}
-.lnurl-tip .success-action a {
+.lnurl-modal .success-action a {
color: var(--highlight);
font-size: 19px;
}
diff --git a/packages/app/src/Element/SendSats.tsx b/packages/app/src/Element/SendSats.tsx
index 368095d9..7bbd7a78 100644
--- a/packages/app/src/Element/SendSats.tsx
+++ b/packages/app/src/Element/SendSats.tsx
@@ -1,23 +1,23 @@
import "./SendSats.css";
-import React, { useEffect, useMemo, useState } from "react";
+import React, { ReactNode, useContext, useEffect, useState } from "react";
import { useIntl, FormattedMessage } from "react-intl";
-import { HexKey, NostrEvent, EventPublisher } from "@snort/system";
-import { LNURL, LNURLError, LNURLErrorCode, LNURLInvoice, LNURLSuccessAction } from "@snort/shared";
+import { HexKey } from "@snort/system";
+import { SnortContext } from "@snort/system-react";
+import { LNURLSuccessAction } from "@snort/shared";
import { formatShort } from "Number";
import Icon from "Icons/Icon";
-import useEventPublisher from "Feed/EventPublisher";
-import ProfileImage from "Element/ProfileImage";
+import useEventPublisher from "Hooks/useEventPublisher";
+import ProfileImage from "Element/User/ProfileImage";
import Modal from "Element/Modal";
import QrCode from "Element/QrCode";
import Copy from "Element/Copy";
-import { chunks, debounce } from "SnortUtils";
-import { useWallet } from "Wallet";
+import { debounce } from "SnortUtils";
+import { LNWallet, useWallet } from "Wallet";
import useLogin from "Hooks/useLogin";
-import { generateRandomKey } from "Login";
-import { ZapPoolController } from "ZapPoolController";
import AsyncButton from "Element/AsyncButton";
+import { ZapTarget, ZapTargetResult, Zapper } from "Zapper";
import messages from "./messages";
@@ -30,56 +30,45 @@ enum ZapType {
export interface SendSatsProps {
onClose?: () => void;
- lnurl?: string;
+ targets?: Array;
show?: boolean;
invoice?: string; // shortcut to invoice qr tab
- title?: string;
+ title?: ReactNode;
notice?: string;
- target?: string;
note?: HexKey;
- author?: HexKey;
allocatePool?: boolean;
}
export default function SendSats(props: SendSatsProps) {
const onClose = props.onClose || (() => undefined);
- const { note, author, target } = props;
- const login = useLogin();
- const defaultZapAmount = login.preferences.defaultZapAmount;
- const amounts = [defaultZapAmount, 1_000, 5_000, 10_000, 20_000, 50_000, 100_000, 1_000_000];
- const emojis: Record = {
- 1_000: "👍",
- 5_000: "💜",
- 10_000: "😍",
- 20_000: "🤩",
- 50_000: "🔥",
- 100_000: "🚀",
- 1_000_000: "🤯",
- };
- const [handler, setHandler] = useState();
- const [invoice, setInvoice] = useState();
- const [amount, setAmount] = useState(defaultZapAmount);
- const [customAmount, setCustomAmount] = useState();
- const [comment, setComment] = useState();
- const [success, setSuccess] = useState();
+ const [zapper, setZapper] = useState();
+ const [invoice, setInvoice] = useState>();
const [error, setError] = useState();
- const [zapType, setZapType] = useState(ZapType.PublicZap);
- const [paying, setPaying] = useState(false);
+ const [success, setSuccess] = useState();
+ const [amount, setAmount] = useState();
- const { formatMessage } = useIntl();
+ const system = useContext(SnortContext);
const publisher = useEventPublisher();
- const canComment = handler ? (handler.canZap && zapType !== ZapType.NonZap) || handler.maxCommentLength > 0 : false;
const walletState = useWallet();
const wallet = walletState.wallet;
useEffect(() => {
if (props.show) {
+ const invoiceTarget = {
+ target: {
+ type: "lnurl",
+ value: "",
+ weight: 1,
+ },
+ pr: props.invoice,
+ paid: false,
+ sent: 0,
+ fee: 0,
+ } as ZapTargetResult;
+
setError(undefined);
- setAmount(defaultZapAmount);
- setComment(undefined);
- setZapType(ZapType.PublicZap);
- setInvoice(props.invoice);
+ setInvoice(props.invoice ? [invoiceTarget] : undefined);
setSuccess(undefined);
}
}, [props.show]);
@@ -94,247 +83,30 @@ export default function SendSats(props: SendSatsProps) {
}, [success]);
useEffect(() => {
- if (props.lnurl && props.show) {
+ if (props.targets && props.show) {
try {
- const h = new LNURL(props.lnurl);
- setHandler(h);
- h.load().catch(e => handleLNURLError(e, formatMessage(messages.InvoiceFail)));
+ console.debug("loading zapper");
+ const zapper = new Zapper(system, publisher);
+ zapper.load(props.targets).then(() => {
+ console.debug(zapper);
+ setZapper(zapper);
+ });
} catch (e) {
+ console.error(e);
if (e instanceof Error) {
setError(e.message);
}
}
}
- }, [props.lnurl, props.show]);
-
- const serviceAmounts = useMemo(() => {
- if (handler) {
- const min = handler.min / 1000;
- const max = handler.max / 1000;
- return amounts.filter(a => a >= min && a <= max);
- }
- return [];
- }, [handler]);
- const amountRows = useMemo(() => chunks(serviceAmounts, 3), [serviceAmounts]);
-
- const selectAmount = (a: number) => {
- setError(undefined);
- setAmount(a);
- };
-
- async function loadInvoice(): Promise {
- if (!amount || !handler || !publisher) return;
-
- let zap: NostrEvent | undefined;
- if (author && zapType !== ZapType.NonZap) {
- const relays = Object.keys(login.relays.item);
-
- // use random key for anon zaps
- if (zapType === ZapType.AnonZap) {
- const randomKey = generateRandomKey();
- console.debug("Generated new key for zap: ", randomKey);
-
- const publisher = EventPublisher.privateKey(randomKey.privateKey);
- zap = await publisher.zap(amount * 1000, author, relays, note, comment, eb => eb.tag(["anon", ""]));
- } else {
- zap = await publisher.zap(amount * 1000, author, relays, note, comment);
- }
- }
-
- try {
- const rsp = await handler.getInvoice(amount, comment, zap);
- if (rsp.pr) {
- setInvoice(rsp.pr);
- await payWithWallet(rsp);
- }
- } catch (e) {
- handleLNURLError(e, formatMessage(messages.InvoiceFail));
- }
- }
-
- function handleLNURLError(e: unknown, fallback: string) {
- if (e instanceof LNURLError) {
- switch (e.code) {
- case LNURLErrorCode.ServiceUnavailable: {
- setError(formatMessage(messages.LNURLFail));
- return;
- }
- case LNURLErrorCode.InvalidLNURL: {
- setError(formatMessage(messages.InvalidLNURL));
- return;
- }
- }
- }
- setError(fallback);
- }
-
- function custom() {
- if (!handler) return null;
- const min = handler.min / 1000;
- const max = handler.max / 1000;
-
- return (
-
- setCustomAmount(parseInt(e.target.value))}
- />
- selectAmount(customAmount ?? 0)}>
-
-
-
- );
- }
-
- async function payWithWallet(invoice: LNURLInvoice) {
- try {
- if (wallet?.isReady()) {
- setPaying(true);
- const res = await wallet.payInvoice(invoice?.pr ?? "");
- if (props.allocatePool) {
- ZapPoolController.allocate(amount);
- }
- console.log(res);
- setSuccess(invoice?.successAction ?? {});
- }
- } catch (e: unknown) {
- console.warn(e);
- if (e instanceof Error) {
- setError(e.toString());
- }
- } finally {
- setPaying(false);
- }
- }
-
- function renderAmounts(amount: number, amounts: number[]) {
- return (
-
- {amounts.map(a => (
- selectAmount(a)}>
- {emojis[a] && <>{emojis[a]} >}
- {a === 1000 ? "1K" : formatShort(a)}
-
- ))}
-
- );
- }
-
- function invoiceForm() {
- if (!handler || invoice) return null;
- return (
- <>
-
-
-
- {amountRows.map(amounts => renderAmounts(amount, amounts))}
- {custom()}
-
- {canComment && (
- setComment(e.target.value)}
- />
- )}
-
- {zapTypeSelector()}
- {(amount ?? 0) > 0 && (
- loadInvoice()}>
-
-
- {target ? (
-
- ) : (
-
- )}
-
-
- )}
- >
- );
- }
-
- function zapTypeSelector() {
- if (!handler || !handler.canZap) return;
-
- const makeTab = (t: ZapType, n: React.ReactNode) => (
- setZapType(t)}>
- {n}
-
- );
- return (
- <>
-
-
-
-
- {makeTab(ZapType.PublicZap, )}
- {/*makeTab(ZapType.PrivateZap, "Private")*/}
- {makeTab(ZapType.AnonZap, )}
- {makeTab(
- ZapType.NonZap,
-
- )}
-
- >
- );
- }
-
- function payInvoice() {
- if (success || !invoice) return null;
- return (
- <>
-
- {props.notice &&
{props.notice} }
- {paying ? (
-
-
- ...
-
- ) : (
-
- )}
-
- {invoice && (
- <>
-
-
-
-
window.open(`lightning:${invoice}`)}>
-
-
- >
- )}
-
-
- >
- );
- }
+ }, [props.targets, props.show]);
function successAction() {
if (!success) return null;
return (
-
-
-
- {success?.description ?? }
+
+
+
+ {success?.description ?? }
{success.url && (
@@ -347,29 +119,293 @@ export default function SendSats(props: SendSatsProps) {
);
}
- const defaultTitle = handler?.canZap ? formatMessage(messages.SendZap) : formatMessage(messages.SendSats);
- const title = target
- ? formatMessage(messages.ToTarget, {
- action: defaultTitle,
- target,
- })
- : defaultTitle;
+ function title() {
+ if (!props.targets) {
+ return (
+ <>
+
+ {zapper?.canZap() ? (
+
+ ) : (
+
+ )}
+
+ >
+ );
+ }
+ if (props.targets.length === 1 && props.targets[0].name) {
+ const t = props.targets[0];
+ const values = {
+ name: t.name,
+ };
+ return (
+ <>
+ {t.zap?.pubkey &&
}
+
+ {zapper?.canZap() ? (
+
+ ) : (
+
+ )}
+
+ >
+ );
+ }
+ if (props.targets.length > 1) {
+ const total = props.targets.reduce((acc, v) => (acc += v.weight), 0);
+
+ return (
+
+
+ {zapper?.canZap() ? (
+
+ ) : (
+
+ )}
+
+
+ {props.targets.map(v => (
+
+ ))}
+
+
+ );
+ }
+ }
+
if (!(props.show ?? false)) return null;
return (
-
- e.stopPropagation()}>
-
-
+
+
+
+
{props.title || title()}
+
+
+
-
- {author &&
}
-
{props.title || title}
-
- {invoiceForm()}
+ {zapper && !invoice && (
+
setAmount(v)}
+ onNextStage={async p => {
+ const targetsWithComments = (props.targets ?? []).map(v => {
+ if (p.comment) {
+ v.memo = p.comment;
+ }
+ if (p.type === ZapType.AnonZap && v.zap) {
+ v.zap = {
+ ...v.zap,
+ anon: true,
+ };
+ } else if (p.type === ZapType.NonZap) {
+ v.zap = undefined;
+ }
+ return v;
+ });
+ if (targetsWithComments.length > 0) {
+ const sends = await zapper.send(wallet, targetsWithComments, p.amount);
+ if (sends[0].error) {
+ setError(sends[0].error.message);
+ } else if (sends.every(a => a.paid)) {
+ setSuccess({});
+ } else {
+ setInvoice(sends);
+ }
+ }
+ }}
+ />
+ )}
{error && {error}
}
- {payInvoice()}
+ {invoice && !success && (
+ {
+ setSuccess({});
+ }}
+ />
+ )}
{successAction()}
);
}
+
+interface SendSatsInputSelection {
+ amount: number;
+ comment?: string;
+ type: ZapType;
+}
+
+function SendSatsInput(props: {
+ zapper: Zapper;
+ onChange?: (v: SendSatsInputSelection) => void;
+ onNextStage: (v: SendSatsInputSelection) => Promise
;
+}) {
+ const { defaultZapAmount, readonly } = useLogin(s => ({
+ defaultZapAmount: s.preferences.defaultZapAmount,
+ readonly: s.readonly,
+ }));
+ const { formatMessage } = useIntl();
+ const amounts: Record = {
+ [defaultZapAmount.toString()]: "",
+ "1000": "👍",
+ "5000": "💜",
+ "10000": "😍",
+ "20000": "🤩",
+ "50000": "🔥",
+ "100000": "🚀",
+ "1000000": "🤯",
+ };
+ const [comment, setComment] = useState();
+ const [amount, setAmount] = useState(defaultZapAmount);
+ const [customAmount, setCustomAmount] = useState(defaultZapAmount);
+ const [zapType, setZapType] = useState(readonly ? ZapType.AnonZap : ZapType.PublicZap);
+
+ function getValue() {
+ return {
+ amount,
+ comment,
+ type: zapType,
+ } as SendSatsInputSelection;
+ }
+
+ useEffect(() => {
+ if (props.onChange) {
+ props.onChange(getValue());
+ }
+ }, [amount, comment, zapType]);
+
+ function renderAmounts() {
+ const min = props.zapper.minAmount() / 1000;
+ const max = props.zapper.maxAmount() / 1000;
+ const filteredAmounts = Object.entries(amounts).filter(([k]) => Number(k) >= min && Number(k) <= max);
+
+ return (
+
+ {filteredAmounts.map(([k, v]) => (
+ setAmount(Number(k))}>
+ {v}
+ {k === "1000" ? "1K" : formatShort(Number(k))}
+
+ ))}
+
+ );
+ }
+
+ function custom() {
+ const min = props.zapper.minAmount() / 1000;
+ const max = props.zapper.maxAmount() / 1000;
+
+ return (
+
+ setCustomAmount(parseInt(e.target.value))}
+ />
+ setAmount(customAmount ?? 0)}>
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {renderAmounts()}
+ {custom()}
+ {props.zapper.maxComment() > 0 && (
+ setComment(e.target.value)}
+ />
+ )}
+
+
+ {(amount ?? 0) > 0 && (
+
props.onNextStage(getValue())}>
+
+
+
+
+
+ )}
+
+ );
+}
+
+function SendSatsZapTypeSelector({ zapType, setZapType }: { zapType: ZapType; setZapType: (t: ZapType) => void }) {
+ const { readonly } = useLogin(s => ({ readonly: s.readonly }));
+ const makeTab = (t: ZapType, n: React.ReactNode) => (
+ setZapType(t)}>
+ {n}
+
+ );
+ return (
+
+
+
+
+
+ {!readonly && makeTab(ZapType.PublicZap, )}
+ {/*makeTab(ZapType.PrivateZap, "Private")*/}
+ {makeTab(ZapType.AnonZap, )}
+ {makeTab(
+ ZapType.NonZap,
+ ,
+ )}
+
+
+ );
+}
+
+function SendSatsInvoice(props: {
+ invoice: Array;
+ wallet?: LNWallet;
+ notice?: ReactNode;
+ onInvoicePaid: () => void;
+}) {
+ return (
+
+ {props.notice &&
{props.notice} }
+ {props.invoice.map(v => (
+ <>
+
+
+ >
+ ))}
+
+ );
+}
diff --git a/packages/app/src/Element/SuggestedProfiles.tsx b/packages/app/src/Element/SuggestedProfiles.tsx
index 4680555b..cd931cb1 100644
--- a/packages/app/src/Element/SuggestedProfiles.tsx
+++ b/packages/app/src/Element/SuggestedProfiles.tsx
@@ -1,8 +1,8 @@
import { useEffect, useState } from "react";
import { HexKey, NostrPrefix } from "@snort/system";
-import { FormattedMessage } from "react-intl";
+import FormattedMessage from "Element/FormattedMessage";
-import FollowListBase from "Element/FollowListBase";
+import FollowListBase from "Element/User/FollowListBase";
import PageSpinner from "Element/PageSpinner";
import NostrBandApi from "External/NostrBand";
import SemisolDevApi from "External/SemisolDev";
diff --git a/packages/app/src/Element/Tabs.css b/packages/app/src/Element/Tabs.css
index fc0ef6d4..9ee1a86c 100644
--- a/packages/app/src/Element/Tabs.css
+++ b/packages/app/src/Element/Tabs.css
@@ -3,20 +3,34 @@
align-items: center;
flex-direction: row;
overflow-x: scroll;
- -ms-overflow-style: none; /* for Internet Explorer, Edge */
- scrollbar-width: none; /* Firefox */
+ -ms-overflow-style: none;
+ /* for Internet Explorer, Edge */
+ scrollbar-width: none;
+ /* Firefox */
white-space: nowrap;
gap: 8px;
- padding: 16px 12px;
}
.tabs::-webkit-scrollbar {
display: none;
}
+.light .tab {
+ border: 1px solid var(--border-color);
+}
+
+.tab.active {
+ background: var(--gray-superdark);
+}
+
+.light .tab.active {
+ background: #fff;
+ border-color: #bfc6d6;
+}
+
.tab {
background: var(--gray-ultradark);
- color: var(--font-tertiary-color);
+ color: var(--font-secondary-color);
border-radius: 100px;
font-weight: 600;
font-size: 16px;
@@ -28,8 +42,7 @@
}
.tab.active {
- color: black;
- background: white;
+ color: var(--font-color);
}
.tabs > div {
@@ -43,5 +56,5 @@
}
.tab:hover {
- border-color: var(--font-color);
+ box-shadow: rgba(0, 0, 0, 0.2) 0 1px 3px;
}
diff --git a/packages/app/src/Element/Tabs.tsx b/packages/app/src/Element/Tabs.tsx
index 318afefc..427610f3 100644
--- a/packages/app/src/Element/Tabs.tsx
+++ b/packages/app/src/Element/Tabs.tsx
@@ -31,7 +31,7 @@ export const TabElement = ({ t, tab, setTab }: TabElementProps) => {
const Tabs = ({ tabs, tab, setTab }: TabsProps) => {
const horizontalScroll = useHorizontalScroll();
return (
-
+
{tabs.map(t => (
))}
diff --git a/packages/app/src/Element/Text.css b/packages/app/src/Element/Text.css
index 09c498fe..9201a171 100644
--- a/packages/app/src/Element/Text.css
+++ b/packages/app/src/Element/Text.css
@@ -6,10 +6,8 @@
.text .text-frag {
text-overflow: ellipsis;
white-space: pre-wrap;
- word-break: normal;
- overflow-x: hidden;
- overflow-y: visible;
display: inline;
+ overflow-wrap: break-word;
}
.text .text-frag > a {
diff --git a/packages/app/src/Element/Text.tsx b/packages/app/src/Element/Text.tsx
index c85effcb..02a5d990 100644
--- a/packages/app/src/Element/Text.tsx
+++ b/packages/app/src/Element/Text.tsx
@@ -1,33 +1,50 @@
import "./Text.css";
-import { useMemo, useState } from "react";
-import { HexKey, ParsedFragment, transformText } from "@snort/system";
+import { useState } from "react";
+import { HexKey, ParsedFragment } from "@snort/system";
-import Invoice from "Element/Invoice";
-import Hashtag from "Element/Hashtag";
+import Invoice from "Element/Embed/Invoice";
+import Hashtag from "Element/Embed/Hashtag";
import HyperText from "Element/HyperText";
-import CashuNuts from "Element/CashuNuts";
-import RevealMedia from "./RevealMedia";
+import CashuNuts from "Element/Embed/CashuNuts";
+import RevealMedia from "./Event/RevealMedia";
import { ProxyImg } from "./ProxyImg";
-import { SpotlightMedia } from "./SpotlightMedia";
+import { SpotlightMediaModal } from "./Deck/SpotlightMedia";
import HighlightedText from "./HighlightedText";
+import { useTextTransformer } from "Hooks/useTextTransformCache";
export interface TextProps {
+ id: string;
content: string;
creator: HexKey;
tags: Array
>;
disableMedia?: boolean;
disableMediaSpotlight?: boolean;
+ disableLinkPreview?: boolean;
depth?: number;
+ truncate?: number;
+ className?: string;
highlighText?: string;
+ onClick?: (e: React.MouseEvent) => void;
}
-export default function Text({ content, tags, creator, disableMedia, depth, disableMediaSpotlight, highlighText }: TextProps) {
+export default function Text({
+ id,
+ content,
+ tags,
+ creator,
+ disableMedia,
+ depth,
+ disableMediaSpotlight,
+ disableLinkPreview,
+ truncate,
+ className,
+ highlighText,
+ onClick,
+}: TextProps) {
const [showSpotlight, setShowSpotlight] = useState(false);
const [imageIdx, setImageIdx] = useState(0);
- const elements = useMemo(() => {
- return transformText(content, tags);
- }, [content]);
+ const elements = useTextTransformer(id, content, tags);
const images = elements.filter(a => a.type === "media" && a.mimeType?.startsWith("image")).map(a => a.content);
@@ -60,55 +77,71 @@ export default function Text({ content, tags, creator, disableMedia, depth, disa
);
}
- function renderChunk(a: ParsedFragment) {
- if (a.type === "media" && !a.mimeType?.startsWith("unknown")) {
- if (disableMedia ?? false) {
- return (
- e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
- {a.content}
-
- );
+ const renderContent = () => {
+ let lenCtr = 0;
+ function renderChunk(a: ParsedFragment) {
+ if (truncate) {
+ if (lenCtr > truncate) {
+ return null;
+ } else if (lenCtr + a.content.length > truncate) {
+ lenCtr += a.content.length;
+ return {a.content.slice(0, truncate - lenCtr)}...
;
+ } else {
+ lenCtr += a.content.length;
+ }
}
- return (
- {
- if (!disableMediaSpotlight) {
- e.stopPropagation();
- e.preventDefault();
- setShowSpotlight(true);
- const selected = images.findIndex(b => b === a.content);
- setImageIdx(selected === -1 ? 0 : selected);
- }
- }}
- />
- );
- } else {
- switch (a.type) {
- case "invoice":
- return ;
- case "hashtag":
- return ;
- case "cashu":
- return ;
- case "media":
- case "link":
- return ;
- case "custom_emoji":
- return ;
- default:
- return
- {highlighText ? renderContentWithHighlightedText(a.content, highlighText) : a.content}
-
;
+
+ if (a.type === "media" && !a.mimeType?.startsWith("unknown")) {
+ if (disableMedia ?? false) {
+ return (
+ e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
+ {a.content}
+
+ );
+ }
+ return (
+ {
+ if (!disableMediaSpotlight) {
+ e.stopPropagation();
+ e.preventDefault();
+ setShowSpotlight(true);
+ const selected = images.findIndex(b => b === a.content);
+ setImageIdx(selected === -1 ? 0 : selected);
+ }
+ }}
+ />
+ );
+ } else {
+ switch (a.type) {
+ case "invoice":
+ return ;
+ case "hashtag":
+ return ;
+ case "cashu":
+ return ;
+ case "media":
+ case "link":
+ return ;
+ case "custom_emoji":
+ return ;
+ default:
+ return
+ {highlighText ? renderContentWithHighlightedText(a.content, highlighText) : a.content}
+
;
+ }
}
}
- }
+
+ return elements.map(a => renderChunk(a));
+ };
return (
-
- {elements.map(a => renderChunk(a))}
- {showSpotlight &&
setShowSpotlight(false)} idx={imageIdx} />}
+
+ {renderContent()}
+ {showSpotlight && setShowSpotlight(false)} idx={imageIdx} />}
);
}
diff --git a/packages/app/src/Element/Textarea.tsx b/packages/app/src/Element/Textarea.tsx
index d9249524..3333bd49 100644
--- a/packages/app/src/Element/Textarea.tsx
+++ b/packages/app/src/Element/Textarea.tsx
@@ -6,8 +6,8 @@ import ReactTextareaAutocomplete from "@webscopeio/react-textarea-autocomplete";
import TextareaAutosize from "react-textarea-autosize";
import { NostrPrefix, MetadataCache } from "@snort/system";
-import Avatar from "Element/Avatar";
-import Nip05 from "Element/Nip05";
+import Avatar from "Element/User/Avatar";
+import Nip05 from "Element/User/Nip05";
import { hexToBech32 } from "SnortUtils";
import { UserCache } from "Cache";
import searchEmoji from "emoji-search";
diff --git a/packages/app/src/Element/TrendingUsers.tsx b/packages/app/src/Element/TrendingUsers.tsx
index 0fd51c90..d4e43ab1 100644
--- a/packages/app/src/Element/TrendingUsers.tsx
+++ b/packages/app/src/Element/TrendingUsers.tsx
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { HexKey } from "@snort/system";
-import FollowListBase from "Element/FollowListBase";
+import FollowListBase from "Element/User/FollowListBase";
import PageSpinner from "Element/PageSpinner";
import NostrBandApi from "External/NostrBand";
@@ -22,8 +22,8 @@ export default function TrendingUsers() {
if (!userList) return ;
return (
- <>
+
- >
+
);
}
diff --git a/packages/app/src/Element/User/Avatar.css b/packages/app/src/Element/User/Avatar.css
new file mode 100644
index 00000000..e25b776f
--- /dev/null
+++ b/packages/app/src/Element/User/Avatar.css
@@ -0,0 +1,52 @@
+.avatar {
+ border-radius: 50%;
+ height: 210px;
+ width: 210px;
+ background-image: var(--img-url);
+ border: 1px solid transparent;
+ background-origin: border-box;
+ background-clip: content-box, border-box;
+ background-size: cover;
+ box-sizing: border-box;
+ background-color: var(--gray);
+}
+
+.avatar[data-domain="snort.social"] {
+ background-image: var(--img-url), var(--snort-gradient);
+}
+
+.avatar[data-domain="strike.army"] {
+ background-image: var(--img-url), var(--strike-army-gradient);
+}
+
+.avatar .overlay {
+ color: white;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ border-radius: 50%;
+ background-color: rgba(0, 0, 0, 0.4);
+}
+
+.avatar .icons {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transform-origin: center;
+ transform: rotate(-135deg) translateY(50%);
+}
+
+.avatar .icons > .icon-circle {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transform-origin: center;
+ padding: 4px;
+ border-radius: 100%;
+ background-color: var(--gray-superdark);
+ transform: rotate(135deg);
+}
diff --git a/packages/app/src/Element/Avatar.tsx b/packages/app/src/Element/User/Avatar.tsx
similarity index 56%
rename from packages/app/src/Element/Avatar.tsx
rename to packages/app/src/Element/User/Avatar.tsx
index e3a85162..49b91e3e 100644
--- a/packages/app/src/Element/Avatar.tsx
+++ b/packages/app/src/Element/User/Avatar.tsx
@@ -1,10 +1,10 @@
import "./Avatar.css";
-import { CSSProperties, useEffect, useState } from "react";
+import { CSSProperties, ReactNode, useEffect, useState } from "react";
import type { UserMetadata } from "@snort/system";
import useImgProxy from "Hooks/useImgProxy";
-import { getDisplayName } from "Element/ProfileImage";
+import { getDisplayName } from "Element/User/ProfileImage";
import { defaultAvatar } from "SnortUtils";
interface AvatarProps {
@@ -13,15 +13,19 @@ interface AvatarProps {
onClick?: () => void;
size?: number;
image?: string;
+ imageOverlay?: ReactNode;
+ icons?: ReactNode;
}
-const Avatar = ({ pubkey, user, size, onClick, image }: AvatarProps) => {
+
+const Avatar = ({ pubkey, user, size, onClick, image, imageOverlay, icons }: AvatarProps) => {
const [url, setUrl] = useState("");
const { proxy } = useImgProxy();
+ const s = size ?? 120;
useEffect(() => {
const url = image ?? user?.picture;
if (url) {
- const proxyUrl = proxy(url, size ?? 120);
+ const proxyUrl = proxy(url, s);
setUrl(proxyUrl);
} else {
setUrl(defaultAvatar(pubkey));
@@ -30,14 +34,21 @@ const Avatar = ({ pubkey, user, size, onClick, image }: AvatarProps) => {
const backgroundImage = `url(${url})`;
const style = { "--img-url": backgroundImage } as CSSProperties;
+ if (size) {
+ style.width = `${s}px`;
+ style.height = `${s}px`;
+ }
const domain = user?.nip05 && user.nip05.split("@")[1];
return (
+ title={getDisplayName(user, "")}>
+ {icons && {icons}
}
+ {imageOverlay && {imageOverlay}
}
+
);
};
diff --git a/packages/app/src/Element/AvatarEditor.css b/packages/app/src/Element/User/AvatarEditor.css
similarity index 100%
rename from packages/app/src/Element/AvatarEditor.css
rename to packages/app/src/Element/User/AvatarEditor.css
diff --git a/packages/app/src/Element/AvatarEditor.tsx b/packages/app/src/Element/User/AvatarEditor.tsx
similarity index 100%
rename from packages/app/src/Element/AvatarEditor.tsx
rename to packages/app/src/Element/User/AvatarEditor.tsx
diff --git a/packages/app/src/Element/BadgeList.css b/packages/app/src/Element/User/BadgeList.css
similarity index 100%
rename from packages/app/src/Element/BadgeList.css
rename to packages/app/src/Element/User/BadgeList.css
diff --git a/packages/app/src/Element/BadgeList.tsx b/packages/app/src/Element/User/BadgeList.tsx
similarity index 92%
rename from packages/app/src/Element/BadgeList.tsx
rename to packages/app/src/Element/User/BadgeList.tsx
index 06cbb319..b9cf1326 100644
--- a/packages/app/src/Element/BadgeList.tsx
+++ b/packages/app/src/Element/User/BadgeList.tsx
@@ -1,14 +1,14 @@
import "./BadgeList.css";
import { useState } from "react";
-import { FormattedMessage } from "react-intl";
+import FormattedMessage from "Element/FormattedMessage";
import { TaggedNostrEvent } from "@snort/system";
import { ProxyImg } from "Element/ProxyImg";
import Icon from "Icons/Icon";
import Modal from "Element/Modal";
-import Username from "Element/Username";
+import Username from "Element/User/Username";
import { findTag } from "SnortUtils";
export default function BadgeList({ badges }: { badges: TaggedNostrEvent[] }) {
@@ -35,7 +35,7 @@ export default function BadgeList({ badges }: { badges: TaggedNostrEvent[] }) {
))}
{showModal && (
-
setShowModal(false)}>
+ setShowModal(false)}>
setShowModal(false)}>
diff --git a/packages/app/src/Element/BlockButton.tsx b/packages/app/src/Element/User/BlockButton.tsx
similarity index 86%
rename from packages/app/src/Element/BlockButton.tsx
rename to packages/app/src/Element/User/BlockButton.tsx
index 3245d76d..d5503f9e 100644
--- a/packages/app/src/Element/BlockButton.tsx
+++ b/packages/app/src/Element/User/BlockButton.tsx
@@ -1,8 +1,8 @@
-import { FormattedMessage } from "react-intl";
+import FormattedMessage from "Element/FormattedMessage";
import { HexKey } from "@snort/system";
import useModeration from "Hooks/useModeration";
-import messages from "./messages";
+import messages from "../messages";
interface BlockButtonProps {
pubkey: HexKey;
diff --git a/packages/app/src/Element/BlockList.tsx b/packages/app/src/Element/User/BlockList.tsx
similarity index 67%
rename from packages/app/src/Element/BlockList.tsx
rename to packages/app/src/Element/User/BlockList.tsx
index bcd5f3a5..7fc353d4 100644
--- a/packages/app/src/Element/BlockList.tsx
+++ b/packages/app/src/Element/User/BlockList.tsx
@@ -1,12 +1,12 @@
-import BlockButton from "Element/BlockButton";
-import ProfilePreview from "Element/ProfilePreview";
+import BlockButton from "Element/User/BlockButton";
+import ProfilePreview from "Element/User/ProfilePreview";
import useModeration from "Hooks/useModeration";
export default function BlockList() {
const { blocked } = useModeration();
return (
-
+
{blocked.map(a => {
return
} pubkey={a} options={{ about: false }} key={a} />;
})}
diff --git a/packages/app/src/Element/FollowButton.css b/packages/app/src/Element/User/FollowButton.css
similarity index 100%
rename from packages/app/src/Element/FollowButton.css
rename to packages/app/src/Element/User/FollowButton.css
diff --git a/packages/app/src/Element/FollowButton.tsx b/packages/app/src/Element/User/FollowButton.tsx
similarity index 71%
rename from packages/app/src/Element/FollowButton.tsx
rename to packages/app/src/Element/User/FollowButton.tsx
index 3319c5fa..5952b7fd 100644
--- a/packages/app/src/Element/FollowButton.tsx
+++ b/packages/app/src/Element/User/FollowButton.tsx
@@ -1,14 +1,15 @@
import "./FollowButton.css";
-import { FormattedMessage } from "react-intl";
+import FormattedMessage from "Element/FormattedMessage";
import { HexKey } from "@snort/system";
-import useEventPublisher from "Feed/EventPublisher";
+import useEventPublisher from "Hooks/useEventPublisher";
import { parseId } from "SnortUtils";
import useLogin from "Hooks/useLogin";
import AsyncButton from "Element/AsyncButton";
import { System } from "index";
-import messages from "./messages";
+import messages from "../messages";
+import { FollowsFeed } from "Cache";
export interface FollowButtonProps {
pubkey: HexKey;
@@ -17,13 +18,14 @@ export interface FollowButtonProps {
export default function FollowButton(props: FollowButtonProps) {
const pubkey = parseId(props.pubkey);
const publisher = useEventPublisher();
- const { follows, relays } = useLogin();
+ const { follows, relays, readonly } = useLogin(s => ({ follows: s.follows, relays: s.relays, readonly: s.readonly }));
const isFollowing = follows.item.includes(pubkey);
- const baseClassname = `${props.className} follow-button`;
+ const baseClassname = `${props.className ? ` ${props.className}` : ""}follow-button`;
async function follow(pubkey: HexKey) {
if (publisher) {
const ev = await publisher.contactList([pubkey, ...follows.item], relays.item);
+ await FollowsFeed.backFill(System, [pubkey]);
System.BroadcastEvent(ev);
}
}
@@ -32,7 +34,7 @@ export default function FollowButton(props: FollowButtonProps) {
if (publisher) {
const ev = await publisher.contactList(
follows.item.filter(a => a !== pubkey),
- relays.item
+ relays.item,
);
System.BroadcastEvent(ev);
}
@@ -41,6 +43,7 @@ export default function FollowButton(props: FollowButtonProps) {
return (
(isFollowing ? unfollow(pubkey) : follow(pubkey))}>
{isFollowing ? : }
diff --git a/packages/app/src/Element/FollowListBase.tsx b/packages/app/src/Element/User/FollowListBase.tsx
similarity index 58%
rename from packages/app/src/Element/FollowListBase.tsx
rename to packages/app/src/Element/User/FollowListBase.tsx
index c10eaa5a..d169d086 100644
--- a/packages/app/src/Element/FollowListBase.tsx
+++ b/packages/app/src/Element/User/FollowListBase.tsx
@@ -1,13 +1,17 @@
import { ReactNode } from "react";
-import { FormattedMessage } from "react-intl";
+import FormattedMessage from "Element/FormattedMessage";
import { HexKey } from "@snort/system";
-import useEventPublisher from "Feed/EventPublisher";
-import ProfilePreview from "Element/ProfilePreview";
+import useEventPublisher from "Hooks/useEventPublisher";
+import ProfilePreview from "Element/User/ProfilePreview";
import useLogin from "Hooks/useLogin";
import { System } from "index";
-import messages from "./messages";
+import messages from "../messages";
+import { FollowsFeed } from "Cache";
+import AsyncButton from "../AsyncButton";
+import { setFollows } from "Login";
+import { dedupe } from "@snort/shared";
export interface FollowListBaseProps {
pubkeys: HexKey[];
@@ -29,12 +33,15 @@ export default function FollowListBase({
profileActions,
}: FollowListBaseProps) {
const publisher = useEventPublisher();
- const { follows, relays } = useLogin();
+ const login = useLogin();
async function followAll() {
if (publisher) {
- const ev = await publisher.contactList([...pubkeys, ...follows.item], relays.item);
+ const newFollows = dedupe([...pubkeys, ...login.follows.item]);
+ const ev = await publisher.contactList(newFollows, login.relays.item);
System.BroadcastEvent(ev);
+ await FollowsFeed.backFill(System, pubkeys);
+ setFollows(login, newFollows, ev.created_at);
}
}
@@ -44,9 +51,9 @@ export default function FollowListBase({
{title}
{actions}
-
followAll()}>
+ followAll()} disabled={login.readonly}>
-
+
)}
{pubkeys?.map(a => (
diff --git a/packages/app/src/Element/User/Following.css b/packages/app/src/Element/User/Following.css
new file mode 100644
index 00000000..329c6b59
--- /dev/null
+++ b/packages/app/src/Element/User/Following.css
@@ -0,0 +1,7 @@
+span.following {
+ padding: 2px 4px;
+ border-radius: 4px;
+ font-size: 11px;
+ color: var(--font-secondary-color);
+ background-color: var(--gray-superdark);
+}
diff --git a/packages/app/src/Element/User/Following.tsx b/packages/app/src/Element/User/Following.tsx
new file mode 100644
index 00000000..6c9efe62
--- /dev/null
+++ b/packages/app/src/Element/User/Following.tsx
@@ -0,0 +1,17 @@
+import "./Following.css";
+import useLogin from "Hooks/useLogin";
+import Icon from "Icons/Icon";
+import FormattedMessage from "Element/FormattedMessage";
+
+export function FollowingMark({ pubkey }: { pubkey: string }) {
+ const { follows } = useLogin(s => ({ follows: s.follows }));
+ const doesFollow = follows.item.includes(pubkey);
+ if (!doesFollow) return;
+
+ return (
+
+
+
+
+ );
+}
diff --git a/packages/app/src/Element/FollowsYou.css b/packages/app/src/Element/User/FollowsYou.css
similarity index 63%
rename from packages/app/src/Element/FollowsYou.css
rename to packages/app/src/Element/User/FollowsYou.css
index 5b91d30f..cd144165 100644
--- a/packages/app/src/Element/FollowsYou.css
+++ b/packages/app/src/Element/User/FollowsYou.css
@@ -3,4 +3,7 @@
font-size: var(--font-size-tiny);
margin-left: 0.2em;
font-weight: normal;
+ padding: 4px 6px;
+ background: var(--bg-secondary);
+ border-radius: 6px;
}
diff --git a/packages/app/src/Element/FollowsYou.tsx b/packages/app/src/Element/User/FollowsYou.tsx
similarity index 90%
rename from packages/app/src/Element/FollowsYou.tsx
rename to packages/app/src/Element/User/FollowsYou.tsx
index 8dc1fd6f..865fc98e 100644
--- a/packages/app/src/Element/FollowsYou.tsx
+++ b/packages/app/src/Element/User/FollowsYou.tsx
@@ -1,7 +1,7 @@
import "./FollowsYou.css";
import { useIntl } from "react-intl";
-import messages from "./messages";
+import messages from "../messages";
export interface FollowsYouProps {
followsMe: boolean;
diff --git a/packages/app/src/Element/MuteButton.tsx b/packages/app/src/Element/User/MuteButton.tsx
similarity index 86%
rename from packages/app/src/Element/MuteButton.tsx
rename to packages/app/src/Element/User/MuteButton.tsx
index b95d1e21..c3b7786a 100644
--- a/packages/app/src/Element/MuteButton.tsx
+++ b/packages/app/src/Element/User/MuteButton.tsx
@@ -1,8 +1,8 @@
-import { FormattedMessage } from "react-intl";
+import FormattedMessage from "Element/FormattedMessage";
import { HexKey } from "@snort/system";
import useModeration from "Hooks/useModeration";
-import messages from "./messages";
+import messages from "../messages";
interface MuteButtonProps {
pubkey: HexKey;
diff --git a/packages/app/src/Element/MutedList.tsx b/packages/app/src/Element/User/MutedList.tsx
similarity index 73%
rename from packages/app/src/Element/MutedList.tsx
rename to packages/app/src/Element/User/MutedList.tsx
index b3ecb946..fefb1f77 100644
--- a/packages/app/src/Element/MutedList.tsx
+++ b/packages/app/src/Element/User/MutedList.tsx
@@ -1,10 +1,10 @@
-import { FormattedMessage } from "react-intl";
+import FormattedMessage from "Element/FormattedMessage";
import { HexKey } from "@snort/system";
-import MuteButton from "Element/MuteButton";
-import ProfilePreview from "Element/ProfilePreview";
+import MuteButton from "Element/User/MuteButton";
+import ProfilePreview from "Element/User/ProfilePreview";
import useModeration from "Hooks/useModeration";
-import messages from "./messages";
+import messages from "../messages";
export interface MutedListProps {
pubkeys: HexKey[];
@@ -15,9 +15,9 @@ export default function MutedList({ pubkeys }: MutedListProps) {
const hasAllMuted = pubkeys.every(isMuted);
return (
-
-
-
+
+
+
void;
+ imageOverlay?: ReactNode;
+ showFollowingMark?: boolean;
+ icons?: ReactNode;
}
export default function ProfileImage({
@@ -34,10 +39,15 @@ export default function ProfileImage({
overrideUsername,
profile,
size,
+ imageOverlay,
onClick,
+ showFollowingMark = true,
+ icons,
}: ProfileImageProps) {
const user = useUserProfile(profile ? "" : pubkey) ?? profile;
const nip05 = defaultNip ? defaultNip : user?.nip05;
+ const { follows } = useLogin();
+ const doesFollow = follows.item.includes(pubkey);
const name = useMemo(() => {
return overrideUsername ?? getDisplayName(user, pubkey);
@@ -54,7 +64,24 @@ export default function ProfileImage({
return (
<>
-
+
+ {icons}
+ {showFollowingMark && (
+
+
+
+ )}
+ >
+ ) : undefined
+ }
+ />
{showUsername && (
diff --git a/packages/app/src/Element/ProfilePreview.css b/packages/app/src/Element/User/ProfilePreview.css
similarity index 100%
rename from packages/app/src/Element/ProfilePreview.css
rename to packages/app/src/Element/User/ProfilePreview.css
diff --git a/packages/app/src/Element/ProfilePreview.tsx b/packages/app/src/Element/User/ProfilePreview.tsx
similarity index 86%
rename from packages/app/src/Element/ProfilePreview.tsx
rename to packages/app/src/Element/User/ProfilePreview.tsx
index 1e2b31e4..1fd65517 100644
--- a/packages/app/src/Element/ProfilePreview.tsx
+++ b/packages/app/src/Element/User/ProfilePreview.tsx
@@ -1,11 +1,11 @@
import "./ProfilePreview.css";
import { ReactNode } from "react";
-import { HexKey } from "@snort/system";
+import { HexKey, UserMetadata } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import { useInView } from "react-intersection-observer";
-import ProfileImage from "Element/ProfileImage";
-import FollowButton from "Element/FollowButton";
+import ProfileImage from "Element/User/ProfileImage";
+import FollowButton from "Element/User/FollowButton";
export interface ProfilePreviewProps {
pubkey: HexKey;
@@ -13,6 +13,7 @@ export interface ProfilePreviewProps {
about?: boolean;
linkToProfile?: boolean;
};
+ profile?: UserMetadata;
actions?: ReactNode;
className?: string;
onClick?: (e: React.MouseEvent
) => void;
@@ -41,6 +42,7 @@ export default function ProfilePreview(props: ProfilePreviewProps) {
<>
{user?.about} : undefined}
/>
diff --git a/packages/app/src/Element/Username.tsx b/packages/app/src/Element/User/Username.tsx
similarity index 100%
rename from packages/app/src/Element/Username.tsx
rename to packages/app/src/Element/User/Username.tsx
diff --git a/packages/app/src/Element/messages.ts b/packages/app/src/Element/messages.ts
index 2cae9eb5..904ac049 100644
--- a/packages/app/src/Element/messages.ts
+++ b/packages/app/src/Element/messages.ts
@@ -29,7 +29,6 @@ export default defineMessages({
PayInvoice: { defaultMessage: "Pay Invoice" },
Expired: { defaultMessage: "Expired" },
Pay: { defaultMessage: "Pay" },
- Paid: { defaultMessage: "Paid" },
Loading: { defaultMessage: "Loading..." },
Logout: { defaultMessage: "Logout" },
ShowMore: { defaultMessage: "Show more" },
@@ -64,14 +63,8 @@ export default defineMessages({
InvoiceFail: { defaultMessage: "Failed to load invoice" },
Custom: { defaultMessage: "Custom" },
Confirm: { defaultMessage: "Confirm" },
- ZapAmount: { defaultMessage: "Zap amount in sats" },
Comment: { defaultMessage: "Comment" },
- ZapTarget: { defaultMessage: "Zap {target} {n} sats" },
- ZapSats: { defaultMessage: "Zap {n} sats" },
- OpenWallet: { defaultMessage: "Open Wallet" },
SendZap: { defaultMessage: "Send zap" },
- SendSats: { defaultMessage: "Send sats" },
- ToTarget: { defaultMessage: "{action} to {target}" },
ShowReplies: { defaultMessage: "Show replies" },
TooShort: { defaultMessage: "name too short" },
TooLong: { defaultMessage: "name too long" },
diff --git a/packages/app/src/Feed/ArticlesFeed.ts b/packages/app/src/Feed/ArticlesFeed.ts
new file mode 100644
index 00000000..e4f88761
--- /dev/null
+++ b/packages/app/src/Feed/ArticlesFeed.ts
@@ -0,0 +1,18 @@
+import { EventKind, NoteCollection, RequestBuilder } from "@snort/system";
+import { useRequestBuilder } from "@snort/system-react";
+import useLogin from "Hooks/useLogin";
+import { useMemo } from "react";
+
+export function useArticles() {
+ const { publicKey, follows } = useLogin();
+
+ const sub = useMemo(() => {
+ if (!publicKey) return null;
+ const rb = new RequestBuilder(`articles:${publicKey}`);
+ rb.withFilter().kinds([EventKind.LongFormTextNote]).authors(follows.item).limit(20);
+
+ return rb;
+ }, [follows.timestamp]);
+
+ return useRequestBuilder(NoteCollection, sub);
+}
diff --git a/packages/app/src/Feed/BadgesFeed.ts b/packages/app/src/Feed/BadgesFeed.ts
index d6905c5d..d180f160 100644
--- a/packages/app/src/Feed/BadgesFeed.ts
+++ b/packages/app/src/Feed/BadgesFeed.ts
@@ -23,7 +23,7 @@ export default function useProfileBadges(pubkey?: HexKey) {
if (profileBadges.data) {
return chunks(
profileBadges.data.tags.filter(t => t[0] === "a" || t[0] === "e"),
- 2
+ 2,
).reduce((acc, [a, e]) => {
return {
...acc,
@@ -44,7 +44,7 @@ export default function useProfileBadges(pubkey?: HexKey) {
}
return acc;
},
- { pubkeys: [], ds: [] } as BadgeAwards
+ { pubkeys: [], ds: [] } as BadgeAwards,
) as BadgeAwards;
}, [profile]);
@@ -77,7 +77,7 @@ export default function useProfileBadges(pubkey?: HexKey) {
})
.filter(
({ award, badge }) =>
- badge && award.pubkey === badge.pubkey && award.tags.find(t => t[0] === "p" && t[1] === pubkey)
+ badge && award.pubkey === badge.pubkey && award.tags.find(t => t[0] === "p" && t[1] === pubkey),
)
.map(({ badge }) => unwrap(badge));
}
diff --git a/packages/app/src/Feed/EventFeed.ts b/packages/app/src/Feed/EventFeed.ts
index 23532025..3f8fce44 100644
--- a/packages/app/src/Feed/EventFeed.ts
+++ b/packages/app/src/Feed/EventFeed.ts
@@ -1,31 +1,23 @@
import { useMemo } from "react";
-import { NostrPrefix, RequestBuilder, ReplaceableNoteStore, NostrLink } from "@snort/system";
+import { RequestBuilder, ReplaceableNoteStore, NostrLink, NoteCollection } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
-import { unwrap } from "SnortUtils";
-
-export default function useEventFeed(link: NostrLink) {
+export function useEventFeed(link: NostrLink) {
const sub = useMemo(() => {
const b = new RequestBuilder(`event:${link.id.slice(0, 12)}`);
- if (link.type === NostrPrefix.Address) {
- const f = b.withFilter().tag("d", [link.id]);
- if (link.author) {
- f.authors([unwrap(link.author)]);
- }
- if (link.kind) {
- f.kinds([unwrap(link.kind)]);
- }
- } else {
- const f = b.withFilter().ids([link.id]);
- if (link.relays) {
- link.relays.slice(0, 2).forEach(r => f.relay(r));
- }
- if (link.author) {
- f.authors([link.author]);
- }
- }
+ b.withFilter().link(link);
return b;
}, [link]);
return useRequestBuilder(ReplaceableNoteStore, sub);
}
+
+export function useEventsFeed(id: string, links: Array) {
+ const sub = useMemo(() => {
+ const b = new RequestBuilder(`events:${id}`);
+ links.forEach(v => b.withFilter().link(v));
+ return b;
+ }, [id, links]);
+
+ return useRequestBuilder(NoteCollection, sub);
+}
diff --git a/packages/app/src/Feed/EventPublisher.ts b/packages/app/src/Feed/EventPublisher.ts
deleted file mode 100644
index c6351274..00000000
--- a/packages/app/src/Feed/EventPublisher.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import useLogin from "Hooks/useLogin";
-import { DefaultPowWorker } from "index";
-
-export default function useEventPublisher() {
- const { publisher, preferences } = useLogin();
- if (preferences.pow) {
- publisher?.pow(preferences.pow, DefaultPowWorker);
- }
- return publisher;
-}
diff --git a/packages/app/src/Feed/FeedReactions.ts b/packages/app/src/Feed/FeedReactions.ts
deleted file mode 100644
index cd32b57a..00000000
--- a/packages/app/src/Feed/FeedReactions.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { RequestBuilder, EventKind, NoteCollection } from "@snort/system";
-import { useRequestBuilder } from "@snort/system-react";
-import useLogin from "Hooks/useLogin";
-import { useMemo } from "react";
-
-export function useReactions(subId: string, ids: Array, others?: (rb: RequestBuilder) => void) {
- const { preferences: pref } = useLogin();
-
- const sub = useMemo(() => {
- const rb = new RequestBuilder(subId);
- if (ids.length > 0) {
- rb.withFilter()
- .kinds(
- pref.enableReactions
- ? [EventKind.Reaction, EventKind.Repost, EventKind.ZapReceipt]
- : [EventKind.ZapReceipt, EventKind.Repost]
- )
- .tag("e", ids);
- }
- others?.(rb);
- return rb.numFilters > 0 ? rb : null;
- }, [ids]);
-
- return useRequestBuilder(NoteCollection, sub);
-}
diff --git a/packages/app/src/Feed/FollowersFeed.ts b/packages/app/src/Feed/FollowersFeed.ts
index 84544a29..e8b5c289 100644
--- a/packages/app/src/Feed/FollowersFeed.ts
+++ b/packages/app/src/Feed/FollowersFeed.ts
@@ -14,7 +14,7 @@ export default function useFollowersFeed(pubkey?: HexKey) {
const followers = useMemo(() => {
const contactLists = followersFeed.data?.filter(
- a => a.kind === EventKind.ContactList && a.tags.some(b => b[0] === "p" && b[1] === pubkey)
+ a => a.kind === EventKind.ContactList && a.tags.some(b => b[0] === "p" && b[1] === pubkey),
);
return [...new Set(contactLists?.map(a => a.pubkey))];
}, [followersFeed, pubkey]);
diff --git a/packages/app/src/Feed/LiveChatFeed.tsx b/packages/app/src/Feed/LiveChatFeed.tsx
deleted file mode 100644
index e4d16e82..00000000
--- a/packages/app/src/Feed/LiveChatFeed.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import { EventKind, FlatNoteStore, NostrLink, RequestBuilder } from "@snort/system";
-import { useRequestBuilder } from "@snort/system-react";
-import { useMemo } from "react";
-
-export function useLiveChatFeed(link: NostrLink) {
- const sub = useMemo(() => {
- const rb = new RequestBuilder(`live:${link.id}:${link.author}`);
- rb.withOptions({
- leaveOpen: true,
- });
- rb.withFilter()
- .kinds([EventKind.ZapReceipt, 1311 as EventKind])
- .tag("a", [`${link.kind}:${link.author}:${link.id}`])
- .limit(100);
- return rb;
- }, [link]);
-
- return useRequestBuilder(FlatNoteStore, sub);
-}
diff --git a/packages/app/src/Feed/LoginFeed.ts b/packages/app/src/Feed/LoginFeed.ts
index 0fa698d1..14f06539 100644
--- a/packages/app/src/Feed/LoginFeed.ts
+++ b/packages/app/src/Feed/LoginFeed.ts
@@ -1,20 +1,32 @@
import { useEffect, useMemo } from "react";
-import { TaggedNostrEvent, Lists, EventKind, FlatNoteStore, RequestBuilder, NoteCollection } from "@snort/system";
+import { TaggedNostrEvent, Lists, EventKind, RequestBuilder, NoteCollection } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { bech32ToHex, getNewest, getNewestEventTagsByKey, unwrap } from "SnortUtils";
import { makeNotification, sendNotification } from "Notifications";
-import useEventPublisher from "Feed/EventPublisher";
+import useEventPublisher from "Hooks/useEventPublisher";
import { getMutedKeys } from "Feed/MuteList";
import useModeration from "Hooks/useModeration";
import useLogin from "Hooks/useLogin";
-import { addSubscription, setBlocked, setBookmarked, setFollows, setMuted, setPinned, setRelays, setTags } from "Login";
+import {
+ SnortAppData,
+ addSubscription,
+ setAppData,
+ setBlocked,
+ setBookmarked,
+ setFollows,
+ setMuted,
+ setPinned,
+ setRelays,
+ setTags,
+} from "Login";
import { SnortPubKey } from "Const";
import { SubscriptionEvent } from "Subscription";
import useRelaysFeedFollows from "./RelaysFeedFollows";
-import { GiftsCache, Notifications, UserRelays } from "Cache";
+import { FollowsFeed, GiftsCache, Notifications, UserRelays } from "Cache";
import { System } from "index";
-import { Nip29Chats, Nip4Chats } from "chat";
+import { Nip28Chats, Nip4Chats } from "chat";
+import { useRefreshFeedCache } from "Hooks/useRefreshFeedcache";
/**
* Managed loading data for the current logged in user
@@ -25,46 +37,48 @@ export default function useLoginFeed() {
const { isMuted } = useModeration();
const publisher = useEventPublisher();
+ useRefreshFeedCache(Notifications, true);
+ useRefreshFeedCache(FollowsFeed, true);
+ useRefreshFeedCache(GiftsCache, true);
+
const subLogin = useMemo(() => {
- if (!pubKey) return null;
+ if (!login || !pubKey) return null;
const b = new RequestBuilder(`login:${pubKey.slice(0, 12)}`);
b.withOptions({
leaveOpen: true,
});
b.withFilter().authors([pubKey]).kinds([EventKind.ContactList]);
- b.withFilter()
- .kinds([EventKind.SnortSubscriptions])
- .authors([bech32ToHex(SnortPubKey)])
- .tag("p", [pubKey])
- .limit(1);
- b.withFilter().kinds([EventKind.GiftWrap]).tag("p", [pubKey]).since(GiftsCache.newest());
-
- b.add(Nip4Chats.subscription(pubKey));
- Notifications.buildSub(login, b);
-
- return b;
- }, [pubKey]);
-
- const subLists = useMemo(() => {
- if (!pubKey) return null;
- const b = new RequestBuilder(`login:${pubKey.slice(0, 12)}:lists`);
- b.withOptions({
- leaveOpen: true,
- });
+ if (!login.readonly) {
+ b.withFilter().authors([pubKey]).kinds([EventKind.AppData]).tag("d", ["snort"]);
+ b.withFilter()
+ .relay("wss://relay.snort.social")
+ .kinds([EventKind.SnortSubscriptions])
+ .authors([bech32ToHex(SnortPubKey)])
+ .tag("p", [pubKey])
+ .limit(1);
+ }
b.withFilter()
.authors([pubKey])
.kinds([EventKind.PubkeyLists])
.tag("d", [Lists.Muted, Lists.Followed, Lists.Pinned, Lists.Bookmarked]);
+ const n4Sub = Nip4Chats.subscription(login);
+ if (n4Sub) {
+ b.add(n4Sub);
+ }
+ const n28Sub = Nip28Chats.subscription(login);
+ if (n28Sub) {
+ b.add(n28Sub);
+ }
return b;
- }, [pubKey]);
+ }, [login]);
const loginFeed = useRequestBuilder(NoteCollection, subLogin);
// update relays and follow lists
useEffect(() => {
- if (loginFeed.data && publisher) {
+ if (loginFeed.data) {
const contactList = getNewest(loginFeed.data.filter(a => a.kind === EventKind.ContactList));
if (contactList) {
if (contactList.content !== "" && contactList.content !== "{}") {
@@ -73,30 +87,37 @@ export default function useLoginFeed() {
}
const pTags = contactList.tags.filter(a => a[0] === "p").map(a => a[1]);
setFollows(login, pTags, contactList.created_at * 1000);
+
+ FollowsFeed.backFillIfMissing(System, pTags);
}
Nip4Chats.onEvent(loginFeed.data);
- Nip29Chats.onEvent(loginFeed.data);
- Notifications.onEvent(loginFeed.data);
+ Nip28Chats.onEvent(loginFeed.data);
- const giftWraps = loginFeed.data.filter(a => a.kind === EventKind.GiftWrap);
- GiftsCache.onEvent(giftWraps, publisher);
+ if (publisher) {
+ const subs = loginFeed.data.filter(
+ a => a.kind === EventKind.SnortSubscriptions && a.pubkey === bech32ToHex(SnortPubKey),
+ );
+ Promise.all(
+ subs.map(async a => {
+ const dx = await publisher.decryptDm(a);
+ if (dx) {
+ const ex = JSON.parse(dx);
+ return {
+ id: a.id,
+ ...ex,
+ } as SubscriptionEvent;
+ }
+ }),
+ ).then(a => addSubscription(login, ...a.filter(a => a !== undefined).map(unwrap)));
- const subs = loginFeed.data.filter(
- a => a.kind === EventKind.SnortSubscriptions && a.pubkey === bech32ToHex(SnortPubKey)
- );
- Promise.all(
- subs.map(async a => {
- const dx = await publisher.decryptDm(a);
- if (dx) {
- const ex = JSON.parse(dx);
- return {
- id: a.id,
- ...ex,
- } as SubscriptionEvent;
- }
- })
- ).then(a => addSubscription(login, ...a.filter(a => a !== undefined).map(unwrap)));
+ const appData = getNewest(loginFeed.data.filter(a => a.kind === EventKind.AppData));
+ if (appData) {
+ publisher.decryptGeneric(appData.content, appData.pubkey).then(d => {
+ setAppData(login, JSON.parse(d) as SnortAppData, appData.created_at * 1000);
+ });
+ }
+ }
}
}, [loginFeed, publisher]);
@@ -104,7 +125,7 @@ export default function useLoginFeed() {
useEffect(() => {
if (loginFeed.data) {
const replies = loginFeed.data.filter(
- a => a.kind === EventKind.TextNote && !isMuted(a.pubkey) && a.created_at > readNotifications
+ a => a.kind === EventKind.TextNote && !isMuted(a.pubkey) && a.created_at > readNotifications,
);
replies.forEach(async nx => {
const n = await makeNotification(nx);
@@ -156,26 +177,28 @@ export default function useLoginFeed() {
}
}
- const listsFeed = useRequestBuilder(FlatNoteStore, subLists);
-
useEffect(() => {
- if (listsFeed.data) {
+ if (loginFeed.data) {
const getList = (evs: readonly TaggedNostrEvent[], list: Lists) =>
- evs.filter(a => unwrap(a.tags.find(b => b[0] === "d"))[1] === list);
+ evs
+ .filter(
+ a => a.kind === EventKind.TagLists || a.kind === EventKind.NoteLists || a.kind === EventKind.PubkeyLists,
+ )
+ .filter(a => unwrap(a.tags.find(b => b[0] === "d"))[1] === list);
- const mutedFeed = getList(listsFeed.data, Lists.Muted);
+ const mutedFeed = getList(loginFeed.data, Lists.Muted);
handleMutedFeed(mutedFeed);
- const pinnedFeed = getList(listsFeed.data, Lists.Pinned);
+ const pinnedFeed = getList(loginFeed.data, Lists.Pinned);
handlePinnedFeed(pinnedFeed);
- const tagsFeed = getList(listsFeed.data, Lists.Followed);
+ const tagsFeed = getList(loginFeed.data, Lists.Followed);
handleTagFeed(tagsFeed);
- const bookmarkFeed = getList(listsFeed.data, Lists.Bookmarked);
+ const bookmarkFeed = getList(loginFeed.data, Lists.Bookmarked);
handleBookmarkFeed(bookmarkFeed);
}
- }, [listsFeed]);
+ }, [loginFeed]);
useEffect(() => {
UserRelays.buffer(follows.item).catch(console.error);
diff --git a/packages/app/src/Feed/Reactions.ts b/packages/app/src/Feed/Reactions.ts
new file mode 100644
index 00000000..db6d0a39
--- /dev/null
+++ b/packages/app/src/Feed/Reactions.ts
@@ -0,0 +1,37 @@
+import { RequestBuilder, EventKind, NoteCollection, NostrLink } from "@snort/system";
+import { useRequestBuilder } from "@snort/system-react";
+import useLogin from "Hooks/useLogin";
+import { useMemo } from "react";
+
+export function useReactions(subId: string, ids: Array, others?: (rb: RequestBuilder) => void) {
+ const { preferences: pref } = useLogin();
+
+ const sub = useMemo(() => {
+ const rb = new RequestBuilder(subId);
+
+ if (ids.length > 0) {
+ const grouped = ids.reduce(
+ (acc, v) => {
+ acc[v.type] ??= [];
+ acc[v.type].push(v);
+ return acc;
+ },
+ {} as Record>,
+ );
+
+ for (const [, v] of Object.entries(grouped)) {
+ rb.withFilter()
+ .kinds(
+ pref.enableReactions
+ ? [EventKind.Reaction, EventKind.Repost, EventKind.ZapReceipt]
+ : [EventKind.ZapReceipt, EventKind.Repost],
+ )
+ .replyToLink(v);
+ }
+ }
+ others?.(rb);
+ return rb.numFilters > 0 ? rb : null;
+ }, [ids]);
+
+ return useRequestBuilder(NoteCollection, sub);
+}
diff --git a/packages/app/src/Feed/StatusFeed.ts b/packages/app/src/Feed/StatusFeed.ts
new file mode 100644
index 00000000..b84a3c98
--- /dev/null
+++ b/packages/app/src/Feed/StatusFeed.ts
@@ -0,0 +1,28 @@
+import { EventKind, NoteCollection, RequestBuilder } from "@snort/system";
+import { useRequestBuilder } from "@snort/system-react";
+import { findTag } from "SnortUtils";
+import { useMemo } from "react";
+
+export function useStatusFeed(id?: string, leaveOpen = false) {
+ const sub = useMemo(() => {
+ if (!id) return null;
+
+ const rb = new RequestBuilder(`statud:${id}`);
+ rb.withOptions({ leaveOpen });
+ rb.withFilter()
+ .kinds([30315 as EventKind])
+ .authors([id]);
+
+ return rb;
+ }, [id]);
+
+ const status = useRequestBuilder(NoteCollection, sub);
+
+ const general = status.data?.find(a => findTag(a, "d") === "general");
+ const music = status.data?.find(a => findTag(a, "d") === "music");
+
+ return {
+ general,
+ music,
+ };
+}
diff --git a/packages/app/src/Feed/ThreadFeed.ts b/packages/app/src/Feed/ThreadFeed.ts
index 6603bd2d..6eef9946 100644
--- a/packages/app/src/Feed/ThreadFeed.ts
+++ b/packages/app/src/Feed/ThreadFeed.ts
@@ -1,101 +1,73 @@
import { useEffect, useMemo, useState } from "react";
-import { u256, EventKind, NostrLink, FlatNoteStore, RequestBuilder, NostrPrefix } from "@snort/system";
+import { EventKind, NostrLink, RequestBuilder, NoteCollection, EventExt } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
-import { appendDedupe } from "SnortUtils";
-import useLogin from "Hooks/useLogin";
+import { useReactions } from "./Reactions";
-interface RelayTaggedEventId {
- id: u256;
- relay?: string;
-}
export default function useThreadFeed(link: NostrLink) {
- const [trackingEvents, setTrackingEvent] = useState>([]);
- const [trackingATags, setTrackingATags] = useState([]);
- const [allEvents, setAllEvents] = useState>([]);
- const pref = useLogin().preferences;
+ const [root, setRoot] = useState();
+ const [allEvents, setAllEvents] = useState>([]);
const sub = useMemo(() => {
- const sub = new RequestBuilder(`thread:${link.id.substring(0, 8)}`);
+ const sub = new RequestBuilder(`thread:${link.id.slice(0, 12)}`);
sub.withOptions({
leaveOpen: true,
});
- if (trackingEvents.length > 0) {
- for (const te of trackingEvents) {
- const fTracking = sub.withFilter();
- fTracking.ids([te.id]);
- if (te.relay) {
- fTracking.relay(te.relay);
- }
- }
+ sub.withFilter().link(link);
+ if (root) {
+ sub.withFilter().link(root);
}
- if (allEvents.length > 0) {
- sub
- .withFilter()
- .kinds(
- pref.enableReactions
- ? [EventKind.Reaction, EventKind.TextNote, EventKind.Repost, EventKind.ZapReceipt]
- : [EventKind.TextNote, EventKind.ZapReceipt, EventKind.Repost]
- )
- .tag(
- "e",
- allEvents.map(a => a.id)
- );
- }
- if (trackingATags.length > 0) {
- const parsed = trackingATags.map(a => a.split(":"));
- sub
- .withFilter()
- .kinds(parsed.map(a => Number(a[0])))
- .authors(parsed.map(a => a[1]))
- .tag(
- "d",
- parsed.map(a => a[2])
- );
- sub.withFilter().tag("a", trackingATags);
+ const grouped = [link, ...allEvents].reduce(
+ (acc, v) => {
+ acc[v.type] ??= [];
+ acc[v.type].push(v);
+ return acc;
+ },
+ {} as Record>,
+ );
+
+ for (const [, v] of Object.entries(grouped)) {
+ sub.withFilter().kinds([EventKind.TextNote]).replyToLink(v);
}
return sub;
- }, [trackingEvents, trackingATags, allEvents, pref]);
+ }, [allEvents.length]);
- const store = useRequestBuilder(FlatNoteStore, sub);
-
- useEffect(() => {
- if (link.type === NostrPrefix.Address) {
- setTrackingATags([`${link.kind}:${link.author}:${link.id}`]);
- } else {
- const lnk = {
- id: link.id,
- relay: link.relays?.[0],
- };
- setTrackingEvent([lnk]);
- setAllEvents([lnk]);
- }
- }, [link.id]);
+ const store = useRequestBuilder(NoteCollection, sub);
useEffect(() => {
if (store.data) {
- const mainNotes = store.data?.filter(a => a.kind === EventKind.TextNote || a.kind === EventKind.Polls) ?? [];
-
- const eTags = mainNotes
- .map(a =>
- a.tags
- .filter(b => b[0] === "e")
- .map(b => {
- return {
- id: b[1],
- relay: b[2],
- };
- })
- )
+ const links = store.data
+ .map(a => [
+ NostrLink.fromEvent(a),
+ ...a.tags.filter(a => a[0] === "e" || a[0] === "a").map(v => NostrLink.fromTag(v)),
+ ])
.flat();
- const eTagsMissing = eTags.filter(a => !mainNotes.some(b => b.id === a.id));
- setTrackingEvent(s => appendDedupe(s, eTagsMissing));
- setAllEvents(s => appendDedupe(s, eTags));
+ setAllEvents(links);
- const aTags = mainNotes.map(a => a.tags.filter(b => b[0] === "a").map(b => b[1])).flat();
- setTrackingATags(s => appendDedupe(s, aTags));
+ const current = store.data.find(a => link.matchesEvent(a));
+ if (current) {
+ const t = EventExt.extractThread(current);
+ if (t) {
+ const rootOrReplyAsRoot = t?.root ?? t?.replyTo;
+ if (rootOrReplyAsRoot) {
+ setRoot(
+ NostrLink.fromTag([
+ rootOrReplyAsRoot.key,
+ rootOrReplyAsRoot.value ?? "",
+ rootOrReplyAsRoot.relay ?? "",
+ ...(rootOrReplyAsRoot.marker ?? []),
+ ]),
+ );
+ }
+ }
+ }
}
- }, [store]);
+ }, [store.data?.length]);
- return store;
+ const reactions = useReactions(`thread:${link.id.slice(0, 12)}:reactions`, [link, ...allEvents]);
+
+ return {
+ thread: store.data ?? [],
+ reactions: reactions.data ?? [],
+ };
}
diff --git a/packages/app/src/Feed/TimelineFeed.ts b/packages/app/src/Feed/TimelineFeed.ts
index 4022e1b1..f566168e 100644
--- a/packages/app/src/Feed/TimelineFeed.ts
+++ b/packages/app/src/Feed/TimelineFeed.ts
@@ -1,12 +1,11 @@
import { useCallback, useEffect, useMemo } from "react";
import { EventKind, NoteCollection, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
+import { unixNow } from "@snort/shared";
-import { unixNow, unwrap, tagFilterOfTextRepost } from "SnortUtils";
import useTimelineWindow from "Hooks/useTimelineWindow";
import useLogin from "Hooks/useLogin";
import { SearchRelays } from "Const";
-import { useReactions } from "./FeedReactions";
export interface TimelineFeedOptions {
method: "TIME_RANGE" | "LIMIT_UNTIL";
@@ -42,7 +41,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
.kinds(
subject.type === "profile_keyword"
? [EventKind.SetMetadata]
- : [EventKind.TextNote, EventKind.Repost, EventKind.Polls]
+ : [EventKind.TextNote, EventKind.Repost, EventKind.Polls],
);
if (subject.relay) {
@@ -139,36 +138,9 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
latest.clear();
}, [subject.relay]);
- function getParentEvents() {
- if (main.data) {
- const repostsByKind6 = main.data
- .filter(a => a.kind === EventKind.Repost && a.content === "")
- .map(a => a.tags.find(b => b[0] === "e"))
- .filter(a => a)
- .map(a => unwrap(a)[1]);
- const repostsByKind1 = main.data
- .filter(
- a => (a.kind === EventKind.Repost || a.kind === EventKind.TextNote) && a.tags.some(tagFilterOfTextRepost(a))
- )
- .map(a => a.tags.find(tagFilterOfTextRepost(a)))
- .filter(a => a)
- .map(a => unwrap(a)[1]);
- return [...repostsByKind6, ...repostsByKind1];
- }
- return [];
- }
-
- const trackingEvents = main.data?.map(a => a.id) ?? [];
- const related = useReactions(`timeline-related:${subject.type}:${subject.discriminator}`, trackingEvents, rb => {
- const trackingParentEvents = getParentEvents();
- if (trackingParentEvents.length > 0) {
- rb.withFilter().ids(trackingParentEvents);
- }
- });
-
return {
main: main.data,
- related: related.data,
+ related: [],
latest: latest.data,
loading: main.loading(),
loadMore: () => {
diff --git a/packages/app/src/Feed/ZapsFeed.ts b/packages/app/src/Feed/ZapsFeed.ts
index e82bc188..87e57881 100644
--- a/packages/app/src/Feed/ZapsFeed.ts
+++ b/packages/app/src/Feed/ZapsFeed.ts
@@ -1,5 +1,5 @@
import { useMemo } from "react";
-import { EventKind, RequestBuilder, parseZap, NostrLink, NostrPrefix, NoteCollection } from "@snort/system";
+import { EventKind, RequestBuilder, parseZap, NostrLink, NoteCollection } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { UserCache } from "Cache";
@@ -7,11 +7,7 @@ export default function useZapsFeed(link?: NostrLink) {
const sub = useMemo(() => {
if (!link) return null;
const b = new RequestBuilder(`zaps:${link.encode()}`);
- if (link.type === NostrPrefix.PublicKey) {
- b.withFilter().tag("p", [link.id]).kinds([EventKind.ZapReceipt]);
- } else if (link.type === NostrPrefix.Event || link.type === NostrPrefix.Note) {
- b.withFilter().tag("e", [link.id]).kinds([EventKind.ZapReceipt]);
- }
+ b.withFilter().kinds([EventKind.ZapReceipt]).replyToLink([link]);
return b;
}, [link]);
diff --git a/packages/app/src/Hooks/useEventPublisher.tsx b/packages/app/src/Hooks/useEventPublisher.tsx
new file mode 100644
index 00000000..b686b0c8
--- /dev/null
+++ b/packages/app/src/Hooks/useEventPublisher.tsx
@@ -0,0 +1,16 @@
+import useLogin from "Hooks/useLogin";
+import { LoginStore, createPublisher, sessionNeedsPin } from "Login";
+
+export default function useEventPublisher() {
+ const login = useLogin();
+
+ let existing = LoginStore.getPublisher(login.id);
+
+ if (login.publicKey && !existing && !sessionNeedsPin(login)) {
+ existing = createPublisher(login);
+ if (existing) {
+ LoginStore.setPublisher(login.id, existing);
+ }
+ }
+ return existing;
+}
diff --git a/packages/app/src/Hooks/useImgProxy.ts b/packages/app/src/Hooks/useImgProxy.ts
index 090fa4f4..2f1a04cd 100644
--- a/packages/app/src/Hooks/useImgProxy.ts
+++ b/packages/app/src/Hooks/useImgProxy.ts
@@ -21,7 +21,7 @@ export default function useImgProxy() {
const result = hmacSha256(
utils.hexToBytes(unwrap(settings).key),
utils.hexToBytes(unwrap(settings).salt),
- te.encode(u)
+ te.encode(u),
);
return urlSafe(base64.encode(result));
}
diff --git a/packages/app/src/Hooks/useInteractionCache.tsx b/packages/app/src/Hooks/useInteractionCache.tsx
index ab73a99c..64cc8122 100644
--- a/packages/app/src/Hooks/useInteractionCache.tsx
+++ b/packages/app/src/Hooks/useInteractionCache.tsx
@@ -15,7 +15,7 @@ export function useInteractionCache(pubkey?: HexKey, event?: u256) {
const data =
useSyncExternalStore(
c => InteractionCache.hook(c, id),
- () => InteractionCache.snapshot().find(a => a.id === id)
+ () => InteractionCache.snapshot().find(a => a.id === id),
) || EmptyInteraction;
return {
data: data,
diff --git a/packages/app/src/Hooks/useLogin.tsx b/packages/app/src/Hooks/useLogin.tsx
index b4ffedff..2d28da1a 100644
--- a/packages/app/src/Hooks/useLogin.tsx
+++ b/packages/app/src/Hooks/useLogin.tsx
@@ -1,9 +1,19 @@
-import { LoginStore } from "Login";
+import { LoginSession, LoginStore } from "Login";
import { useSyncExternalStore } from "react";
+import { useSyncExternalStoreWithSelector } from "use-sync-external-store/with-selector";
-export default function useLogin() {
- return useSyncExternalStore(
- s => LoginStore.hook(s),
- () => LoginStore.snapshot()
- );
+export default function useLogin(selector?: (v: LoginSession) => T) {
+ if (selector) {
+ return useSyncExternalStoreWithSelector(
+ s => LoginStore.hook(s),
+ () => LoginStore.snapshot(),
+ undefined,
+ selector,
+ );
+ } else {
+ return useSyncExternalStore(
+ s => LoginStore.hook(s),
+ () => LoginStore.snapshot() as T,
+ );
+ }
}
diff --git a/packages/app/src/Hooks/useLoginHandler.tsx b/packages/app/src/Hooks/useLoginHandler.tsx
index a1036082..1127ffe7 100644
--- a/packages/app/src/Hooks/useLoginHandler.tsx
+++ b/packages/app/src/Hooks/useLoginHandler.tsx
@@ -1,17 +1,20 @@
import { useIntl } from "react-intl";
+import { Nip46Signer, PinEncrypted } from "@snort/system";
import { EmailRegex, MnemonicRegex } from "Const";
import { LoginSessionType, LoginStore } from "Login";
import { generateBip39Entropy, entropyToPrivateKey } from "nip6";
import { getNip05PubKey } from "Pages/LoginPage";
import { bech32ToHex } from "SnortUtils";
-import { Nip46Signer } from "@snort/system";
+import { unwrap } from "@snort/shared";
+
+export class PinRequiredError extends Error {}
export default function useLoginHandler() {
const { formatMessage } = useIntl();
const hasSubtleCrypto = window.crypto.subtle !== undefined;
- async function doLogin(key: string) {
+ async function doLogin(key: string, pin?: string) {
const insecureMsg = formatMessage({
defaultMessage:
"Can't login with private key on an insecure connection, please use a Nostr key manager extension instead",
@@ -23,7 +26,9 @@ export default function useLoginHandler() {
}
const hexKey = bech32ToHex(key);
if (hexKey.length === 64) {
- LoginStore.loginWithPrivateKey(hexKey);
+ if (!pin) throw new PinRequiredError();
+ LoginStore.loginWithPrivateKey(await PinEncrypted.create(hexKey, pin));
+ return;
} else {
throw new Error("INVALID PRIVATE KEY");
}
@@ -31,14 +36,18 @@ export default function useLoginHandler() {
if (!hasSubtleCrypto) {
throw new Error(insecureMsg);
}
+ if (!pin) throw new PinRequiredError();
const ent = generateBip39Entropy(key);
const keyHex = entropyToPrivateKey(ent);
- LoginStore.loginWithPrivateKey(keyHex);
+ LoginStore.loginWithPrivateKey(await PinEncrypted.create(keyHex, pin));
+ return;
} else if (key.length === 64) {
if (!hasSubtleCrypto) {
throw new Error(insecureMsg);
}
- LoginStore.loginWithPrivateKey(key);
+ if (!pin) throw new PinRequiredError();
+ LoginStore.loginWithPrivateKey(await PinEncrypted.create(key, pin));
+ return;
}
// public key logins
@@ -49,11 +58,18 @@ export default function useLoginHandler() {
const hexKey = await getNip05PubKey(key);
LoginStore.loginWithPubkey(hexKey, LoginSessionType.PublicKey);
} else if (key.startsWith("bunker://")) {
+ if (!pin) throw new PinRequiredError();
const nip46 = new Nip46Signer(key);
await nip46.init();
const loginPubkey = await nip46.getPubKey();
- LoginStore.loginWithPubkey(loginPubkey, LoginSessionType.Nip46, undefined, nip46.relays, nip46.privateKey);
+ LoginStore.loginWithPubkey(
+ loginPubkey,
+ LoginSessionType.Nip46,
+ undefined,
+ nip46.relays,
+ await PinEncrypted.create(unwrap(nip46.privateKey), pin),
+ );
nip46.close();
} else {
throw new Error("INVALID PRIVATE KEY");
diff --git a/packages/app/src/Hooks/useLoginRelays.tsx b/packages/app/src/Hooks/useLoginRelays.tsx
new file mode 100644
index 00000000..b09a2bb9
--- /dev/null
+++ b/packages/app/src/Hooks/useLoginRelays.tsx
@@ -0,0 +1,22 @@
+import { System } from "index";
+import { useEffect } from "react";
+import useLogin from "./useLogin";
+
+export function useLoginRelays() {
+ const { relays } = useLogin();
+
+ useEffect(() => {
+ if (relays) {
+ (async () => {
+ for (const [k, v] of Object.entries(relays.item)) {
+ await System.ConnectToRelay(k, v);
+ }
+ for (const v of System.Sockets) {
+ if (!relays.item[v.address] && !v.ephemeral) {
+ System.DisconnectRelay(v.address);
+ }
+ }
+ })();
+ }
+ }, [relays]);
+}
diff --git a/packages/app/src/Hooks/useModeration.tsx b/packages/app/src/Hooks/useModeration.tsx
index 0e19d86d..36b767ed 100644
--- a/packages/app/src/Hooks/useModeration.tsx
+++ b/packages/app/src/Hooks/useModeration.tsx
@@ -1,5 +1,5 @@
-import { HexKey } from "@snort/system";
-import useEventPublisher from "Feed/EventPublisher";
+import { HexKey, TaggedNostrEvent } from "@snort/system";
+import useEventPublisher from "Hooks/useEventPublisher";
import useLogin from "Hooks/useLogin";
import { setBlocked, setMuted } from "Login";
import { appendDedupe } from "SnortUtils";
@@ -7,7 +7,7 @@ import { System } from "index";
export default function useModeration() {
const login = useLogin();
- const { muted, blocked } = login;
+ const { muted, blocked, appData } = login;
const publisher = useEventPublisher();
async function setMutedList(pub: HexKey[], priv: HexKey[]) {
@@ -57,6 +57,14 @@ export default function useModeration() {
setMuted(login, newMuted, ts);
}
+ function isMutedWord(word: string) {
+ return appData.item.mutedWords.includes(word.toLowerCase());
+ }
+
+ function isEventMuted(ev: TaggedNostrEvent) {
+ return isMuted(ev.pubkey) || appData.item.mutedWords.some(w => ev.content.toLowerCase().includes(w));
+ }
+
return {
muted: muted.item,
mute,
@@ -67,5 +75,7 @@ export default function useModeration() {
block,
unblock,
isBlocked,
+ isMutedWord,
+ isEventMuted,
};
}
diff --git a/packages/app/src/Hooks/useRefreshFeedcache.tsx b/packages/app/src/Hooks/useRefreshFeedcache.tsx
new file mode 100644
index 00000000..00fb2098
--- /dev/null
+++ b/packages/app/src/Hooks/useRefreshFeedcache.tsx
@@ -0,0 +1,53 @@
+import { SnortContext } from "@snort/system-react";
+import { useContext, useEffect, useMemo } from "react";
+import { NoopStore, RequestBuilder, TaggedNostrEvent } from "@snort/system";
+
+import { RefreshFeedCache } from "Cache/RefreshFeedCache";
+import useLogin from "./useLogin";
+import useEventPublisher from "./useEventPublisher";
+
+export function useRefreshFeedCache(c: RefreshFeedCache, leaveOpen = false) {
+ const system = useContext(SnortContext);
+ const login = useLogin();
+ const publisher = useEventPublisher();
+
+ const sub = useMemo(() => {
+ if (login.publicKey) {
+ const rb = new RequestBuilder(`using-${c.name}`);
+ rb.withOptions({
+ leaveOpen,
+ });
+ c.buildSub(login, rb);
+ return rb;
+ }
+ return undefined;
+ }, [login]);
+
+ useEffect(() => {
+ if (sub) {
+ const q = system.Query(NoopStore, sub);
+ let t: ReturnType | undefined;
+ let tBuf: Array = [];
+ const releaseOnEvent = q.feed.onEvent(evs => {
+ if (!t) {
+ tBuf = [...evs];
+ t = setTimeout(() => {
+ t = undefined;
+ c.onEvent(tBuf, publisher);
+ }, 100);
+ } else {
+ tBuf.push(...evs);
+ }
+ });
+ q.uncancel();
+ return () => {
+ q.cancel();
+ q.sendClose();
+ releaseOnEvent();
+ };
+ }
+ return () => {
+ // noop
+ };
+ }, [sub]);
+}
diff --git a/packages/app/src/Hooks/useTextTransformCache.tsx b/packages/app/src/Hooks/useTextTransformCache.tsx
new file mode 100644
index 00000000..07442043
--- /dev/null
+++ b/packages/app/src/Hooks/useTextTransformCache.tsx
@@ -0,0 +1,15 @@
+import { ParsedFragment, transformText } from "@snort/system";
+
+const TextCache = new Map>();
+
+export function transformTextCached(id: string, content: string, tags: Array>) {
+ const cached = TextCache.get(id);
+ if (cached) return cached;
+ const newCache = transformText(content, tags);
+ TextCache.set(id, newCache);
+ return newCache;
+}
+
+export function useTextTransformer(id: string, content: string, tags: Array>) {
+ return transformTextCached(id, content, tags);
+}
diff --git a/packages/app/src/Hooks/useTheme.tsx b/packages/app/src/Hooks/useTheme.tsx
new file mode 100644
index 00000000..15386624
--- /dev/null
+++ b/packages/app/src/Hooks/useTheme.tsx
@@ -0,0 +1,31 @@
+import { useEffect } from "react";
+import useLogin from "./useLogin";
+
+export function useTheme() {
+ const { preferences } = useLogin();
+
+ function setTheme(theme: "light" | "dark") {
+ const elm = document.documentElement;
+ if (theme === "light" && !elm.classList.contains("light")) {
+ elm.classList.add("light");
+ } else if (theme === "dark" && elm.classList.contains("light")) {
+ elm.classList.remove("light");
+ }
+ }
+
+ useEffect(() => {
+ const osTheme = window.matchMedia("(prefers-color-scheme: light)");
+ setTheme(
+ preferences.theme === "system" && osTheme.matches ? "light" : preferences.theme === "light" ? "light" : "dark",
+ );
+
+ osTheme.onchange = e => {
+ if (preferences.theme === "system") {
+ setTheme(e.matches ? "light" : "dark");
+ }
+ };
+ return () => {
+ osTheme.onchange = null;
+ };
+ }, [preferences.theme]);
+}
diff --git a/packages/app/src/Hooks/useThreadContext.tsx b/packages/app/src/Hooks/useThreadContext.tsx
new file mode 100644
index 00000000..afc24229
--- /dev/null
+++ b/packages/app/src/Hooks/useThreadContext.tsx
@@ -0,0 +1,86 @@
+/* eslint-disable no-debugger */
+import { unwrap } from "@snort/shared";
+import { EventExt, NostrLink, TaggedNostrEvent, u256 } from "@snort/system";
+import useThreadFeed from "Feed/ThreadFeed";
+import { ReactNode, createContext, useMemo, useState } from "react";
+import { useLocation } from "react-router-dom";
+
+export interface ThreadContext {
+ current: string;
+ root?: TaggedNostrEvent;
+ chains: Map>;
+ data: Array;
+ reactions: Array;
+ setCurrent: (i: string) => void;
+}
+
+export const ThreadContext = createContext({} as ThreadContext);
+
+/**
+ * Get the chain key as a reply event
+ */
+export function replyChainKey(ev: TaggedNostrEvent) {
+ const t = EventExt.extractThread(ev);
+ return t?.replyTo?.value ?? t?.root?.value;
+}
+
+/**
+ * Get the chain key of this event
+ */
+export function chainKey(ev: TaggedNostrEvent) {
+ const link = NostrLink.fromEvent(ev);
+ return unwrap(link.toEventTag())[1];
+}
+
+export function ThreadContextWrapper({ link, children }: { link: NostrLink; children?: ReactNode }) {
+ const location = useLocation();
+ const [currentId, setCurrentId] = useState(unwrap(link.toEventTag())[1]);
+ const feed = useThreadFeed(link);
+
+ const chains = useMemo(() => {
+ const chains = new Map>();
+ if (feed.thread) {
+ feed.thread
+ ?.sort((a, b) => b.created_at - a.created_at)
+ .forEach(v => {
+ const replyTo = replyChainKey(v);
+ if (replyTo) {
+ if (!chains.has(replyTo)) {
+ chains.set(replyTo, [v]);
+ } else {
+ unwrap(chains.get(replyTo)).push(v);
+ }
+ }
+ });
+ }
+ return chains;
+ }, [feed.thread]);
+
+ // Root is the parent of the current note or the current note if its a root note or the root of the thread
+ const root = useMemo(() => {
+ const currentNote =
+ feed.thread?.find(a => chainKey(a) === currentId) ??
+ (location.state && "sig" in location.state ? (location.state as TaggedNostrEvent) : undefined);
+ if (currentNote) {
+ const key = replyChainKey(currentNote);
+ if (key) {
+ return feed.thread?.find(a => chainKey(a) === key);
+ } else {
+ return currentNote;
+ }
+ }
+ }, [feed.thread.length, currentId, location]);
+
+ const ctxValue = useMemo(() => {
+ return {
+ current: currentId,
+ root,
+ chains,
+ reactions: feed.reactions,
+ data: feed.thread,
+ setCurrent: v => setCurrentId(v),
+ };
+ }, [root, chains]);
+
+ return {children} ;
+}
diff --git a/packages/app/src/IntlProvider.tsx b/packages/app/src/IntlProvider.tsx
index 01db1120..e41459ab 100644
--- a/packages/app/src/IntlProvider.tsx
+++ b/packages/app/src/IntlProvider.tsx
@@ -65,6 +65,12 @@ const getMessages = (locale: string) => {
case "sw-KE":
case "sw":
return (await import("translations/sw_KE.json")).default;
+ case "nl-NL":
+ case "nl":
+ return (await import("translations/nl_NL.json")).default;
+ case "fi-FI":
+ case "fi":
+ return (await import("translations/fi_FI.json")).default;
case DefaultLocale:
case "en":
return enMessages;
@@ -75,7 +81,7 @@ const getMessages = (locale: string) => {
};
export const IntlProvider = ({ children }: { children: ReactNode }) => {
- const { language } = useLogin().preferences;
+ const { language } = useLogin(s => ({ language: s.preferences.language }));
const locale = language ?? getLocale();
const [messages, setMessages] = useState>(enMessages);
diff --git a/packages/app/src/Login/Functions.ts b/packages/app/src/Login/Functions.ts
index 16a924c6..5413a77c 100644
--- a/packages/app/src/Login/Functions.ts
+++ b/packages/app/src/Login/Functions.ts
@@ -1,13 +1,17 @@
-import { HexKey, RelaySettings, EventPublisher } from "@snort/system";
+import { RelaySettings, EventPublisher, PinEncrypted, Nip46Signer, Nip7Signer, PrivateKeySigner } from "@snort/system";
+import { unixNowMs } from "@snort/shared";
import * as secp from "@noble/curves/secp256k1";
import * as utils from "@noble/curves/abstract/utils";
import { DefaultRelays, SnortPubKey } from "Const";
-import { LoginStore, UserPreferences, LoginSession } from "Login";
+import { LoginStore, UserPreferences, LoginSession, LoginSessionType, SnortAppData } from "Login";
import { generateBip39Entropy, entropyToPrivateKey } from "nip6";
-import { bech32ToHex, dedupeById, randomSample, sanitizeRelayUrl, unixNowMs, unwrap } from "SnortUtils";
+import { bech32ToHex, dedupeById, randomSample, sanitizeRelayUrl, unwrap } from "SnortUtils";
import { SubscriptionEvent } from "Subscription";
import { System } from "index";
+import { Chats, FollowsFeed, GiftsCache, Notifications } from "Cache";
+import { PinRequiredError } from "Hooks/useLoginHandler";
+import { Nip7OsSigner } from "./Nip7OsSigner";
export function setRelays(state: LoginSession, relays: Record, createdAt: number) {
if (state.relays.timestamp >= createdAt) {
@@ -39,10 +43,12 @@ export function updatePreferences(state: LoginSession, p: UserPreferences) {
LoginStore.updateSession(state);
}
-export function logout(k: HexKey) {
- LoginStore.removeSession(k);
- //TODO: delete giftwarps for:k
- //TODO: delete notifications for:k
+export function logout(id: string) {
+ LoginStore.removeSession(id);
+ GiftsCache.clear();
+ Notifications.clear();
+ FollowsFeed.clear();
+ Chats.clear();
}
export function markNotificationsRead(state: LoginSession) {
@@ -58,7 +64,7 @@ export function clearEntropy(state: LoginSession) {
/**
* Generate a new key and login with this generated key
*/
-export async function generateNewLogin() {
+export async function generateNewLogin(pin: string) {
const ent = generateBip39Entropy();
const entropy = utils.bytesToHex(ent);
const privateKey = entropyToPrivateKey(ent);
@@ -84,7 +90,8 @@ export async function generateNewLogin() {
const ev = await publisher.contactList([bech32ToHex(SnortPubKey), publicKey], newRelays);
System.BroadcastEvent(ev);
- LoginStore.loginWithPrivateKey(privateKey, entropy, newRelays);
+ const key = await PinEncrypted.create(privateKey, pin);
+ LoginStore.loginWithPrivateKey(key, entropy, newRelays);
}
export function generateRandomKey() {
@@ -150,6 +157,15 @@ export function setBookmarked(state: LoginSession, bookmarked: Array, ts
LoginStore.updateSession(state);
}
+export function setAppData(state: LoginSession, data: SnortAppData, ts: number) {
+ if (state.appData.timestamp >= ts) {
+ return;
+ }
+ state.appData.item = data;
+ state.appData.timestamp = ts;
+ LoginStore.updateSession(state);
+}
+
export function addSubscription(state: LoginSession, ...subs: SubscriptionEvent[]) {
const newSubs = dedupeById([...(state.subscriptions || []), ...subs]);
if (newSubs.length !== state.subscriptions.length) {
@@ -157,3 +173,32 @@ export function addSubscription(state: LoginSession, ...subs: SubscriptionEvent[
LoginStore.updateSession(state);
}
}
+
+export function sessionNeedsPin(l: LoginSession) {
+ return l.type === LoginSessionType.PrivateKey || l.type === LoginSessionType.Nip46;
+}
+
+export function createPublisher(l: LoginSession, pin?: PinEncrypted) {
+ switch (l.type) {
+ case LoginSessionType.PrivateKey: {
+ if (!pin) throw new PinRequiredError();
+ l.privateKeyData = pin;
+ return EventPublisher.privateKey(pin.value);
+ }
+ case LoginSessionType.Nip46: {
+ if (!pin) throw new PinRequiredError();
+ l.privateKeyData = pin;
+
+ const relayArgs = (l.remoteSignerRelays ?? []).map(a => `relay=${encodeURIComponent(a)}`);
+ const inner = new PrivateKeySigner(pin.value);
+ const nip46 = new Nip46Signer(`bunker://${unwrap(l.publicKey)}?${[...relayArgs].join("&")}`, inner);
+ return new EventPublisher(nip46, unwrap(l.publicKey));
+ }
+ case LoginSessionType.Nip7os: {
+ return new EventPublisher(new Nip7OsSigner(), unwrap(l.publicKey));
+ }
+ case LoginSessionType.Nip7: {
+ return new EventPublisher(new Nip7Signer(), unwrap(l.publicKey));
+ }
+ }
+}
diff --git a/packages/app/src/Login/LoginSession.ts b/packages/app/src/Login/LoginSession.ts
index ba7d42d4..12df2aa0 100644
--- a/packages/app/src/Login/LoginSession.ts
+++ b/packages/app/src/Login/LoginSession.ts
@@ -1,4 +1,4 @@
-import { HexKey, RelaySettings, u256, EventPublisher } from "@snort/system";
+import { HexKey, RelaySettings, u256, PinEncrypted, PinEncryptedPayload } from "@snort/system";
import { UserPreferences } from "Login";
import { SubscriptionEvent } from "Subscription";
@@ -18,7 +18,16 @@ export enum LoginSessionType {
Nip7os = "nip7_os",
}
+export interface SnortAppData {
+ mutedWords: Array;
+}
+
export interface LoginSession {
+ /**
+ * Unique ID to identify this session
+ */
+ id: string;
+
/**
* Type of login session
*/
@@ -26,9 +35,20 @@ export interface LoginSession {
/**
* Current user private key
+ * @deprecated Moving to pin encrypted storage
*/
privateKey?: HexKey;
+ /**
+ * If this session cannot sign events
+ */
+ readonly: boolean;
+
+ /**
+ * Encrypted private key
+ */
+ privateKeyData?: PinEncrypted | PinEncryptedPayload;
+
/**
* BIP39-generated, hex-encoded entropy
*/
@@ -100,7 +120,12 @@ export interface LoginSession {
remoteSignerRelays?: Array;
/**
- * Instance event publisher
+ * Snort application data
*/
- publisher?: EventPublisher;
+ appData: Newest;
+
+ /**
+ * A list of chats which we have joined (NIP-28/NIP-29)
+ */
+ extraChats: Array;
}
diff --git a/packages/app/src/Login/MultiAccountStore.ts b/packages/app/src/Login/MultiAccountStore.ts
index 3c35ec22..a067696d 100644
--- a/packages/app/src/Login/MultiAccountStore.ts
+++ b/packages/app/src/Login/MultiAccountStore.ts
@@ -1,18 +1,20 @@
import * as secp from "@noble/curves/secp256k1";
import * as utils from "@noble/curves/abstract/utils";
+import { v4 as uuid } from "uuid";
-import { HexKey, RelaySettings, EventPublisher, Nip46Signer, Nip7Signer, PrivateKeySigner } from "@snort/system";
+import { HexKey, RelaySettings, PinEncrypted, EventPublisher } from "@snort/system";
import { deepClone, sanitizeRelayUrl, unwrap, ExternalStore } from "@snort/shared";
import { DefaultRelays } from "Const";
-import { LoginSession, LoginSessionType } from "Login";
-import { DefaultPreferences, UserPreferences } from "./Preferences";
-import { Nip7OsSigner } from "./Nip7OsSigner";
+import { LoginSession, LoginSessionType, createPublisher } from "Login";
+import { DefaultPreferences } from "./Preferences";
const AccountStoreKey = "sessions";
const LoggedOut = {
+ id: "default",
type: "public_key",
preferences: DefaultPreferences,
+ readonly: true,
tags: {
item: [],
timestamp: 0,
@@ -44,26 +46,26 @@ const LoggedOut = {
latestNotification: 0,
readNotifications: 0,
subscriptions: [],
+ appData: {
+ item: {
+ mutedWords: [],
+ },
+ timestamp: 0,
+ },
+ extraChats: [],
} as LoginSession;
-const LegacyKeys = {
- PrivateKeyItem: "secret",
- PublicKeyItem: "pubkey",
- NotificationsReadItem: "notifications-read",
- UserPreferencesKey: "preferences",
- RelayListKey: "last-relays",
- FollowList: "last-follows",
-};
export class MultiAccountStore extends ExternalStore {
#activeAccount?: HexKey;
#accounts: Map;
+ #publishers = new Map();
constructor() {
super();
const existing = window.localStorage.getItem(AccountStoreKey);
if (existing) {
const logins = JSON.parse(existing);
- this.#accounts = new Map((logins as Array).map(a => [unwrap(a.publicKey), a]));
+ this.#accounts = new Map((logins as Array).map(a => [a.id, a]));
} else {
this.#accounts = new Map();
}
@@ -71,32 +73,55 @@ export class MultiAccountStore extends ExternalStore {
if (!this.#activeAccount) {
this.#activeAccount = this.#accounts.keys().next().value;
}
+ // reset readonly on load
for (const [, v] of this.#accounts) {
- v.publisher = this.#createPublisher(v);
+ if (v.type === LoginSessionType.PrivateKey && v.readonly) {
+ v.readonly = false;
+ }
+ v.appData ??= {
+ item: {
+ mutedWords: [],
+ },
+ timestamp: 0,
+ };
+ v.extraChats ??= [];
}
+ this.#loadIrisKeyIfExists();
}
getSessions() {
- return [...this.#accounts.keys()];
+ return [...this.#accounts.values()].map(v => ({
+ pubkey: unwrap(v.publicKey),
+ id: v.id,
+ }));
}
allSubscriptions() {
return [...this.#accounts.values()].map(a => a.subscriptions).flat();
}
- switchAccount(pk: string) {
- if (this.#accounts.has(pk)) {
- this.#activeAccount = pk;
+ switchAccount(id: string) {
+ if (this.#accounts.has(id)) {
+ this.#activeAccount = id;
this.#save();
}
}
+ getPublisher(id: string) {
+ return this.#publishers.get(id);
+ }
+
+ setPublisher(id: string, pub: EventPublisher) {
+ this.#publishers.set(id, pub);
+ this.notifyChange();
+ }
+
loginWithPubkey(
key: HexKey,
type: LoginSessionType,
relays?: Record,
remoteSignerRelays?: Array,
- privateKey?: string
+ privateKey?: PinEncrypted,
) {
if (this.#accounts.has(key)) {
throw new Error("Already logged in with this pubkey");
@@ -104,6 +129,8 @@ export class MultiAccountStore extends ExternalStore {
const initRelays = this.decideInitRelays(relays);
const newSession = {
...LoggedOut,
+ id: uuid(),
+ readonly: type === LoginSessionType.PublicKey,
type,
publicKey: key,
relays: {
@@ -112,12 +139,15 @@ export class MultiAccountStore extends ExternalStore {
},
preferences: deepClone(DefaultPreferences),
remoteSignerRelays,
- privateKey,
+ privateKeyData: privateKey,
} as LoginSession;
- newSession.publisher = this.#createPublisher(newSession);
- this.#accounts.set(key, newSession);
- this.#activeAccount = key;
+ const pub = createPublisher(newSession);
+ if (pub) {
+ this.setPublisher(newSession.id, pub);
+ }
+ this.#accounts.set(newSession.id, newSession);
+ this.#activeAccount = newSession.id;
this.#save();
return newSession;
}
@@ -129,16 +159,18 @@ export class MultiAccountStore extends ExternalStore {
return Object.fromEntries(DefaultRelays.entries());
}
- loginWithPrivateKey(key: HexKey, entropy?: string, relays?: Record) {
- const pubKey = utils.bytesToHex(secp.schnorr.getPublicKey(key));
+ loginWithPrivateKey(key: PinEncrypted, entropy?: string, relays?: Record) {
+ const pubKey = utils.bytesToHex(secp.schnorr.getPublicKey(key.value));
if (this.#accounts.has(pubKey)) {
throw new Error("Already logged in with this pubkey");
}
const initRelays = relays ?? Object.fromEntries(DefaultRelays.entries());
const newSession = {
...LoggedOut,
+ id: uuid(),
type: LoginSessionType.PrivateKey,
- privateKey: key,
+ readonly: false,
+ privateKeyData: key,
publicKey: pubKey,
generatedEntropy: entropy,
relays: {
@@ -149,30 +181,30 @@ export class MultiAccountStore extends ExternalStore {
} as LoginSession;
if ("nostr_os" in window && window.nostr_os) {
- window.nostr_os.saveKey(key);
+ window.nostr_os.saveKey(key.value);
newSession.type = LoginSessionType.Nip7os;
- newSession.privateKey = undefined;
+ newSession.privateKeyData = undefined;
}
- newSession.publisher = this.#createPublisher(newSession);
+ const pub = EventPublisher.privateKey(key.value);
+ this.setPublisher(newSession.id, pub);
- this.#accounts.set(pubKey, newSession);
- this.#activeAccount = pubKey;
+ this.#accounts.set(newSession.id, newSession);
+ this.#activeAccount = newSession.id;
this.#save();
return newSession;
}
updateSession(s: LoginSession) {
- const pk = unwrap(s.publicKey);
- if (this.#accounts.has(pk)) {
- this.#accounts.set(pk, s);
+ if (this.#accounts.has(s.id)) {
+ this.#accounts.set(s.id, s);
console.debug("SET SESSION", s);
this.#save();
}
}
- removeSession(k: string) {
- if (this.#accounts.delete(k)) {
- if (this.#activeAccount === k) {
+ removeSession(id: string) {
+ if (this.#accounts.delete(id)) {
+ if (this.#activeAccount === id) {
this.#activeAccount = undefined;
}
this.#save();
@@ -186,62 +218,24 @@ export class MultiAccountStore extends ExternalStore {
return { ...s };
}
- #createPublisher(l: LoginSession) {
- switch (l.type) {
- case LoginSessionType.PrivateKey: {
- return EventPublisher.privateKey(unwrap(l.privateKey));
- }
- case LoginSessionType.Nip46: {
- const relayArgs = (l.remoteSignerRelays ?? []).map(a => `relay=${encodeURIComponent(a)}`);
- const inner = new PrivateKeySigner(unwrap(l.privateKey));
- const nip46 = new Nip46Signer(`bunker://${unwrap(l.publicKey)}?${[...relayArgs].join("&")}`, inner);
- return new EventPublisher(nip46, unwrap(l.publicKey));
- }
- case LoginSessionType.Nip7os: {
- return new EventPublisher(new Nip7OsSigner(), unwrap(l.publicKey));
- }
- default: {
- if (l.publicKey) {
- return new EventPublisher(new Nip7Signer(), l.publicKey);
+ async #loadIrisKeyIfExists() {
+ try {
+ const irisKeyJSON = window.localStorage.getItem("iris.myKey");
+ if (irisKeyJSON) {
+ const irisKeyObj = JSON.parse(irisKeyJSON);
+ if (irisKeyObj.priv) {
+ const privateKey = await PinEncrypted.create(irisKeyObj.priv, "1234");
+ this.loginWithPrivateKey(privateKey);
+ window.localStorage.removeItem("iris.myKey");
}
}
+ } catch (e) {
+ console.error("Failed to load iris key", e);
}
}
#migrate() {
let didMigrate = false;
- const oldPreferences = window.localStorage.getItem(LegacyKeys.UserPreferencesKey);
- const pref: UserPreferences = oldPreferences ? JSON.parse(oldPreferences) : deepClone(DefaultPreferences);
- window.localStorage.removeItem(LegacyKeys.UserPreferencesKey);
-
- const privKey = window.localStorage.getItem(LegacyKeys.PrivateKeyItem);
- if (privKey) {
- const pubKey = utils.bytesToHex(secp.schnorr.getPublicKey(privKey));
- this.#accounts.set(pubKey, {
- ...LoggedOut,
- privateKey: privKey,
- publicKey: pubKey,
- preferences: pref,
- } as LoginSession);
- window.localStorage.removeItem(LegacyKeys.PrivateKeyItem);
- window.localStorage.removeItem(LegacyKeys.PublicKeyItem);
- didMigrate = true;
- }
-
- const pubKey = window.localStorage.getItem(LegacyKeys.PublicKeyItem);
- if (pubKey) {
- this.#accounts.set(pubKey, {
- ...LoggedOut,
- publicKey: pubKey,
- preferences: pref,
- } as LoginSession);
- window.localStorage.removeItem(LegacyKeys.PublicKeyItem);
- didMigrate = true;
- }
-
- window.localStorage.removeItem(LegacyKeys.RelayListKey);
- window.localStorage.removeItem(LegacyKeys.FollowList);
- window.localStorage.removeItem(LegacyKeys.NotificationsReadItem);
// replace default tab with notes
for (const [, v] of this.#accounts) {
@@ -259,17 +253,50 @@ export class MultiAccountStore extends ExternalStore {
}
}
+ // add ids
+ for (const [, v] of this.#accounts) {
+ if ((v.id?.length ?? 0) === 0) {
+ v.id = uuid();
+ didMigrate = true;
+ }
+ }
+
+ // mark readonly
+ for (const [, v] of this.#accounts) {
+ if (v.type === LoginSessionType.PublicKey && !v.readonly) {
+ v.readonly = true;
+ didMigrate = true;
+ }
+ // reset readonly on load
+ if (v.type === LoginSessionType.PrivateKey && v.readonly) {
+ v.readonly = false;
+ didMigrate = true;
+ }
+ }
+
if (didMigrate) {
- console.debug("Finished migration to MultiAccountStore");
+ console.debug("Finished migration in MultiAccountStore");
this.#save();
}
}
#save() {
if (!this.#activeAccount && this.#accounts.size > 0) {
- this.#activeAccount = [...this.#accounts.keys()][0];
+ this.#activeAccount = this.#accounts.keys().next().value;
}
- window.localStorage.setItem(AccountStoreKey, JSON.stringify([...this.#accounts.values()]));
+ const toSave = [];
+ for (const v of this.#accounts.values()) {
+ if (v.privateKeyData instanceof PinEncrypted) {
+ toSave.push({
+ ...v,
+ privateKeyData: v.privateKeyData.toPayload(),
+ });
+ } else {
+ toSave.push({ ...v });
+ }
+ }
+
+ window.localStorage.setItem(AccountStoreKey, JSON.stringify(toSave));
this.notifyChange();
}
}
diff --git a/packages/app/src/Login/Nip7OsSigner.ts b/packages/app/src/Login/Nip7OsSigner.ts
index 123eede1..e44f0cdb 100644
--- a/packages/app/src/Login/Nip7OsSigner.ts
+++ b/packages/app/src/Login/Nip7OsSigner.ts
@@ -13,6 +13,10 @@ export class Nip7OsSigner implements EventSigner {
}
}
+ get supports(): string[] {
+ return ["nip04"];
+ }
+
init(): Promise {
return Promise.resolve();
}
diff --git a/packages/app/src/Login/Preferences.ts b/packages/app/src/Login/Preferences.ts
index ea12eee5..f68a22ea 100644
--- a/packages/app/src/Login/Preferences.ts
+++ b/packages/app/src/Login/Preferences.ts
@@ -71,6 +71,21 @@ export interface UserPreferences {
* Proof-of-Work to apply to all events
*/
pow?: number;
+
+ /**
+ * Collect usage metrics
+ */
+ telemetry?: boolean;
+
+ /**
+ * Show badges on profiles
+ */
+ showBadges?: boolean;
+
+ /**
+ * Show user status messages on profiles
+ */
+ showStatus?: boolean;
}
export const DefaultPreferences = {
@@ -87,4 +102,7 @@ export const DefaultPreferences = {
defaultRootTab: "notes",
defaultZapAmount: 50,
autoZap: false,
+ telemetry: true,
+ showBadges: false,
+ showStatus: true,
} as UserPreferences;
diff --git a/packages/app/src/Login/index.ts b/packages/app/src/Login/index.ts
index ebe134f1..bf0e4f8c 100644
--- a/packages/app/src/Login/index.ts
+++ b/packages/app/src/Login/index.ts
@@ -1,4 +1,5 @@
import { MultiAccountStore } from "./MultiAccountStore";
+
export const LoginStore = new MultiAccountStore();
export interface Nip7os {
diff --git a/packages/app/src/Nip05/ServiceProvider.ts b/packages/app/src/Nip05/ServiceProvider.ts
index 610d4ed5..eda71371 100644
--- a/packages/app/src/Nip05/ServiceProvider.ts
+++ b/packages/app/src/Nip05/ServiceProvider.ts
@@ -97,7 +97,7 @@ export class ServiceProvider {
path: string,
method?: "GET" | string,
body?: unknown,
- headers?: { [key: string]: string }
+ headers?: { [key: string]: string },
): Promise {
try {
const rsp = await fetch(`${this.url}${path}`, {
diff --git a/packages/app/src/Nip05/SnortServiceProvider.ts b/packages/app/src/Nip05/SnortServiceProvider.ts
index d6efa314..f4de626a 100644
--- a/packages/app/src/Nip05/SnortServiceProvider.ts
+++ b/packages/app/src/Nip05/SnortServiceProvider.ts
@@ -54,7 +54,7 @@ export default class SnortServiceProvider extends ServiceProvider {
path: string,
method?: "GET" | string,
body?: unknown,
- headers?: { [key: string]: string }
+ headers?: { [key: string]: string },
): Promise {
const auth = await this.#publisher.generic(eb => {
eb.kind(EventKind.HttpAuthentication);
diff --git a/packages/app/src/Notifications.ts b/packages/app/src/Notifications.ts
index 5f308ff4..4da9df5a 100644
--- a/packages/app/src/Notifications.ts
+++ b/packages/app/src/Notifications.ts
@@ -1,5 +1,5 @@
import { TaggedNostrEvent, EventKind, MetadataCache } from "@snort/system";
-import { getDisplayName } from "Element/ProfileImage";
+import { getDisplayName } from "Element/User/ProfileImage";
import { MentionRegex } from "Const";
import { defaultAvatar, tagFilterOfTextRepost, unwrap } from "SnortUtils";
import { UserCache } from "Cache";
diff --git a/packages/app/src/Pages/Deck.css b/packages/app/src/Pages/Deck.css
new file mode 100644
index 00000000..81147ddf
--- /dev/null
+++ b/packages/app/src/Pages/Deck.css
@@ -0,0 +1,101 @@
+.deck-layout {
+ display: flex;
+ height: 100vh;
+ overflow-y: hidden;
+}
+
+.deck-layout .deck-cols {
+ display: flex;
+ height: 100vh;
+ overflow-y: hidden;
+ overflow-x: auto;
+}
+
+.deck-layout .deck-cols .deck-col-header {
+ padding: 8px 16px;
+ border: 1px solid var(--border-color);
+ border-collapse: collapse;
+ font-size: 20px;
+ font-weight: 700;
+ min-height: 40px;
+ max-height: 40px;
+}
+
+.deck-layout .deck-cols .deck-col-header:not(:last-of-type) {
+ border-right: 0;
+}
+
+.deck-layout .deck-cols > div {
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+ width: 550px;
+ min-width: 550px;
+}
+
+.deck-layout .deck-cols > div > div:not(:first-of-type) {
+ overflow-y: scroll;
+}
+
+.image-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 4px;
+}
+
+.image-grid > .media-note {
+ border: 1px solid var(--border-color);
+ background-image: var(--img);
+ background-position: center;
+ background-size: cover;
+ aspect-ratio: 1;
+ cursor: pointer;
+}
+
+.modal.thread-overlay > .modal-body {
+ background-color: unset;
+ padding: 0;
+ width: 100vw;
+ height: 100vh;
+ --border-color: #3a3a3a;
+}
+
+.modal.thread-overlay > .modal-body > div {
+ display: flex;
+ flex-direction: row;
+ border-radius: unset;
+ justify-content: center;
+ gap: 16px;
+}
+
+.modal.thread-overlay > .modal-body > div > div:last-of-type {
+ width: 550px;
+ min-width: 550px;
+ height: 100vh;
+ overflow-y: auto;
+ background-color: var(--gray-superdark);
+}
+
+.thread-overlay .spotlight {
+ flex-grow: 1;
+ margin: auto;
+ text-align: center;
+}
+
+.thread-overlay .spotlight .details {
+ right: calc(28px + 550px + 16px);
+}
+
+.thread-overlay .spotlight .right {
+ right: calc(24px + 550px + 16px);
+}
+
+.thread-overlay .spotlight img,
+.thread-overlay .spotlight video {
+ max-width: calc(100vw - 550px - 16px);
+}
+
+.thread-overlay .main-content {
+ border: 0;
+ border-bottom: 1px solid var(--border-color);
+}
diff --git a/packages/app/src/Pages/DeckLayout.tsx b/packages/app/src/Pages/DeckLayout.tsx
new file mode 100644
index 00000000..8d8f35d9
--- /dev/null
+++ b/packages/app/src/Pages/DeckLayout.tsx
@@ -0,0 +1,182 @@
+import "./Deck.css";
+import { CSSProperties, createContext, useContext, useEffect, useState } from "react";
+import { Outlet, useNavigate } from "react-router-dom";
+import FormattedMessage from "Element/FormattedMessage";
+import { NostrLink } from "@snort/system";
+
+import { DeckNav } from "Element/Deck/Nav";
+import useLoginFeed from "Feed/LoginFeed";
+import { useLoginRelays } from "Hooks/useLoginRelays";
+import { useTheme } from "Hooks/useTheme";
+import Articles from "Element/Deck/Articles";
+import TimelineFollows from "Element/Feed/TimelineFollows";
+import { transformTextCached } from "Hooks/useTextTransformCache";
+import Icon from "Icons/Icon";
+import NotificationsPage from "./Notifications";
+import useImgProxy from "Hooks/useImgProxy";
+import Modal from "Element/Modal";
+import { Thread } from "Element/Event/Thread";
+import { RootTabs } from "Element/RootTabs";
+import { SpotlightMedia } from "Element/Deck/SpotlightMedia";
+import { ThreadContext, ThreadContextWrapper } from "Hooks/useThreadContext";
+import Toaster from "Toaster";
+import useLogin from "Hooks/useLogin";
+
+type Cols = "notes" | "articles" | "media" | "streams" | "notifications";
+
+interface DeckScope {
+ thread?: NostrLink;
+ setThread: (e?: NostrLink) => void;
+}
+
+export const DeckContext = createContext(undefined);
+
+export function SnortDeckLayout() {
+ const login = useLogin();
+ const navigate = useNavigate();
+ const [deckScope, setDeckScope] = useState({
+ setThread: (e?: NostrLink) => setDeckScope(s => ({ ...s, thread: e })),
+ });
+
+ useLoginFeed();
+ useTheme();
+ useLoginRelays();
+
+ useEffect(() => {
+ if (!login.publicKey) {
+ navigate("/");
+ }
+ }, [login]);
+
+ if (!login.publicKey) return null;
+ const cols = ["notes", "media", "notifications", "articles"] as Array;
+ return (
+
+
+
+
+ {cols.map(c => {
+ switch (c) {
+ case "notes":
+ return
;
+ case "media":
+ return
;
+ case "articles":
+ return
;
+ case "notifications":
+ return
;
+ }
+ })}
+
+ {deckScope.thread && (
+ <>
+ deckScope.setThread(undefined)} className="thread-overlay">
+
+ deckScope.setThread(undefined)} />
+
+ deckScope.setThread(undefined)} disableSpotlight={true} />
+
+
+
+ >
+ )}
+
+
+
+ );
+}
+
+function SpotlightFromThread({ onClose }: { onClose: () => void }) {
+ const thread = useContext(ThreadContext);
+
+ const parsed = thread.root ? transformTextCached(thread.root.id, thread.root.content, thread.root.tags) : [];
+ const images = parsed.filter(a => a.type === "media" && a.mimeType?.startsWith("image/"));
+ if (images.length === 0) return;
+ return a.content)} idx={0} onClose={onClose} />;
+}
+
+function NotesCol() {
+ return (
+
+ );
+}
+
+function ArticlesCol() {
+ return (
+
+ );
+}
+
+function MediaCol({ setThread }: { setThread: (e: NostrLink) => void }) {
+ const { proxy } = useImgProxy();
+ return (
+
+
+
+
+
+
+
{
+ const parsed = transformTextCached(e.id, e.content, e.tags);
+ const images = parsed.filter(a => a.type === "media" && a.mimeType?.startsWith("image/"));
+ return images.length > 0;
+ }}
+ noteRenderer={e => {
+ const parsed = transformTextCached(e.id, e.content, e.tags);
+ const images = parsed.filter(a => a.type === "media" && a.mimeType?.startsWith("image/"));
+
+ return (
+ setThread(NostrLink.fromEvent(e))}>
+ );
+ }}
+ />
+
+
+ );
+}
+
+function NotificationsCol({ setThread }: { setThread: (e: NostrLink) => void }) {
+ return (
+
+ );
+}
diff --git a/packages/app/src/Pages/Discover.tsx b/packages/app/src/Pages/Discover.tsx
index 98937236..0c2ce193 100644
--- a/packages/app/src/Pages/Discover.tsx
+++ b/packages/app/src/Pages/Discover.tsx
@@ -1,6 +1,6 @@
import SuggestedProfiles from "Element/SuggestedProfiles";
import { Tab, TabElement } from "Element/Tabs";
-import TrendingNotes from "Element/TrendingPosts";
+import TrendingNotes from "Element/Feed/TrendingPosts";
import TrendingUsers from "Element/TrendingUsers";
import { useState } from "react";
import { useIntl } from "react-intl";
@@ -29,7 +29,7 @@ export default function Discover() {
return (
<>
-
+
{[Tabs.Follows, Tabs.Posts, Tabs.Profiles].map(a => (
))}
diff --git a/packages/app/src/Pages/DonatePage.tsx b/packages/app/src/Pages/DonatePage.tsx
index dc5b16af..200f303c 100644
--- a/packages/app/src/Pages/DonatePage.tsx
+++ b/packages/app/src/Pages/DonatePage.tsx
@@ -1,15 +1,20 @@
import { useEffect, useState } from "react";
-import { FormattedMessage } from "react-intl";
+import FormattedMessage from "Element/FormattedMessage";
import { HexKey } from "@snort/system";
import { ApiHost, KieranPubKey, SnortPubKey } from "Const";
-import ProfilePreview from "Element/ProfilePreview";
-import ZapButton from "Element/ZapButton";
+import ProfilePreview from "Element/User/ProfilePreview";
+import ZapButton from "Element/Event/ZapButton";
import { bech32ToHex } from "SnortUtils";
import SnortApi, { RevenueSplit, RevenueToday } from "SnortApi";
+import Modal from "Element/Modal";
+import AsyncButton from "Element/AsyncButton";
+import QrCode from "Element/QrCode";
+import Copy from "Element/Copy";
const Developers = [
bech32ToHex(KieranPubKey), // kieran
+ bech32ToHex("npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk"), // Martti
bech32ToHex("npub107jk7htfv243u0x5ynn43scq9wrxtaasmrwwa8lfu2ydwag6cx2quqncxg"), // verbiricha
bech32ToHex("npub1r0rs5q2gk0e3dk3nlc7gnu378ec6cnlenqp8a3cjhyzu6f8k5sgs4sq9ac"), // Karnage
];
@@ -48,6 +53,8 @@ const Translators = [
bech32ToHex("npub1z9n5ktfjrlpyywds9t7ljekr9cm9jjnzs27h702te5fy8p2c4dgs5zvycf"), // Felix - DE
bech32ToHex("npub1wh30wunfpkezx5s7edqu9g0s0raeetf5dgthzm0zw7sk8wqygmjqqfljgh"), // Fernando Porazzi - pt-BR
+
+ bech32ToHex("npub1ust7u0v3qffejwhqee45r49zgcyewrcn99vdwkednd356c9resyqtnn3mj"), // Petri - FI
];
export const DonateLNURL = "donate@snort.social";
@@ -55,8 +62,14 @@ export const DonateLNURL = "donate@snort.social";
const DonatePage = () => {
const [splits, setSplits] = useState
([]);
const [today, setSumToday] = useState();
+ const [onChain, setOnChain] = useState("");
const api = new SnortApi(ApiHost);
+ async function getOnChainAddress() {
+ const { address } = await api.onChainDonation();
+ setOnChain(address);
+ }
+
async function loadData() {
const rsp = await api.revenueSplits();
setSplits(rsp);
@@ -102,24 +115,43 @@ const DonatePage = () => {
-
-
-
-
+
+
+
+
+
+
+
+
+ {today && (
+
+
+
+ )}
+
+
- {today && (
-
-
-
- )}
+ {onChain && (
+
setOnChain("")} id="donate-on-chain">
+
+
+
+
+
+
+
+
+ )}
diff --git a/packages/app/src/Pages/ErrorPage.tsx b/packages/app/src/Pages/ErrorPage.tsx
index 687c068d..2ad1f794 100644
--- a/packages/app/src/Pages/ErrorPage.tsx
+++ b/packages/app/src/Pages/ErrorPage.tsx
@@ -1,6 +1,6 @@
import { db } from "Db";
import AsyncButton from "Element/AsyncButton";
-import { FormattedMessage } from "react-intl";
+import FormattedMessage from "Element/FormattedMessage";
import { useRouteError } from "react-router-dom";
const ErrorPage = () => {
@@ -25,7 +25,7 @@ const ErrorPage = () => {
{JSON.stringify(
error instanceof Error ? { name: error.name, message: error.message, stack: error.stack } : error,
undefined,
- " "
+ " ",
)}
}
diff --git a/packages/app/src/Pages/HashTagsPage.tsx b/packages/app/src/Pages/HashTagsPage.tsx
index a2e7a8c8..132b9283 100644
--- a/packages/app/src/Pages/HashTagsPage.tsx
+++ b/packages/app/src/Pages/HashTagsPage.tsx
@@ -1,9 +1,9 @@
import { useMemo } from "react";
import { useParams } from "react-router-dom";
-import { FormattedMessage } from "react-intl";
+import FormattedMessage from "Element/FormattedMessage";
-import Timeline from "Element/Timeline";
-import useEventPublisher from "Feed/EventPublisher";
+import Timeline from "Element/Feed/Timeline";
+import useEventPublisher from "Hooks/useEventPublisher";
import useLogin from "Hooks/useLogin";
import { setTags } from "Login";
import { System } from "index";
diff --git a/packages/app/src/Pages/HelpPage.tsx b/packages/app/src/Pages/HelpPage.tsx
index 793fa898..fff8cfd8 100644
--- a/packages/app/src/Pages/HelpPage.tsx
+++ b/packages/app/src/Pages/HelpPage.tsx
@@ -1,6 +1,6 @@
import { Link } from "react-router-dom";
import { KieranPubKey } from "Const";
-import { FormattedMessage } from "react-intl";
+import FormattedMessage from "Element/FormattedMessage";
import { TLVEntryType, encodeTLVEntries, NostrPrefix } from "@snort/system";
import { bech32ToHex } from "SnortUtils";
diff --git a/packages/app/src/Pages/Layout.css b/packages/app/src/Pages/Layout.css
index a4243922..9e7600d1 100644
--- a/packages/app/src/Pages/Layout.css
+++ b/packages/app/src/Pages/Layout.css
@@ -20,6 +20,7 @@ header {
justify-content: space-between;
align-items: center;
align-self: stretch;
+ gap: 24px;
}
.header-actions .avatar {
@@ -33,6 +34,7 @@ header {
flex-direction: row;
align-items: center;
gap: 24px;
+ width: 100%;
}
.header-actions .btn {
@@ -65,6 +67,11 @@ header {
border-radius: 1000px;
}
+.light .search {
+ background: #fff;
+ border: 1px solid var(--border-color);
+}
+
.search input {
border: none !important;
border-radius: 0 !important;
diff --git a/packages/app/src/Pages/Layout.tsx b/packages/app/src/Pages/Layout.tsx
index 1df8c604..a02150fd 100644
--- a/packages/app/src/Pages/Layout.tsx
+++ b/packages/app/src/Pages/Layout.tsx
@@ -1,51 +1,35 @@
import "./Layout.css";
import { useEffect, useMemo, useState } from "react";
-import { useDispatch, useSelector } from "react-redux";
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
import { FormattedMessage, useIntl } from "react-intl";
import { useUserProfile } from "@snort/system-react";
+import { NostrLink, NostrPrefix, tryParseNostrLink } from "@snort/system";
import messages from "./messages";
import Icon from "Icons/Icon";
-import { RootState } from "State/Store";
-import { setShow, reset } from "State/NoteCreator";
-import { System } from "index";
import useLoginFeed from "Feed/LoginFeed";
-import { NoteCreator } from "Element/NoteCreator";
+import { NoteCreator } from "Element/Event/NoteCreator";
import { mapPlanName } from "./subscribe";
import useLogin from "Hooks/useLogin";
-import Avatar from "Element/Avatar";
+import Avatar from "Element/User/Avatar";
import { profileLink } from "SnortUtils";
import { getCurrentSubscription } from "Subscription";
import Toaster from "Toaster";
import Spinner from "Icons/Spinner";
-import { NostrPrefix, createNostrLink, tryParseNostrLink } from "@snort/system";
import { fetchNip05Pubkey } from "Nip05/Verifier";
+import { useTheme } from "Hooks/useTheme";
+import { useLoginRelays } from "Hooks/useLoginRelays";
+import { useNoteCreator } from "State/NoteCreator";
+import { LoginUnlock } from "Element/PinPrompt";
export default function Layout() {
const location = useLocation();
- const replyTo = useSelector((s: RootState) => s.noteCreator.replyTo);
- const isNoteCreatorShowing = useSelector((s: RootState) => s.noteCreator.show);
- const isReplyNoteCreatorShowing = replyTo && isNoteCreatorShowing;
- const dispatch = useDispatch();
- const navigate = useNavigate();
- const { publicKey, relays, preferences, subscriptions } = useLogin();
- const currentSubscription = getCurrentSubscription(subscriptions);
const [pageClass, setPageClass] = useState("page");
+
useLoginFeed();
-
- const handleNoteCreatorButtonClick = () => {
- if (replyTo) {
- dispatch(reset());
- }
- dispatch(setShow(true));
- };
-
- const shouldHideNoteCreator = useMemo(() => {
- const hideOn = ["/settings", "/messages", "/new", "/login", "/donate", "/e", "/subscribe"];
- return isReplyNoteCreatorShowing || hideOn.some(a => location.pathname.startsWith(a));
- }, [location, isReplyNoteCreatorShowing]);
+ useTheme();
+ useLoginRelays();
const shouldHideHeader = useMemo(() => {
const hideOn = ["/login", "/new"];
@@ -62,89 +46,63 @@ export default function Layout() {
}
}, [location]);
- useEffect(() => {
- if (relays) {
- (async () => {
- for (const [k, v] of Object.entries(relays.item)) {
- await System.ConnectToRelay(k, v);
- }
- for (const v of System.Sockets) {
- if (!relays.item[v.address] && !v.ephemeral) {
- System.DisconnectRelay(v.address);
- }
- }
- })();
- }
- }, [relays]);
-
- function setTheme(theme: "light" | "dark") {
- const elm = document.documentElement;
- if (theme === "light" && !elm.classList.contains("light")) {
- elm.classList.add("light");
- } else if (theme === "dark" && elm.classList.contains("light")) {
- elm.classList.remove("light");
- }
- }
-
- useEffect(() => {
- const osTheme = window.matchMedia("(prefers-color-scheme: light)");
- setTheme(
- preferences.theme === "system" && osTheme.matches ? "light" : preferences.theme === "light" ? "light" : "dark"
- );
-
- osTheme.onchange = e => {
- if (preferences.theme === "system") {
- setTheme(e.matches ? "light" : "dark");
- }
- };
- return () => {
- osTheme.onchange = null;
- };
- }, [preferences.theme]);
-
return (
-
- {!shouldHideHeader && (
-
+ )}
+
+
+
+
+
+ >
);
}
+const NoteCreatorButton = () => {
+ 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 }));
+
+ const shouldHideNoteCreator = useMemo(() => {
+ const isReplyNoteCreatorShowing = replyTo && show;
+ const hideOn = ["/settings", "/messages", "/new", "/login", "/donate", "/e", "/subscribe"];
+ return readonly || isReplyNoteCreatorShowing || hideOn.some(a => location.pathname.startsWith(a));
+ }, [location, readonly]);
+
+ if (shouldHideNoteCreator) return;
+ return (
+ <>
+
+ update(v => {
+ v.replyTo = undefined;
+ v.show = true;
+ })
+ }>
+
+
+
+ >
+ );
+};
+
const AccountHeader = () => {
const navigate = useNavigate();
const { formatMessage } = useIntl();
- const { publicKey, latestNotification, readNotifications } = useLogin();
+ const { publicKey, latestNotification, readNotifications, readonly } = useLogin(s => ({
+ publicKey: s.publicKey,
+ latestNotification: s.latestNotification,
+ readNotifications: s.readNotifications,
+ readonly: s.readonly,
+ }));
const profile = useUserProfile(publicKey);
const [search, setSearch] = useState("");
const [searching, setSearching] = useState(false);
@@ -161,7 +119,7 @@ const AccountHeader = () => {
const [handle, domain] = search.split("@");
const pk = await fetchNip05Pubkey(handle, domain);
if (pk) {
- navigate(`/${createNostrLink(NostrPrefix.PublicKey, pk).encode()}`);
+ navigate(`/${new NostrLink(NostrPrefix.PublicKey, pk).encode()}`);
return;
}
}
@@ -174,7 +132,7 @@ const AccountHeader = () => {
const hasNotifications = useMemo(
() => latestNotification > readNotifications,
- [latestNotification, readNotifications]
+ [latestNotification, readNotifications],
);
const unreadDms = useMemo(() => (publicKey ? 0 : 0), [publicKey]);
@@ -192,6 +150,13 @@ const AccountHeader = () => {
}
}
+ if (!publicKey) {
+ return (
+
navigate("/login")}>
+
+
+ );
+ }
return (
{!location.pathname.startsWith("/search") && (
@@ -215,12 +180,14 @@ const AccountHeader = () => {
)}
)}
-
-
- {unreadDms > 0 &&
}
-
+ {!readonly && (
+
+
+ {unreadDms > 0 &&
}
+
+ )}
-
+
{hasNotifications &&
}
{
);
};
+
+function LogoHeader() {
+ const { subscriptions } = useLogin();
+ const currentSubscription = getCurrentSubscription(subscriptions);
+
+ return (
+
+
{process.env.APP_NAME}
+ {currentSubscription && (
+
+
+ {mapPlanName(currentSubscription.type)}
+
+ )}
+
+ );
+}
diff --git a/packages/app/src/Pages/LoginPage.tsx b/packages/app/src/Pages/LoginPage.tsx
index ba004d7b..91cd7891 100644
--- a/packages/app/src/Pages/LoginPage.tsx
+++ b/packages/app/src/Pages/LoginPage.tsx
@@ -3,22 +3,28 @@ import "./LoginPage.css";
import { CSSProperties, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useIntl, FormattedMessage } from "react-intl";
-import { HexKey, Nip46Signer, PrivateKeySigner } from "@snort/system";
+import { HexKey, Nip46Signer, PinEncrypted, PrivateKeySigner } from "@snort/system";
import { bech32ToHex, getPublicKey, unwrap } from "SnortUtils";
-import ZapButton from "Element/ZapButton";
+import ZapButton from "Element/Event/ZapButton";
import useImgProxy from "Hooks/useImgProxy";
import Icon from "Icons/Icon";
-import useLogin from "Hooks/useLogin";
import { generateNewLogin, LoginSessionType, LoginStore } from "Login";
import AsyncButton from "Element/AsyncButton";
-import useLoginHandler from "Hooks/useLoginHandler";
+import useLoginHandler, { PinRequiredError } from "Hooks/useLoginHandler";
import { secp256k1 } from "@noble/curves/secp256k1";
import { bytesToHex } from "@noble/curves/abstract/utils";
import Modal from "Element/Modal";
import QrCode from "Element/QrCode";
import Copy from "Element/Copy";
import { delay } from "SnortUtils";
+import { PinPrompt } from "Element/PinPrompt";
+
+declare global {
+ interface Window {
+ plausible?: (tag: string) => void;
+ }
+}
interface ArtworkEntry {
name: string;
@@ -55,7 +61,7 @@ const Artwork: Array
= [
export async function getNip05PubKey(addr: string): Promise {
const [username, domain] = addr.split("@");
const rsp = await fetch(
- `https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(username.toLocaleLowerCase())}`
+ `https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(username.toLocaleLowerCase())}`,
);
if (rsp.ok) {
const data = await rsp.json();
@@ -69,9 +75,10 @@ export async function getNip05PubKey(addr: string): Promise {
export default function LoginPage() {
const navigate = useNavigate();
- const login = useLogin();
const [key, setKey] = useState("");
+ const [nip46Key, setNip46Key] = useState("");
const [error, setError] = useState("");
+ const [pin, setPin] = useState(false);
const [art, setArt] = useState();
const [isMasking, setMasking] = useState(true);
const { formatMessage } = useIntl();
@@ -81,38 +88,45 @@ export default function LoginPage() {
const hasSubtleCrypto = window.crypto.subtle !== undefined;
const [nostrConnect, setNostrConnect] = useState("");
- useEffect(() => {
- if (login.publicKey) {
- navigate("/");
- }
- }, [login, navigate]);
-
useEffect(() => {
const ret = unwrap(Artwork.at(Artwork.length * Math.random()));
const url = proxy(ret.link);
setArt({ ...ret, link: url });
}, []);
- async function doLogin() {
+ async function doLogin(pin?: string) {
+ setError("");
try {
- await loginHandler.doLogin(key);
+ await loginHandler.doLogin(key, pin);
+ navigate("/");
} catch (e) {
+ if (e instanceof PinRequiredError) {
+ setPin(true);
+ return;
+ }
if (e instanceof Error) {
setError(e.message);
} else {
setError(
formatMessage({
defaultMessage: "Unknown login error",
- })
+ }),
);
}
console.error(e);
}
}
- async function makeRandomKey() {
- await generateNewLogin();
- navigate("/new");
+ async function makeRandomKey(pin: string) {
+ try {
+ await generateNewLogin(pin);
+ window.plausible?.("Generate Account");
+ navigate("/new");
+ } catch (e) {
+ if (e instanceof Error) {
+ setError(e.message);
+ }
+ }
}
async function doNip07Login() {
@@ -120,9 +134,10 @@ export default function LoginPage() {
"getRelays" in unwrap(window.nostr) ? await unwrap(window.nostr?.getRelays).call(window.nostr) : undefined;
const pubKey = await unwrap(window.nostr).getPublicKey();
LoginStore.loginWithPubkey(pubKey, LoginSessionType.Nip7, relays);
+ navigate("/");
}
- async function startNip46() {
+ function generateNip46() {
const meta = {
name: "Snort",
url: window.location.href,
@@ -135,26 +150,51 @@ export default function LoginPage() {
`metadata=${encodeURIComponent(JSON.stringify(meta))}`,
].join("&")}`;
setNostrConnect(connectUrl);
+ setNip46Key(newKey);
+ }
- const signer = new Nip46Signer(connectUrl, new PrivateKeySigner(newKey));
+ async function startNip46(pin: string) {
+ if (!nostrConnect || !nip46Key) return;
+
+ const signer = new Nip46Signer(nostrConnect, new PrivateKeySigner(nip46Key));
await signer.init();
await delay(500);
await signer.describe();
+ LoginStore.loginWithPubkey(
+ await signer.getPubKey(),
+ LoginSessionType.Nip46,
+ undefined,
+ ["wss://relay.damus.io"],
+ await PinEncrypted.create(nip46Key, pin),
+ );
+ navigate("/");
}
function nip46Buttons() {
- return null;
return (
<>
-
-
+ {
+ generateNip46();
+ setPin(true);
+ }}>
+
- {nostrConnect && (
- setNostrConnect("")}>
-
-
-
-
+ {nostrConnect && !pin && (
+ setNostrConnect("")}>
+ <>
+
+
+
+
+
+
+
+
+
+
+ >
)}
>
@@ -170,7 +210,7 @@ export default function LoginPage() {
<>
@@ -243,7 +283,7 @@ export default function LoginPage() {
navigate("/")}>
- Snort
+ {process.env.APP_NAME}
@@ -251,7 +291,7 @@ export default function LoginPage() {
-
+
setMasking(!isMasking)}
/>
@@ -276,12 +316,32 @@ export default function LoginPage() {
/>
-
+ doLogin()}>
- makeRandomKey()}>
+ setPin(true)}>
+ {pin && (
+
+
+
+ }
+ onResult={async pin => {
+ setPin(false);
+ if (key) {
+ await doLogin(pin);
+ } else if (nostrConnect) {
+ await startNip46(pin);
+ } else {
+ await makeRandomKey(pin);
+ }
+ }}
+ onCancel={() => setPin(false)}
+ />
+ )}
{altLogins()}
{installExtension()}
diff --git a/packages/app/src/Pages/MessagesPage.css b/packages/app/src/Pages/MessagesPage.css
index 133e2e8c..1bcfcd6c 100644
--- a/packages/app/src/Pages/MessagesPage.css
+++ b/packages/app/src/Pages/MessagesPage.css
@@ -5,6 +5,11 @@
height: var(--full-height);
/* 100vh - header - padding */
overflow: hidden;
+ padding: 4px;
+}
+
+.dm-page > div:nth-child(1)::-webkit-scrollbar-track {
+ background: transparent !important;
}
/* These should match what is in code too */
@@ -33,6 +38,7 @@
/* Chat window */
.dm-page > div:nth-child(2) {
padding: 0 12px;
+ margin: 0 4px;
height: var(--full-height);
background-color: var(--gray-superdark);
border-radius: 16px;
@@ -40,7 +46,7 @@
/* Profile pannel */
.dm-page > div:nth-child(3) {
- margin: 0 10px;
+ margin: 16px;
}
.dm-page > div:nth-child(3) .avatar {
diff --git a/packages/app/src/Pages/MessagesPage.tsx b/packages/app/src/Pages/MessagesPage.tsx
index 6646d7ec..e3c69585 100644
--- a/packages/app/src/Pages/MessagesPage.tsx
+++ b/packages/app/src/Pages/MessagesPage.tsx
@@ -3,24 +3,28 @@ import "./MessagesPage.css";
import React, { useEffect, useMemo, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { useNavigate, useParams } from "react-router-dom";
-import { TLVEntryType, decodeTLV } from "@snort/system";
+import { NostrLink, NostrPrefix, TLVEntryType, UserMetadata, decodeTLV } from "@snort/system";
import { useUserProfile, useUserSearch } from "@snort/system-react";
import UnreadCount from "Element/UnreadCount";
-import ProfileImage, { getDisplayName } from "Element/ProfileImage";
+import ProfileImage, { getDisplayName } from "Element/User/ProfileImage";
import { appendDedupe, debounce, parseId } from "SnortUtils";
-import NoteToSelf from "Element/NoteToSelf";
+import NoteToSelf from "Element/User/NoteToSelf";
import useModeration from "Hooks/useModeration";
import useLogin from "Hooks/useLogin";
import usePageWidth from "Hooks/usePageWidth";
-import NoteTime from "Element/NoteTime";
-import DmWindow from "Element/DmWindow";
-import Avatar from "Element/Avatar";
+import NoteTime from "Element/Event/NoteTime";
+import DmWindow from "Element/Chat/DmWindow";
+import Avatar from "Element/User/Avatar";
import Icon from "Icons/Icon";
import Text from "Element/Text";
import { Chat, ChatType, createChatLink, useChatSystem } from "chat";
import Modal from "Element/Modal";
-import ProfilePreview from "Element/ProfilePreview";
+import ProfilePreview from "Element/User/ProfilePreview";
+import { useEventFeed } from "Feed/EventFeed";
+import { LoginSession, LoginStore } from "Login";
+import { Nip28ChatSystem } from "chat/nip28";
+import { ChatParticipantProfile } from "Element/Chat/ChatParticipant";
const TwoCol = 768;
const ThreeCol = 1500;
@@ -57,18 +61,12 @@ export default function MessagesPage() {
function conversationIdent(cx: Chat) {
if (cx.participants.length === 1) {
- const p = cx.participants[0];
-
- if (p.type === "pubkey") {
- return
;
- } else {
- return
;
- }
+ return
;
} else {
return (
{cx.participants.map(v => (
-
+
))}
{cx.title ??
}
@@ -148,7 +146,14 @@ function ProfileDmActions({ id }: { id: string }) {
{getDisplayName(profile, pubkey)}
-
+
(blocked ? unblock(pubkey) : block(pubkey))}>
@@ -161,17 +166,17 @@ function ProfileDmActions({ id }: { id: string }) {
function NewChatWindow() {
const [show, setShow] = useState(false);
- const [newChat, setNewChat] = useState
([]);
- const [results, setResults] = useState([]);
+ const [newChat, setNewChat] = useState>([]);
+ const [results, setResults] = useState>([]);
const [term, setSearchTerm] = useState("");
const navigate = useNavigate();
const search = useUserSearch();
- const { follows } = useLogin();
+ const login = useLogin();
useEffect(() => {
setNewChat([]);
setSearchTerm("");
- setResults(follows.item);
+ setResults(login.follows.item);
}, [show]);
useEffect(() => {
@@ -179,7 +184,7 @@ function NewChatWindow() {
if (term) {
search(term).then(setResults);
} else {
- setResults(follows.item);
+ setResults(login.follows.item);
}
});
}, [term]);
@@ -203,7 +208,7 @@ function NewChatWindow() {
{show && (
- setShow(false)} className="new-chat-modal">
+ setShow(false)} className="new-chat-modal">
@@ -252,6 +257,19 @@ function NewChatWindow() {
/>
);
})}
+ {results.length === 1 && (
+ {
+ setShow(false);
+ LoginStore.updateSession({
+ ...login,
+ extraChats: appendDedupe(login.extraChats, [Nip28ChatSystem.chatId(id)]),
+ } as LoginSession);
+ navigate(createChatLink(ChatType.PublicGroupChat, id));
+ }}
+ />
+ )}
@@ -260,3 +278,19 @@ function NewChatWindow() {
>
);
}
+
+export function Nip28ChatProfile({ id, onClick }: { id: string; onClick: (id: string) => void }) {
+ const channel = useEventFeed(new NostrLink(NostrPrefix.Event, id, 40));
+ if (channel?.data) {
+ const meta = JSON.parse(channel.data.content) as UserMetadata;
+ return (
+
>}
+ onClick={() => onClick(id)}
+ />
+ );
+ }
+}
diff --git a/packages/app/src/Pages/NostrAddressPage.tsx b/packages/app/src/Pages/NostrAddressPage.tsx
index 96c2436c..74d524c1 100644
--- a/packages/app/src/Pages/NostrAddressPage.tsx
+++ b/packages/app/src/Pages/NostrAddressPage.tsx
@@ -1,4 +1,4 @@
-import { FormattedMessage } from "react-intl";
+import FormattedMessage from "Element/FormattedMessage";
import { ApiHost } from "Const";
import Nip5Service from "Element/Nip5Service";
diff --git a/packages/app/src/Pages/NostrLinkHandler.tsx b/packages/app/src/Pages/NostrLinkHandler.tsx
index 6786b05b..99217d26 100644
--- a/packages/app/src/Pages/NostrLinkHandler.tsx
+++ b/packages/app/src/Pages/NostrLinkHandler.tsx
@@ -1,6 +1,6 @@
import { NostrPrefix, tryParseNostrLink } from "@snort/system";
import { useEffect, useState } from "react";
-import { FormattedMessage } from "react-intl";
+import FormattedMessage from "Element/FormattedMessage";
import { useNavigate, useParams } from "react-router-dom";
import Spinner from "Icons/Spinner";
@@ -24,7 +24,7 @@ export default function NostrLinkHandler() {
}
} else {
try {
- const pubkey = await getNip05PubKey(`${link}@snort.social`);
+ const pubkey = await getNip05PubKey(`${link}@${process.env.NIP05_DOMAIN}`);
if (pubkey) {
navigate(profileLink(pubkey));
}
diff --git a/packages/app/src/Pages/Notifications.css b/packages/app/src/Pages/Notifications.css
index 168437fc..e4ab399a 100644
--- a/packages/app/src/Pages/Notifications.css
+++ b/packages/app/src/Pages/Notifications.css
@@ -25,6 +25,10 @@
line-height: 1em;
}
+.notification-group > div:last-of-type {
+ max-width: calc(100% - 64px);
+}
+
.notification-group .avatar {
width: 40px;
height: 40px;
@@ -39,8 +43,12 @@
}
.notification-group .content {
- font-size: 14px;
- line-height: 22px;
+ cursor: pointer;
color: var(--font-secondary-color);
- word-break: break-all;
+}
+
+.notification-group .content img {
+ width: unset;
+ max-width: 100%;
+ max-height: 300px; /* Cap images in notifications to 300px height */
}
diff --git a/packages/app/src/Pages/Notifications.tsx b/packages/app/src/Pages/Notifications.tsx
index f510b7b5..3e0325a5 100644
--- a/packages/app/src/Pages/Notifications.tsx
+++ b/packages/app/src/Pages/Notifications.tsx
@@ -1,31 +1,24 @@
import "./Notifications.css";
import { useEffect, useMemo, useSyncExternalStore } from "react";
-import {
- EventExt,
- EventKind,
- NostrEvent,
- NostrLink,
- NostrPrefix,
- TaggedNostrEvent,
- createNostrLink,
- parseZap,
-} from "@snort/system";
+import { EventExt, EventKind, NostrEvent, NostrLink, NostrPrefix, TaggedNostrEvent, parseZap } from "@snort/system";
import { unwrap } from "@snort/shared";
import { useUserProfile } from "@snort/system-react";
import { useInView } from "react-intersection-observer";
import { FormattedMessage, useIntl } from "react-intl";
+import { useNavigate } from "react-router-dom";
import useLogin from "Hooks/useLogin";
import { markNotificationsRead } from "Login";
import { Notifications, UserCache } from "Cache";
import { dedupe, findTag, orderDescending } from "SnortUtils";
import Icon from "Icons/Icon";
-import ProfileImage, { getDisplayName } from "Element/ProfileImage";
+import ProfileImage, { getDisplayName } from "Element/User/ProfileImage";
import useModeration from "Hooks/useModeration";
-import useEventFeed from "Feed/EventFeed";
+import { useEventFeed } from "Feed/EventFeed";
import Text from "Element/Text";
import { formatShort } from "Number";
-import { useNavigate } from "react-router-dom";
+import { LiveEvent } from "Element/LiveEvent";
+import ProfilePreview from "Element/User/ProfilePreview";
function notificationContext(ev: TaggedNostrEvent) {
switch (ev.kind) {
@@ -33,15 +26,15 @@ function notificationContext(ev: TaggedNostrEvent) {
const aTag = findTag(ev, "a");
if (aTag) {
const [kind, author, d] = aTag.split(":");
- return createNostrLink(NostrPrefix.Address, d, undefined, Number(kind), author);
+ return new NostrLink(NostrPrefix.Address, d, Number(kind), author);
}
const eTag = findTag(ev, "e");
if (eTag) {
- return createNostrLink(NostrPrefix.Event, eTag);
+ return new NostrLink(NostrPrefix.Event, eTag);
}
const pTag = ev.tags.filter(a => a[0] === "p").slice(-1)?.[0];
if (pTag) {
- return createNostrLink(NostrPrefix.PublicKey, pTag[1]);
+ return new NostrLink(NostrPrefix.PublicKey, pTag[1]);
}
break;
}
@@ -50,21 +43,21 @@ function notificationContext(ev: TaggedNostrEvent) {
const thread = EventExt.extractThread(ev);
const tag = unwrap(thread?.replyTo ?? thread?.root ?? { value: ev.id, key: "e" });
if (tag.key === "e") {
- return createNostrLink(NostrPrefix.Event, unwrap(tag.value));
+ return new NostrLink(NostrPrefix.Event, unwrap(tag.value));
} else if (tag.key === "a") {
const [kind, author, d] = unwrap(tag.value).split(":");
- return createNostrLink(NostrPrefix.Address, d, undefined, Number(kind), author);
+ return new NostrLink(NostrPrefix.Address, d, Number(kind), author);
} else {
throw new Error("Unknown thread context");
}
}
case EventKind.TextNote: {
- return createNostrLink(NostrPrefix.Note, ev.id);
+ return new NostrLink(NostrPrefix.Note, ev.id);
}
}
}
-export default function NotificationsPage() {
+export default function NotificationsPage({ onClick }: { onClick?: (link: NostrLink) => void }) {
const login = useLogin();
const { isMuted } = useModeration();
const groupInterval = 3600 * 3;
@@ -75,7 +68,7 @@ export default function NotificationsPage() {
const notifications = useSyncExternalStore(
c => Notifications.hook(c, "*"),
- () => Notifications.snapshot()
+ () => Notifications.snapshot(),
);
const timeKey = (ev: NostrEvent) => {
@@ -85,7 +78,7 @@ export default function NotificationsPage() {
const timeGrouped = useMemo(() => {
return orderDescending([...notifications])
- .filter(a => !isMuted(a.pubkey) && findTag(a, "p") === login.publicKey)
+ .filter(a => !isMuted(a.pubkey) && a.tags.some(b => b[0] === "p" && b[1] === login.publicKey))
.reduce((acc, v) => {
const key = `${timeKey(v)}:${notificationContext(v as TaggedNostrEvent)?.encode()}:${v.kind}`;
if (acc.has(key)) {
@@ -99,15 +92,17 @@ export default function NotificationsPage() {
return (
- {login.publicKey && [...timeGrouped.entries()].map(([k, g]) => )}
+ {login.publicKey &&
+ [...timeGrouped.entries()].map(([k, g]) => )}
);
}
-function NotificationGroup({ evs }: { evs: Array }) {
+function NotificationGroup({ evs, onClick }: { evs: Array; onClick?: (link: NostrLink) => void }) {
const { ref, inView } = useInView({ triggerOnce: true });
const { formatMessage } = useIntl();
const kind = evs[0].kind;
+ const navigate = useNavigate();
const zaps = useMemo(() => {
return evs.filter(a => a.kind === EventKind.ZapReceipt).map(a => parseZap(a, UserCache));
@@ -119,7 +114,7 @@ function NotificationGroup({ evs }: { evs: Array }) {
return zap.anonZap ? "anon" : zap.sender ?? a.pubkey;
}
return a.pubkey;
- })
+ }),
);
const firstPubkey = pubkeys[0];
const firstPubkeyProfile = useUserProfile(inView ? (firstPubkey === "anon" ? "" : firstPubkey) : "");
@@ -192,7 +187,7 @@ function NotificationGroup({ evs }: { evs: Array }) {
{kind === EventKind.ZapReceipt && formatShort(totalZaps)}
-
+
{pubkeys
.filter(a => a !== "anon")
@@ -213,11 +208,22 @@ function NotificationGroup({ evs }: { evs: Array }) {
pubkeys.length - 1,
firstPubkey === "anon"
? formatMessage({ defaultMessage: "Anon" })
- : getDisplayName(firstPubkeyProfile, firstPubkey)
+ : getDisplayName(firstPubkeyProfile, firstPubkey),
)}
)}
-
{context && }
+ {context && (
+
{
+ if (onClick) {
+ onClick(context);
+ } else {
+ navigate(`/e/${context.encode()}`);
+ }
+ }}
+ />
+ )}
>
)}
@@ -225,18 +231,25 @@ function NotificationGroup({ evs }: { evs: Array
}) {
);
}
-function NotificationContext({ link }: { link: NostrLink }) {
+function NotificationContext({ link, onClick }: { link: NostrLink; onClick: () => void }) {
const { data: ev } = useEventFeed(link);
- const navigate = useNavigate();
- const content = ev?.content ?? "";
-
+ if (link.type === NostrPrefix.PublicKey) {
+ return >} />;
+ }
+ if (!ev) return;
+ if (ev.kind === EventKind.LiveEvent) {
+ return ;
+ }
return (
- navigate(`/${link.encode()}`)} className="pointer">
- 120 ? `${content.substring(0, 120)}...` : content}
- tags={ev?.tags ?? []}
- creator={ev?.pubkey ?? ""}
- />
-
+
);
}
diff --git a/packages/app/src/Pages/ProfilePage.css b/packages/app/src/Pages/ProfilePage.css
index e25d57a5..eb4e3c5e 100644
--- a/packages/app/src/Pages/ProfilePage.css
+++ b/packages/app/src/Pages/ProfilePage.css
@@ -2,7 +2,9 @@
display: flex;
flex-direction: column;
align-items: flex-start;
- border: 1px solid var(--gray-superdark);
+ border: 1px solid var(--border-color);
+ border-bottom: none;
+ border-top: none;
}
.profile .banner {
@@ -17,12 +19,14 @@
display: flex;
align-items: center;
align-self: flex-end;
+ gap: 8px;
}
.profile .icon-actions {
display: flex;
flex-direction: row;
align-items: center;
+ gap: 8px;
}
.profile .profile-actions button:not(:last-child) {
@@ -39,6 +43,7 @@
max-width: 720px;
height: 280px;
}
+
.profile .profile-actions button.icon:not(:last-child) {
margin-right: 2px;
}
@@ -58,10 +63,8 @@
white-space: pre-wrap;
}
-.details-wrapper > .name > h2 {
- margin: 0 0 4px 0;
- font-weight: 600;
- font-size: 21px;
+.details-wrapper h2 {
+ margin: 0;
}
.details-wrapper > .name > .nip05 {
@@ -87,7 +90,7 @@
}
.profile .about {
- color: var(--font-secondary-color);
+ color: var(--font-color);
font-size: 16px;
line-height: 26px;
}
@@ -138,6 +141,7 @@
.profile .website a {
text-decoration: none;
}
+
.profile .website a:hover {
text-decoration: underline;
}
@@ -205,6 +209,7 @@
.profile .nip05 .nick {
display: unset;
}
+
.profile .nip05 .domain {
display: unset;
}
diff --git a/packages/app/src/Pages/ProfilePage.tsx b/packages/app/src/Pages/ProfilePage.tsx
index 6ff75f64..9d4d4406 100644
--- a/packages/app/src/Pages/ProfilePage.tsx
+++ b/packages/app/src/Pages/ProfilePage.tsx
@@ -1,13 +1,13 @@
import "./ProfilePage.css";
import { useEffect, useState } from "react";
-import { FormattedMessage } from "react-intl";
+import FormattedMessage from "Element/FormattedMessage";
import { useNavigate, useParams } from "react-router-dom";
import {
- createNostrLink,
encodeTLV,
encodeTLVEntries,
EventKind,
HexKey,
+ NostrLink,
NostrPrefix,
TLVEntryType,
tryParseNostrLink,
@@ -15,11 +15,11 @@ import {
import { LNURL } from "@snort/shared";
import { useUserProfile } from "@snort/system-react";
-import { getReactions, unwrap } from "SnortUtils";
+import { findTag, getReactions, unwrap } from "SnortUtils";
import { formatShort } from "Number";
-import Note from "Element/Note";
+import Note from "Element/Event/Note";
import Bookmarks from "Element/Bookmarks";
-import RelaysMetadata from "Element/RelaysMetadata";
+import RelaysMetadata from "Element/Relay/RelaysMetadata";
import { Tab, TabElement } from "Element/Tabs";
import Icon from "Icons/Icon";
import useMutedFeed from "Feed/MuteList";
@@ -31,29 +31,31 @@ import useFollowsFeed from "Feed/FollowsFeed";
import useProfileBadges from "Feed/BadgesFeed";
import useModeration from "Hooks/useModeration";
import useZapsFeed from "Feed/ZapsFeed";
-import { default as ZapElement } from "Element/Zap";
-import FollowButton from "Element/FollowButton";
+import { default as ZapElement } from "Element/Event/Zap";
+import FollowButton from "Element/User/FollowButton";
import { parseId, hexToBech32 } from "SnortUtils";
-import Avatar from "Element/Avatar";
-import Timeline from "Element/Timeline";
+import Avatar from "Element/User/Avatar";
+import Timeline from "Element/Feed/Timeline";
import Text from "Element/Text";
import SendSats from "Element/SendSats";
-import Nip05 from "Element/Nip05";
+import Nip05 from "Element/User/Nip05";
import Copy from "Element/Copy";
-import ProfileImage from "Element/ProfileImage";
-import BlockList from "Element/BlockList";
-import MutedList from "Element/MutedList";
-import FollowsList from "Element/FollowListBase";
+import ProfileImage from "Element/User/ProfileImage";
+import BlockList from "Element/User/BlockList";
+import MutedList from "Element/User/MutedList";
+import FollowsList from "Element/User/FollowListBase";
import IconButton from "Element/IconButton";
-import FollowsYou from "Element/FollowsYou";
+import FollowsYou from "Element/User/FollowsYou";
import QrCode from "Element/QrCode";
import Modal from "Element/Modal";
-import BadgeList from "Element/BadgeList";
+import BadgeList from "Element/User/BadgeList";
import { ProxyImg } from "Element/ProxyImg";
import useHorizontalScroll from "Hooks/useHorizontalScroll";
import { EmailRegex } from "Const";
import { getNip05PubKey } from "Pages/LoginPage";
import useLogin from "Hooks/useLogin";
+import { ZapTarget } from "Zapper";
+import { useStatusFeed } from "Feed/StatusFeed";
import messages from "./messages";
@@ -68,13 +70,13 @@ const RELAYS = 7;
const BOOKMARKS = 8;
function ZapsProfileTab({ id }: { id: HexKey }) {
- const zaps = useZapsFeed(createNostrLink(NostrPrefix.PublicKey, id));
+ const zaps = useZapsFeed(new NostrLink(NostrPrefix.PublicKey, id));
const zapsTotal = zaps.reduce((acc, z) => acc + z.amount, 0);
return (
-
+
-
+
{zaps.map(z => (
))}
@@ -84,12 +86,12 @@ function ZapsProfileTab({ id }: { id: HexKey }) {
function FollowersTab({ id }: { id: HexKey }) {
const followers = useFollowersFeed(id);
- return
;
+ return
;
}
function FollowsTab({ id }: { id: HexKey }) {
const follows = useFollowsFeed(id);
- return
;
+ return
;
}
function RelaysTab({ id }: { id: HexKey }) {
@@ -113,17 +115,12 @@ export default function ProfilePage() {
const navigate = useNavigate();
const [id, setId] = useState
();
const user = useUserProfile(id);
- const loginPubKey = useLogin().publicKey;
+ const login = useLogin();
+ const loginPubKey = login.publicKey;
const isMe = loginPubKey === id;
const [showLnQr, setShowLnQr] = useState(false);
const [showProfileQr, setShowProfileQr] = useState(false);
const aboutText = user?.about || "";
- const about = Text({
- content: aboutText,
- tags: [],
- creator: "",
- disableMedia: true,
- });
const npub = !id?.startsWith(NostrPrefix.PublicKey) ? hexToBech32(NostrPrefix.PublicKey, id || undefined) : id;
const lnurl = (() => {
@@ -133,14 +130,19 @@ export default function ProfilePage() {
// ignored
}
})();
+ const showBadges = login.preferences.showBadges ?? false;
+ const showStatus = login.preferences.showStatus ?? true;
+
const website_url =
user?.website && !user.website.startsWith("http") ? "https://" + user.website : user?.website || "";
// feeds
const { blocked } = useModeration();
const pinned = usePinnedFeed(id);
const muted = useMutedFeed(id);
- const badges = useProfileBadges(id);
+ const badges = useProfileBadges(showBadges ? id : undefined);
const follows = useFollowsFeed(id);
+ const status = useStatusFeed(showStatus ? id : undefined, true);
+
// tabs
const ProfileTab = {
Notes: {
@@ -227,7 +229,7 @@ export default function ProfilePage() {
} as { [key: string]: Tab };
const [tab, setTab] = useState(ProfileTab.Notes);
const optionalTabs = [ProfileTab.Zaps, ProfileTab.Relays, ProfileTab.Bookmarks, ProfileTab.Muted].filter(a =>
- unwrap(a)
+ unwrap(a),
) as Tab[];
const horizontalScroll = useHorizontalScroll();
@@ -248,17 +250,41 @@ export default function ProfilePage() {
setTab(ProfileTab.Notes);
}, [params]);
+ function musicStatus() {
+ if (!status.music) return;
+
+ const link = findTag(status.music, "r");
+ const cover = findTag(status.music, "cover");
+ const inner = () => {
+ return (
+
+ {cover &&
}
+
🎵 {unwrap(status.music).content}
+
+ );
+ };
+ if (link) {
+ return (
+
+ {inner()}
+
+ );
+ }
+ return inner();
+ }
+
function username() {
return (
<>
-
-
+
+
{user?.display_name || user?.name || "Nostrich"}
{user?.nip05 && }
-
+ {showBadges && }
+ {showStatus && <>{musicStatus()}>}
{links()}
@@ -297,21 +323,41 @@ export default function ProfilePage() {
)}
setShowLnQr(false)}
- author={id}
- target={user?.display_name || user?.name}
/>
>
);
}
function bio() {
+ if (!id) return null;
+
return (
aboutText.length > 0 && (
- {about}
+
)
);
@@ -356,7 +402,7 @@ export default function ProfilePage() {
}
case FOLLOWS: {
if (isMe) {
- return ;
+ return ;
} else {
return ;
}
@@ -385,7 +431,7 @@ export default function ProfilePage() {
{renderIcons()}
- {!isMe && id && }
+ {!isMe && id && }
);
@@ -401,7 +447,7 @@ export default function ProfilePage() {
{showProfileQr && (
- setShowProfileQr(false)}>
+ setShowProfileQr(false)}>
@@ -420,16 +466,16 @@ export default function ProfilePage() {
)}
- {loginPubKey && (
+ {loginPubKey && !login.readonly && (
<>
navigate(
`/messages/${encodeTLVEntries("chat4" as NostrPrefix, {
type: TLVEntryType.Author,
- length: 64,
+ length: 32,
value: id,
- })}`
+ })}`,
)
}>
@@ -467,7 +513,7 @@ export default function ProfilePage() {
-
+
{[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows].map(renderTab)}
{optionalTabs.map(renderTab)}
{isMe && blocked.length > 0 && renderTab(ProfileTab.Blocked)}
diff --git a/packages/app/src/Pages/Root.css b/packages/app/src/Pages/Root.css
index 4a26dfe7..e69de29b 100644
--- a/packages/app/src/Pages/Root.css
+++ b/packages/app/src/Pages/Root.css
@@ -1,18 +0,0 @@
-.root-type {
- padding: 8px 12px;
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-.root-type > button {
- background: white;
- color: black;
- font-size: 16px;
- padding: 10px 16px;
- border-radius: 1000px;
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 12px;
-}
diff --git a/packages/app/src/Pages/Root.tsx b/packages/app/src/Pages/Root.tsx
index aee22f9e..69255d00 100644
--- a/packages/app/src/Pages/Root.tsx
+++ b/packages/app/src/Pages/Root.tsx
@@ -1,21 +1,23 @@
-import { ReactNode, useEffect, useState } from "react";
-import { Link, Outlet, RouteObject, useLocation, useNavigate, useParams } from "react-router-dom";
-import { FormattedMessage } from "react-intl";
-import { Menu, MenuItem } from "@szhsin/react-menu";
-import "./Root.css";
+import { useContext, useEffect, useState } from "react";
+import { Link, Outlet, RouteObject, useParams } from "react-router-dom";
+import FormattedMessage from "Element/FormattedMessage";
+import { unixNow } from "@snort/shared";
+import { NostrLink } from "@snort/system";
-import Timeline from "Element/Timeline";
+import Timeline from "Element/Feed/Timeline";
import { System } from "index";
import { TimelineSubject } from "Feed/TimelineFeed";
-import { debounce, getRelayName, sha256, unixNow } from "SnortUtils";
+import { debounce, getRelayName, sha256 } from "SnortUtils";
import useLogin from "Hooks/useLogin";
import Discover from "Pages/Discover";
-import Icon from "Icons/Icon";
import TrendingUsers from "Element/TrendingUsers";
-import TrendingNotes from "Element/TrendingPosts";
+import TrendingNotes from "Element/Feed/TrendingPosts";
import HashTagsPage from "Pages/HashTagsPage";
import SuggestedProfiles from "Element/SuggestedProfiles";
import { TaskList } from "Tasks/TaskList";
+import TimelineFollows from "Element/Feed/TimelineFollows";
+import { RootTabs } from "Element/RootTabs";
+import { DeckContext } from "Pages/DeckLayout";
import messages from "./messages";
@@ -24,149 +26,11 @@ interface RelayOption {
paid: boolean;
}
-type RootPage = "following" | "conversations" | "trending-notes" | "trending-people" | "suggested" | "tags" | "global";
-
export default function RootPage() {
- const navigate = useNavigate();
- const location = useLocation();
- const { publicKey: pubKey, tags, preferences } = useLogin();
- const [rootType, setRootType] = useState
("following");
-
- const menuItems = [
- {
- tab: "following",
- path: "/notes",
- show: Boolean(pubKey),
- element: (
- <>
-
-
- >
- ),
- },
- {
- tab: "trending-notes",
- path: "/trending/notes",
- show: true,
- element: (
- <>
-
-
- >
- ),
- },
- {
- tab: "conversations",
- path: "/conversations",
- show: Boolean(pubKey),
- element: (
- <>
-
-
- >
- ),
- },
- {
- tab: "trending-people",
- path: "/trending/people",
- show: true,
- element: (
- <>
-
-
- >
- ),
- },
- {
- tab: "suggested",
- path: "/suggested",
- show: Boolean(pubKey),
- element: (
- <>
-
-
- >
- ),
- },
- {
- tab: "global",
- path: "/global",
- show: true,
- element: (
- <>
-
-
- >
- ),
- },
- ] as Array<{
- tab: RootPage;
- path: string;
- show: boolean;
- element: ReactNode;
- }>;
-
- useEffect(() => {
- if (location.pathname === "/") {
- const t = pubKey ? preferences.defaultRootTab ?? "/notes" : "/trending/notes";
- navigate(t);
- } else {
- const currentTab = menuItems.find(a => a.path === location.pathname)?.tab;
- if (currentTab) {
- setRootType(currentTab);
- }
- }
- }, [location]);
-
- function currentMenuItem() {
- if (location.pathname.startsWith("/t/")) {
- return (
- <>
-
- {location.pathname.split("/").slice(-1)}
- >
- );
- }
- return menuItems.find(a => a.tab === rootType)?.element;
- }
-
return (
<>
-
-
- {currentMenuItem()}
-
-
- }
- align="center"
- menuClassName={() => "ctx-menu"}>
-
- {menuItems
- .filter(a => a.show)
- .map(a => (
- {
- navigate(a.path);
- }}>
- {a.element}
-
- ))}
- {tags.item.map(v => (
- {
- navigate(`/t/${v}`);
- }}>
-
- {v}
-
- ))}
-
+
+
@@ -194,7 +58,7 @@ const FollowsHint = () => {
return null;
};
-const GlobalTab = () => {
+export const GlobalTab = () => {
const { relays } = useLogin();
const [relay, setRelay] = useState
();
const [allRelays, setAllRelays] = useState();
@@ -270,96 +134,103 @@ const GlobalTab = () => {
);
};
-const NotesTab = () => {
- const { follows, publicKey } = useLogin();
- const subject: TimelineSubject = {
- type: "pubkey",
- items: follows.item,
- discriminator: `follows:${publicKey?.slice(0, 12)}`,
- streams: true,
- };
+export const NotesTab = () => {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const deckContext = useContext(DeckContext);
return (
<>
-
+ {
+ deckContext.setThread(NostrLink.fromEvent(ev));
+ }
+ : undefined
+ }
+ />
>
);
};
-const ConversationsTab = () => {
- const { follows, publicKey } = useLogin();
- const subject: TimelineSubject = {
- type: "pubkey",
- items: follows.item,
- discriminator: `follows:${publicKey?.slice(0, 12)}`,
- };
-
- return ;
+export const ConversationsTab = () => {
+ return ;
};
-const TagsTab = () => {
+export const TagsTab = (params: { tag?: string }) => {
const { tag } = useParams();
+ const t = params.tag ?? tag ?? "";
const subject: TimelineSubject = {
type: "hashtag",
- items: [tag ?? ""],
- discriminator: `tags-${tag}`,
+ items: [t],
+ discriminator: `tags-${t}`,
streams: true,
};
return ;
};
+const DefaultTab = () => {
+ const { preferences, publicKey } = useLogin();
+ const tab = publicKey ? preferences.defaultRootTab ?? `notes` : `trending/notes`;
+ const elm = RootTabRoutes.find(a => a.path === tab)?.element;
+ return elm;
+};
+
+export const RootTabRoutes = [
+ {
+ path: "",
+ element: ,
+ },
+ {
+ path: "global",
+ element: ,
+ },
+ {
+ path: "notes",
+ element: ,
+ },
+ {
+ path: "conversations",
+ element: ,
+ },
+ {
+ path: "discover",
+ element: ,
+ },
+ {
+ path: "tag/:tag",
+ element: ,
+ },
+ {
+ path: "trending/notes",
+ element: ,
+ },
+ {
+ path: "trending/people",
+ element: ,
+ },
+ {
+ path: "suggested",
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: "t/:tag",
+ element: ,
+ },
+];
+
export const RootRoutes = [
{
path: "/",
element: ,
- children: [
- {
- path: "global",
- element: ,
- },
- {
- path: "notes",
- element: ,
- },
- {
- path: "conversations",
- element: ,
- },
- {
- path: "discover",
- element: ,
- },
- {
- path: "tag/:tag",
- element: ,
- },
- {
- path: "trending/notes",
- element: ,
- },
- {
- path: "trending/people",
- element: (
-
-
-
- ),
- },
- {
- path: "suggested",
- element: (
-
-
-
- ),
- },
- {
- path: "/t/:tag",
- element: ,
- },
- ],
+ children: RootTabRoutes,
},
] as RouteObject[];
diff --git a/packages/app/src/Pages/SearchPage.tsx b/packages/app/src/Pages/SearchPage.tsx
index ead1a9c7..637a9f91 100644
--- a/packages/app/src/Pages/SearchPage.tsx
+++ b/packages/app/src/Pages/SearchPage.tsx
@@ -1,13 +1,13 @@
import { useIntl, FormattedMessage } from "react-intl";
import { useParams } from "react-router-dom";
-import Timeline from "Element/Timeline";
+import Timeline from "Element/Feed/Timeline";
import { Tab, TabElement } from "Element/Tabs";
import { useEffect, useState } from "react";
import { debounce } from "SnortUtils";
import { router } from "index";
import TrendingUsers from "Element/TrendingUsers";
-import TrendingNotes from "Element/TrendingPosts";
+import TrendingNotes from "Element/Feed/TrendingPosts";
const NOTES = 0;
const PROFILES = 1;
@@ -106,7 +106,7 @@ const SearchPage = () => {
autoFocus={true}
/>
-
{[SearchTab.Posts, SearchTab.Profiles].map(renderTab)}
+
{[SearchTab.Posts, SearchTab.Profiles].map(renderTab)}
{tabContent()}
);
diff --git a/packages/app/src/Pages/SettingsPage.tsx b/packages/app/src/Pages/SettingsPage.tsx
index 4160ca7b..34d236b0 100644
--- a/packages/app/src/Pages/SettingsPage.tsx
+++ b/packages/app/src/Pages/SettingsPage.tsx
@@ -1,4 +1,4 @@
-import { FormattedMessage } from "react-intl";
+import FormattedMessage from "Element/FormattedMessage";
import { Outlet, RouteObject, useNavigate } from "react-router-dom";
import SettingsIndex from "Pages/settings/Root";
import Profile from "Pages/settings/Profile";
@@ -9,6 +9,7 @@ import AccountsPage from "Pages/settings/Accounts";
import { WalletSettingsRoutes } from "Pages/settings/WalletSettings";
import { ManageHandleRoutes } from "Pages/settings/handle";
import ExportKeys from "Pages/settings/Keys";
+import { ModerationSettings } from "./settings/Moderation";
import messages from "./messages";
@@ -56,6 +57,10 @@ export const SettingsRoutes: RouteObject[] = [
path: "keys",
element: ,
},
+ {
+ path: "moderation",
+ element: ,
+ },
...ManageHandleRoutes,
...WalletSettingsRoutes,
],
diff --git a/packages/app/src/Pages/WalletPage.tsx b/packages/app/src/Pages/WalletPage.tsx
index 8364f95c..362a04b5 100644
--- a/packages/app/src/Pages/WalletPage.tsx
+++ b/packages/app/src/Pages/WalletPage.tsx
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
import { RouteObject, useNavigate } from "react-router-dom";
import { FormattedMessage, FormattedNumber, useIntl } from "react-intl";
-import NoteTime from "Element/NoteTime";
+import NoteTime from "Element/Event/NoteTime";
import { WalletInvoice, Sats, WalletInfo, WalletInvoiceState, useWallet, LNWallet, Wallets } from "Wallet";
import AsyncButton from "Element/AsyncButton";
import { unwrap } from "SnortUtils";
diff --git a/packages/app/src/Pages/ZapPool.tsx b/packages/app/src/Pages/ZapPool.tsx
index f6703ee3..3d1504a3 100644
--- a/packages/app/src/Pages/ZapPool.tsx
+++ b/packages/app/src/Pages/ZapPool.tsx
@@ -5,7 +5,7 @@ import { FormattedMessage, FormattedNumber } from "react-intl";
import { useUserProfile } from "@snort/system-react";
import { SnortPubKey } from "Const";
-import ProfilePreview from "Element/ProfilePreview";
+import ProfilePreview from "Element/User/ProfilePreview";
import useLogin from "Hooks/useLogin";
import { UploaderServices } from "Upload";
import { bech32ToHex, getRelayName, unwrap } from "SnortUtils";
@@ -73,7 +73,7 @@ export default function ZapPoolPage() {
const login = useLogin();
const zapPool = useSyncExternalStore(
c => ZapPoolController.hook(c),
- () => ZapPoolController.snapshot()
+ () => ZapPoolController.snapshot(),
);
const { wallet } = useWallet();
diff --git a/packages/app/src/Pages/new/DiscoverFollows.tsx b/packages/app/src/Pages/new/DiscoverFollows.tsx
index 7a0afcca..80479768 100644
--- a/packages/app/src/Pages/new/DiscoverFollows.tsx
+++ b/packages/app/src/Pages/new/DiscoverFollows.tsx
@@ -4,7 +4,7 @@ import { useNavigate, Link } from "react-router-dom";
import { RecommendedFollows } from "Const";
import Logo from "Element/Logo";
-import FollowListBase from "Element/FollowListBase";
+import FollowListBase from "Element/User/FollowListBase";
import { clearEntropy } from "Login";
import useLogin from "Hooks/useLogin";
import TrendingUsers from "Element/TrendingUsers";
diff --git a/packages/app/src/Pages/new/GetVerified.tsx b/packages/app/src/Pages/new/GetVerified.tsx
index 273de69e..c9ce92f7 100644
--- a/packages/app/src/Pages/new/GetVerified.tsx
+++ b/packages/app/src/Pages/new/GetVerified.tsx
@@ -1,12 +1,12 @@
import { useState } from "react";
-import { FormattedMessage } from "react-intl";
+import FormattedMessage from "Element/FormattedMessage";
import { useNavigate } from "react-router-dom";
import { useUserProfile } from "@snort/system-react";
import Logo from "Element/Logo";
import { Nip5Services } from "Pages/NostrAddressPage";
import Nip5Service from "Element/Nip5Service";
-import ProfileImage from "Element/ProfileImage";
+import ProfileImage from "Element/User/ProfileImage";
import useLogin from "Hooks/useLogin";
import messages from "./messages";
diff --git a/packages/app/src/Pages/new/ImportFollows.tsx b/packages/app/src/Pages/new/ImportFollows.tsx
index 3a1654c5..3e0d6f63 100644
--- a/packages/app/src/Pages/new/ImportFollows.tsx
+++ b/packages/app/src/Pages/new/ImportFollows.tsx
@@ -5,7 +5,7 @@ import { useNavigate } from "react-router-dom";
import { ApiHost } from "Const";
import Logo from "Element/Logo";
import AsyncButton from "Element/AsyncButton";
-import FollowListBase from "Element/FollowListBase";
+import FollowListBase from "Element/User/FollowListBase";
import { bech32ToHex } from "SnortUtils";
import SnortApi from "SnortApi";
import useLogin from "Hooks/useLogin";
diff --git a/packages/app/src/Pages/new/NewUserFlow.tsx b/packages/app/src/Pages/new/NewUserFlow.tsx
index 4c780d7e..a88e5ffc 100644
--- a/packages/app/src/Pages/new/NewUserFlow.tsx
+++ b/packages/app/src/Pages/new/NewUserFlow.tsx
@@ -1,4 +1,4 @@
-import { FormattedMessage } from "react-intl";
+import FormattedMessage from "Element/FormattedMessage";
import { useNavigate } from "react-router-dom";
import Logo from "Element/Logo";
diff --git a/packages/app/src/Pages/new/ProfileSetup.tsx b/packages/app/src/Pages/new/ProfileSetup.tsx
index 3d23df0e..dd4d17a6 100644
--- a/packages/app/src/Pages/new/ProfileSetup.tsx
+++ b/packages/app/src/Pages/new/ProfileSetup.tsx
@@ -5,10 +5,10 @@ import { mapEventToProfile } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import Logo from "Element/Logo";
-import useEventPublisher from "Feed/EventPublisher";
+import useEventPublisher from "Hooks/useEventPublisher";
import useLogin from "Hooks/useLogin";
import { UserCache } from "Cache";
-import AvatarEditor from "Element/AvatarEditor";
+import AvatarEditor from "Element/User/AvatarEditor";
import { DISCOVER } from ".";
import { System } from "index";
diff --git a/packages/app/src/Pages/settings/Accounts.tsx b/packages/app/src/Pages/settings/Accounts.tsx
index 9ad66696..e91271a4 100644
--- a/packages/app/src/Pages/settings/Accounts.tsx
+++ b/packages/app/src/Pages/settings/Accounts.tsx
@@ -1,57 +1,32 @@
-import { useState } from "react";
-import { FormattedMessage, useIntl } from "react-intl";
+import FormattedMessage from "Element/FormattedMessage";
+import { Link } from "react-router-dom";
-import ProfilePreview from "Element/ProfilePreview";
+import ProfilePreview from "Element/User/ProfilePreview";
import { LoginStore } from "Login";
-import useLoginHandler from "Hooks/useLoginHandler";
-import AsyncButton from "Element/AsyncButton";
import { getActiveSubscriptions } from "Subscription";
export default function AccountsPage() {
- const { formatMessage } = useIntl();
- const [key, setKey] = useState("");
- const [error, setError] = useState("");
- const loginHandler = useLoginHandler();
const logins = LoginStore.getSessions();
const sub = getActiveSubscriptions(LoginStore.allSubscriptions());
- async function doLogin() {
- try {
- setError("");
- await loginHandler.doLogin(key);
- setKey("");
- } catch (e) {
- if (e instanceof Error) {
- setError(e.message);
- } else {
- setError(
- formatMessage({
- defaultMessage: "Unknown login error",
- })
- );
- }
- console.error(e);
- }
- }
-
return (
- <>
+
{logins.map(a => (
-
+
- LoginStore.switchAccount(a)}>
+ LoginStore.switchAccount(a.id)}>
- LoginStore.removeSession(a)}>
+ LoginStore.removeSession(a.id)}>
@@ -61,27 +36,12 @@ export default function AccountsPage() {
))}
{sub && (
- <>
-
+
+
-
-
-
setKey(e.target.value)}
- />
-
doLogin()}>
-
-
-
- >
+
+
)}
- {error &&
{error} }
- >
+
);
}
diff --git a/packages/app/src/Pages/settings/Keys.tsx b/packages/app/src/Pages/settings/Keys.tsx
index a678ffec..278e8199 100644
--- a/packages/app/src/Pages/settings/Keys.tsx
+++ b/packages/app/src/Pages/settings/Keys.tsx
@@ -1,6 +1,6 @@
import "./Keys.css";
-import { FormattedMessage } from "react-intl";
-import { encodeTLV, NostrPrefix } from "@snort/system";
+import FormattedMessage from "Element/FormattedMessage";
+import { encodeTLV, NostrPrefix, PinEncrypted } from "@snort/system";
import Copy from "Element/Copy";
import useLogin from "Hooks/useLogin";
@@ -8,7 +8,7 @@ import { hexToMnemonic } from "nip6";
import { hexToBech32 } from "SnortUtils";
export default function ExportKeys() {
- const { publicKey, privateKey, generatedEntropy } = useLogin();
+ const { publicKey, privateKeyData, generatedEntropy } = useLogin();
return (
@@ -16,12 +16,12 @@ export default function ExportKeys() {
- {privateKey && (
+ {privateKeyData instanceof PinEncrypted && (
<>
-
+
>
)}
{generatedEntropy && (
diff --git a/packages/app/src/Pages/settings/Moderation.tsx b/packages/app/src/Pages/settings/Moderation.tsx
new file mode 100644
index 00000000..394c55aa
--- /dev/null
+++ b/packages/app/src/Pages/settings/Moderation.tsx
@@ -0,0 +1,58 @@
+import { unixNowMs } from "@snort/shared";
+import useLogin from "Hooks/useLogin";
+import { setAppData } from "Login";
+import { appendDedupe } from "SnortUtils";
+import { useState } from "react";
+import FormattedMessage from "Element/FormattedMessage";
+
+export function ModerationSettings() {
+ const login = useLogin();
+ const [muteWord, setMuteWord] = useState("");
+
+ function addMutedWord() {
+ login.appData ??= {
+ item: {
+ mutedWords: [],
+ },
+ timestamp: 0,
+ };
+ setAppData(
+ login,
+ {
+ ...login.appData.item,
+ mutedWords: appendDedupe(login.appData.item.mutedWords, [muteWord]),
+ },
+ unixNowMs(),
+ );
+ setMuteWord("");
+ }
+ return (
+ <>
+
+
+
+
+
+ setMuteWord(e.target.value)}
+ />
+
+
+
+
+ {login.appData.item.mutedWords.map(v => (
+
+ ))}
+
+ >
+ );
+}
diff --git a/packages/app/src/Pages/settings/Preferences.tsx b/packages/app/src/Pages/settings/Preferences.tsx
index d3f59d99..8991b4fd 100644
--- a/packages/app/src/Pages/settings/Preferences.tsx
+++ b/packages/app/src/Pages/settings/Preferences.tsx
@@ -30,6 +30,8 @@ export const AllLanguageCodes = [
"th",
"pt-BR",
"sw",
+ "nl",
+ "fi",
];
const PreferencesPage = () => {
@@ -126,6 +128,23 @@ const PreferencesPage = () => {
+
+
+
+
+
+
+
+
+
+
+ updatePreferences(login, { ...perf, telemetry: e.target.checked })}
+ />
+
+
@@ -188,6 +207,40 @@ const PreferencesPage = () => {
/>
+
+
+
+
+
+
+
+
+
+
+ updatePreferences(login, { ...perf, showBadges: e.target.checked })}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ updatePreferences(login, { ...perf, showStatus: e.target.checked })}
+ />
+
+
diff --git a/packages/app/src/Pages/settings/Profile.tsx b/packages/app/src/Pages/settings/Profile.tsx
index 68be332d..8a8a5a70 100644
--- a/packages/app/src/Pages/settings/Profile.tsx
+++ b/packages/app/src/Pages/settings/Profile.tsx
@@ -1,19 +1,19 @@
import "./Profile.css";
import { useEffect, useState } from "react";
-import { FormattedMessage } from "react-intl";
+import FormattedMessage from "Element/FormattedMessage";
import { useNavigate } from "react-router-dom";
import { mapEventToProfile } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import { System } from "index";
-import useEventPublisher from "Feed/EventPublisher";
+import useEventPublisher from "Hooks/useEventPublisher";
import { openFile } from "SnortUtils";
import useFileUpload from "Upload";
import AsyncButton from "Element/AsyncButton";
import { UserCache } from "Cache";
import useLogin from "Hooks/useLogin";
import Icon from "Icons/Icon";
-import Avatar from "Element/Avatar";
+import Avatar from "Element/User/Avatar";
export interface ProfileSettingsProps {
avatar?: boolean;
@@ -22,7 +22,7 @@ export interface ProfileSettingsProps {
export default function ProfileSettings(props: ProfileSettingsProps) {
const navigate = useNavigate();
- const { publicKey: id } = useLogin();
+ const { publicKey: id, readonly } = useLogin(s => ({ publicKey: s.publicKey, readonly: s.readonly }));
const user = useUserProfile(id ?? "");
const publisher = useEventPublisher();
const uploader = useFileUpload();
@@ -113,26 +113,48 @@ export default function ProfileSettings(props: ProfileSettingsProps) {
- setName(e.target.value)} />
+ setName(e.target.value)}
+ disabled={readonly}
+ />
-
+
- setWebsite(e.target.value)} />
+ setWebsite(e.target.value)}
+ disabled={readonly}
+ />
@@ -170,7 +198,7 @@ export default function ProfileSettings(props: ProfileSettingsProps) {
background: (banner?.length ?? 0) > 0 ? `no-repeat center/cover url("${banner}")` : undefined,
}}
className="banner">
-
setNewBanner()}>
+ setNewBanner()} disabled={readonly}>
@@ -178,7 +206,7 @@ export default function ProfileSettings(props: ProfileSettingsProps) {
{(props.avatar ?? true) && (
-
setNewAvatar()}>
+ setNewAvatar()} disabled={readonly}>
diff --git a/packages/app/src/Pages/settings/RelayInfo.tsx b/packages/app/src/Pages/settings/RelayInfo.tsx
index 6c5bca22..196dc47f 100644
--- a/packages/app/src/Pages/settings/RelayInfo.tsx
+++ b/packages/app/src/Pages/settings/RelayInfo.tsx
@@ -1,5 +1,5 @@
-import { FormattedMessage } from "react-intl";
-import ProfilePreview from "Element/ProfilePreview";
+import FormattedMessage from "Element/FormattedMessage";
+import ProfilePreview from "Element/User/ProfilePreview";
import useRelayState from "Feed/RelayState";
import { useNavigate, useParams } from "react-router-dom";
import { parseId, unwrap } from "SnortUtils";
diff --git a/packages/app/src/Pages/settings/Relays.tsx b/packages/app/src/Pages/settings/Relays.tsx
index 9470ed22..035d9f39 100644
--- a/packages/app/src/Pages/settings/Relays.tsx
+++ b/packages/app/src/Pages/settings/Relays.tsx
@@ -1,14 +1,17 @@
import { useMemo, useState } from "react";
-import { FormattedMessage } from "react-intl";
+import FormattedMessage from "Element/FormattedMessage";
+import { unixNowMs } from "@snort/shared";
-import { randomSample, unixNowMs } from "SnortUtils";
-import Relay from "Element/Relay";
-import useEventPublisher from "Feed/EventPublisher";
+import { randomSample } from "SnortUtils";
+import Relay from "Element/Relay/Relay";
+import useEventPublisher from "Hooks/useEventPublisher";
import { System } from "index";
import useLogin from "Hooks/useLogin";
import { setRelays } from "Login";
+import AsyncButton from "Element/AsyncButton";
import messages from "./messages";
+
const RelaySettingsPage = () => {
const publisher = useEventPublisher();
const login = useLogin();
@@ -89,9 +92,9 @@ const RelaySettingsPage = () => {
-
saveRelays()}>
+ saveRelays()} disabled={login.readonly}>
-
+
{addRelay()}
diff --git a/packages/app/src/Pages/settings/Root.css b/packages/app/src/Pages/settings/Root.css
index 5afcbcc6..434ebed1 100644
--- a/packages/app/src/Pages/settings/Root.css
+++ b/packages/app/src/Pages/settings/Root.css
@@ -10,7 +10,7 @@
}
.settings-nav > div {
- border: 1px solid var(--gray-superdark);
+ border: 1px solid var(--border-color);
}
.settings-nav > div.content {
@@ -26,7 +26,7 @@
grid-template-columns: 24px 1fr 24px;
align-items: center;
cursor: pointer;
- padding: 12px 16px;
+ padding: 12px 0px 12px 16px;
gap: 8px;
font-size: 16px;
font-weight: 600;
diff --git a/packages/app/src/Pages/settings/Root.tsx b/packages/app/src/Pages/settings/Root.tsx
index 2ac92d6f..8edab2b8 100644
--- a/packages/app/src/Pages/settings/Root.tsx
+++ b/packages/app/src/Pages/settings/Root.tsx
@@ -1,11 +1,10 @@
import "./Root.css";
import { useEffect, useMemo } from "react";
-import { FormattedMessage } from "react-intl";
+import FormattedMessage from "Element/FormattedMessage";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import Icon from "Icons/Icon";
import { LoginStore, logout } from "Login";
import useLogin from "Hooks/useLogin";
-import { unwrap } from "SnortUtils";
import { getCurrentSubscription } from "Subscription";
import messages from "./messages";
@@ -19,7 +18,7 @@ const SettingsIndex = () => {
const sub = getCurrentSubscription(LoginStore.allSubscriptions());
function handleLogout() {
- logout(unwrap(login.publicKey));
+ logout(login.id);
navigate("/");
}
@@ -52,6 +51,11 @@ const SettingsIndex = () => {
+
navigate("moderation")}>
+
+
+
+
navigate("handle")}>
diff --git a/packages/app/src/Pages/settings/WalletSettings.tsx b/packages/app/src/Pages/settings/WalletSettings.tsx
index 26540973..ca1f107e 100644
--- a/packages/app/src/Pages/settings/WalletSettings.tsx
+++ b/packages/app/src/Pages/settings/WalletSettings.tsx
@@ -1,6 +1,6 @@
import "./WalletSettings.css";
import LndLogo from "lnd-logo.png";
-import { FormattedMessage } from "react-intl";
+import FormattedMessage from "Element/FormattedMessage";
import { Link, RouteObject, useNavigate } from "react-router-dom";
import BlueWallet from "Icons/BlueWallet";
diff --git a/packages/app/src/Pages/settings/handle/LNAddress.tsx b/packages/app/src/Pages/settings/handle/LNAddress.tsx
index 562dc571..1f4d5fc0 100644
--- a/packages/app/src/Pages/settings/handle/LNAddress.tsx
+++ b/packages/app/src/Pages/settings/handle/LNAddress.tsx
@@ -4,7 +4,7 @@ import { LNURL } from "@snort/shared";
import { ApiHost } from "Const";
import AsyncButton from "Element/AsyncButton";
-import useEventPublisher from "Feed/EventPublisher";
+import useEventPublisher from "Hooks/useEventPublisher";
import SnortServiceProvider, { ManageHandle } from "Nip05/SnortServiceProvider";
export default function LNForwardAddress({ handle }: { handle: ManageHandle }) {
@@ -29,7 +29,7 @@ export default function LNForwardAddress({ handle }: { handle: ManageHandle }) {
setError(
formatMessage({
defaultMessage: "Invalid LNURL",
- })
+ }),
);
return;
}
diff --git a/packages/app/src/Pages/settings/handle/ListHandles.tsx b/packages/app/src/Pages/settings/handle/ListHandles.tsx
index b209710a..7677f72f 100644
--- a/packages/app/src/Pages/settings/handle/ListHandles.tsx
+++ b/packages/app/src/Pages/settings/handle/ListHandles.tsx
@@ -1,9 +1,9 @@
import { useEffect, useState } from "react";
-import { FormattedMessage } from "react-intl";
+import FormattedMessage from "Element/FormattedMessage";
import { Link, useNavigate } from "react-router-dom";
import { ApiHost } from "Const";
-import useEventPublisher from "Feed/EventPublisher";
+import useEventPublisher from "Hooks/useEventPublisher";
import SnortServiceProvider, { ManageHandle } from "Nip05/SnortServiceProvider";
export default function ListHandles() {
diff --git a/packages/app/src/Pages/settings/handle/TransferHandle.tsx b/packages/app/src/Pages/settings/handle/TransferHandle.tsx
index ba5b0907..d21249f2 100644
--- a/packages/app/src/Pages/settings/handle/TransferHandle.tsx
+++ b/packages/app/src/Pages/settings/handle/TransferHandle.tsx
@@ -1,6 +1,6 @@
import { ApiHost } from "Const";
import AsyncButton from "Element/AsyncButton";
-import useEventPublisher from "Feed/EventPublisher";
+import useEventPublisher from "Hooks/useEventPublisher";
import { ServiceError } from "Nip05/ServiceProvider";
import SnortServiceProvider, { ManageHandle } from "Nip05/SnortServiceProvider";
import { useState } from "react";
diff --git a/packages/app/src/Pages/settings/handle/index.tsx b/packages/app/src/Pages/settings/handle/index.tsx
index 5a60b380..c8531da1 100644
--- a/packages/app/src/Pages/settings/handle/index.tsx
+++ b/packages/app/src/Pages/settings/handle/index.tsx
@@ -1,4 +1,4 @@
-import { FormattedMessage } from "react-intl";
+import FormattedMessage from "Element/FormattedMessage";
import { Outlet, RouteObject, useNavigate } from "react-router-dom";
import ListHandles from "./ListHandles";
diff --git a/packages/app/src/Pages/settings/wallet/Cashu.tsx b/packages/app/src/Pages/settings/wallet/Cashu.tsx
index 5a2f4efa..e1a6d411 100644
--- a/packages/app/src/Pages/settings/wallet/Cashu.tsx
+++ b/packages/app/src/Pages/settings/wallet/Cashu.tsx
@@ -39,7 +39,7 @@ const ConnectCashu = () => {
setError(
formatMessage({
defaultMessage: "Unknown error",
- })
+ }),
);
}
}
diff --git a/packages/app/src/Pages/settings/wallet/LNC.tsx b/packages/app/src/Pages/settings/wallet/LNC.tsx
index d33fa9fe..64c66d21 100644
--- a/packages/app/src/Pages/settings/wallet/LNC.tsx
+++ b/packages/app/src/Pages/settings/wallet/LNC.tsx
@@ -32,7 +32,7 @@ const ConnectLNC = () => {
setError(
formatMessage({
defaultMessage: "Unknown error",
- })
+ }),
);
}
}
diff --git a/packages/app/src/Pages/settings/wallet/LNDHub.tsx b/packages/app/src/Pages/settings/wallet/LNDHub.tsx
index b33462ec..d2c20f44 100644
--- a/packages/app/src/Pages/settings/wallet/LNDHub.tsx
+++ b/packages/app/src/Pages/settings/wallet/LNDHub.tsx
@@ -37,7 +37,7 @@ const ConnectLNDHub = () => {
setError(
formatMessage({
defaultMessage: "Unknown error",
- })
+ }),
);
}
}
diff --git a/packages/app/src/Pages/settings/wallet/NWC.tsx b/packages/app/src/Pages/settings/wallet/NWC.tsx
index 921d6c59..41419615 100644
--- a/packages/app/src/Pages/settings/wallet/NWC.tsx
+++ b/packages/app/src/Pages/settings/wallet/NWC.tsx
@@ -37,7 +37,7 @@ const ConnectNostrWallet = () => {
setError(
formatMessage({
defaultMessage: "Unknown error",
- })
+ }),
);
}
}
diff --git a/packages/app/src/Pages/subscribe/ManageSubscription.tsx b/packages/app/src/Pages/subscribe/ManageSubscription.tsx
index 120d138d..b4b194c4 100644
--- a/packages/app/src/Pages/subscribe/ManageSubscription.tsx
+++ b/packages/app/src/Pages/subscribe/ManageSubscription.tsx
@@ -1,9 +1,9 @@
import { useEffect, useState } from "react";
-import { FormattedMessage } from "react-intl";
+import FormattedMessage from "Element/FormattedMessage";
import { Link, useNavigate } from "react-router-dom";
import PageSpinner from "Element/PageSpinner";
-import useEventPublisher from "Feed/EventPublisher";
+import useEventPublisher from "Hooks/useEventPublisher";
import SnortApi, { Subscription, SubscriptionError } from "SnortApi";
import { mapSubscriptionErrorCode } from ".";
import SubscriptionCard from "./SubscriptionCard";
@@ -33,7 +33,7 @@ export default function ManageSubscriptionPage() {
return
;
}
return (
-
+
@@ -41,7 +41,7 @@ export default function ManageSubscriptionPage() {
))}
{subs.length !== 0 && (
-
navigate("/subscribe")}>
+ navigate("/subscribe")}>
)}
diff --git a/packages/app/src/Pages/subscribe/SubscriptionCard.tsx b/packages/app/src/Pages/subscribe/SubscriptionCard.tsx
index 87c872ab..610cd39f 100644
--- a/packages/app/src/Pages/subscribe/SubscriptionCard.tsx
+++ b/packages/app/src/Pages/subscribe/SubscriptionCard.tsx
@@ -5,11 +5,11 @@ import SnortApi, { Subscription, SubscriptionError } from "SnortApi";
import { mapPlanName, mapSubscriptionErrorCode } from ".";
import AsyncButton from "Element/AsyncButton";
import Icon from "Icons/Icon";
-import useEventPublisher from "Feed/EventPublisher";
+import useEventPublisher from "Hooks/useEventPublisher";
import SendSats from "Element/SendSats";
import Nip5Service from "Element/Nip5Service";
import { SnortNostrAddressService } from "Pages/NostrAddressPage";
-import Nip05 from "Element/Nip05";
+import Nip05 from "Element/User/Nip05";
export default function SubscriptionCard({ sub }: { sub: Subscription }) {
const publisher = useEventPublisher();
@@ -62,7 +62,7 @@ export default function SubscriptionCard({ sub }: { sub: Subscription }) {
return (
<>
-
+
{mapPlanName(sub.type)}
diff --git a/packages/app/src/Pages/subscribe/index.tsx b/packages/app/src/Pages/subscribe/index.tsx
index 48acf99a..9167956f 100644
--- a/packages/app/src/Pages/subscribe/index.tsx
+++ b/packages/app/src/Pages/subscribe/index.tsx
@@ -1,14 +1,14 @@
import "./index.css";
import { useState } from "react";
-import { FormattedMessage } from "react-intl";
+import FormattedMessage from "Element/FormattedMessage";
import { RouteObject } from "react-router-dom";
import { formatShort } from "Number";
import { LockedFeatures, Plans, SubscriptionType } from "Subscription";
import ManageSubscriptionPage from "Pages/subscribe/ManageSubscription";
import AsyncButton from "Element/AsyncButton";
-import useEventPublisher from "Feed/EventPublisher";
+import useEventPublisher from "Hooks/useEventPublisher";
import SnortApi, { SubscriptionError, SubscriptionErrorCode } from "SnortApi";
import SendSats from "Element/SendSats";
diff --git a/packages/app/src/SnortApi.ts b/packages/app/src/SnortApi.ts
index a69f71e6..a94ad9c7 100644
--- a/packages/app/src/SnortApi.ts
+++ b/packages/app/src/SnortApi.ts
@@ -84,11 +84,15 @@ export default class SnortApi {
return this.#getJson
(`api/v1/preview?url=${encodeURIComponent(url)}`);
}
+ onChainDonation() {
+ return this.#getJson<{ address: string }>("p/on-chain");
+ }
+
async #getJsonAuthd(
path: string,
method?: "GET" | string,
body?: { [key: string]: string },
- headers?: { [key: string]: string }
+ headers?: { [key: string]: string },
): Promise {
if (!this.#publisher) {
throw new Error("Publisher not set");
@@ -110,7 +114,7 @@ export default class SnortApi {
path: string,
method?: "GET" | string,
body?: { [key: string]: string },
- headers?: { [key: string]: string }
+ headers?: { [key: string]: string },
): Promise {
const rsp = await fetch(`${this.#url}${path}`, {
method: method,
diff --git a/packages/app/src/SnortUtils/index.ts b/packages/app/src/SnortUtils/index.ts
index 33ca7af6..65062b2a 100644
--- a/packages/app/src/SnortUtils/index.ts
+++ b/packages/app/src/SnortUtils/index.ts
@@ -50,7 +50,7 @@ export async function openFile(): Promise {
}
}, 300);
},
- { once: true }
+ { once: true },
);
});
}
@@ -173,14 +173,6 @@ export function getAllReactions(notes: readonly TaggedNostrEvent[] | undefined,
return notes?.filter(a => a.kind === (kind ?? a.kind) && a.tags.some(a => a[0] === "e" && ids.includes(a[1]))) || [];
}
-export function unixNow() {
- return Math.floor(unixNowMs() / 1000);
-}
-
-export function unixNowMs() {
- return new Date().getTime();
-}
-
export function deepClone(obj: T) {
if ("structuredClone" in window) {
return structuredClone(obj);
@@ -217,7 +209,7 @@ export function dedupeByPubkey(events: TaggedNostrEvent[]) {
list: [...list, ev],
};
},
- { list: [], seen: new Set([]) }
+ { list: [], seen: new Set([]) },
);
return deduped.list as TaggedNostrEvent[];
}
@@ -234,7 +226,7 @@ export function dedupeById(events: Array) {
list: [...list, ev],
};
},
- { list: [], seen: new Set([]) }
+ { list: [], seen: new Set([]) },
);
return deduped.list as Array;
}
@@ -495,7 +487,7 @@ export function kvToObject(o: string, sep?: string) {
return [match[1], match[2]];
}
return [];
- })
+ }),
) as T;
}
diff --git a/packages/app/src/State/NoteCreator.ts b/packages/app/src/State/NoteCreator.ts
deleted file mode 100644
index ccde3617..00000000
--- a/packages/app/src/State/NoteCreator.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-import { createSlice, PayloadAction } from "@reduxjs/toolkit";
-import { NostrEvent, TaggedNostrEvent } from "@snort/system";
-
-interface NoteCreatorStore {
- show: boolean;
- note: string;
- error: string;
- active: boolean;
- preview?: NostrEvent;
- replyTo?: TaggedNostrEvent;
- showAdvanced: boolean;
- selectedCustomRelays: false | Array;
- zapForward: string;
- sensitive: string;
- pollOptions?: Array;
- otherEvents: Array;
-}
-
-const InitState: NoteCreatorStore = {
- show: false,
- note: "",
- error: "",
- active: false,
- showAdvanced: false,
- selectedCustomRelays: false,
- zapForward: "",
- sensitive: "",
- otherEvents: [],
-};
-
-const NoteCreatorSlice = createSlice({
- name: "NoteCreator",
- initialState: InitState,
- reducers: {
- setShow: (state, action: PayloadAction) => {
- state.show = action.payload;
- },
- setNote: (state, action: PayloadAction) => {
- state.note = action.payload;
- },
- setError: (state, action: PayloadAction) => {
- state.error = action.payload;
- },
- setActive: (state, action: PayloadAction) => {
- state.active = action.payload;
- },
- setPreview: (state, action: PayloadAction) => {
- state.preview = action.payload;
- },
- setReplyTo: (state, action: PayloadAction) => {
- state.replyTo = action.payload;
- },
- setShowAdvanced: (state, action: PayloadAction) => {
- state.showAdvanced = action.payload;
- },
- setSelectedCustomRelays: (state, action: PayloadAction>) => {
- state.selectedCustomRelays = action.payload;
- },
- setZapForward: (state, action: PayloadAction) => {
- state.zapForward = action.payload;
- },
- setSensitive: (state, action: PayloadAction) => {
- state.sensitive = action.payload;
- },
- setPollOptions: (state, action: PayloadAction | undefined>) => {
- state.pollOptions = action.payload;
- },
- setOtherEvents: (state, action: PayloadAction>) => {
- state.otherEvents = action.payload;
- },
- reset: () => InitState,
- },
-});
-
-export const {
- setShow,
- setNote,
- setError,
- setActive,
- setPreview,
- setReplyTo,
- setShowAdvanced,
- setSelectedCustomRelays,
- setZapForward,
- setSensitive,
- setPollOptions,
- setOtherEvents,
- reset,
-} = NoteCreatorSlice.actions;
-
-export const reducer = NoteCreatorSlice.reducer;
diff --git a/packages/app/src/State/NoteCreator.tsx b/packages/app/src/State/NoteCreator.tsx
new file mode 100644
index 00000000..b2b84e6c
--- /dev/null
+++ b/packages/app/src/State/NoteCreator.tsx
@@ -0,0 +1,94 @@
+import { ExternalStore } from "@snort/shared";
+import { NostrEvent, TaggedNostrEvent } from "@snort/system";
+import { ZapTarget } from "Zapper";
+import { useSyncExternalStore } from "react";
+import { useSyncExternalStoreWithSelector } from "use-sync-external-store/with-selector";
+
+interface NoteCreatorDataSnapshot {
+ show: boolean;
+ note: string;
+ error: string;
+ active: boolean;
+ advanced: boolean;
+ preview?: NostrEvent;
+ replyTo?: TaggedNostrEvent;
+ selectedCustomRelays?: Array;
+ zapSplits?: Array;
+ sensitive?: string;
+ pollOptions?: Array;
+ otherEvents?: Array;
+ reset: () => void;
+ update: (fn: (v: NoteCreatorDataSnapshot) => void) => void;
+}
+
+class NoteCreatorStore extends ExternalStore {
+ #data: NoteCreatorDataSnapshot;
+
+ constructor() {
+ super();
+ this.#data = {
+ show: false,
+ note: "",
+ error: "",
+ active: false,
+ advanced: false,
+ reset: () => {
+ this.#reset(this.#data);
+ this.notifyChange(this.#data);
+ },
+ update: (fn: (v: NoteCreatorDataSnapshot) => void) => {
+ fn(this.#data);
+ this.notifyChange(this.#data);
+ },
+ };
+ }
+
+ #reset(d: NoteCreatorDataSnapshot) {
+ d.show = false;
+ d.note = "";
+ d.error = "";
+ d.active = false;
+ d.advanced = false;
+ d.preview = undefined;
+ d.replyTo = undefined;
+ d.selectedCustomRelays = undefined;
+ d.zapSplits = undefined;
+ d.sensitive = undefined;
+ d.pollOptions = undefined;
+ d.otherEvents = undefined;
+ }
+
+ takeSnapshot(): NoteCreatorDataSnapshot {
+ const sn = {
+ ...this.#data,
+ reset: () => {
+ this.#reset(this.#data);
+ },
+ update: (fn: (v: NoteCreatorDataSnapshot) => void) => {
+ fn(this.#data);
+ this.notifyChange(this.#data);
+ },
+ } as NoteCreatorDataSnapshot;
+ return sn;
+ }
+}
+
+const NoteCreatorState = new NoteCreatorStore();
+
+export function useNoteCreator(
+ selector?: (v: NoteCreatorDataSnapshot) => T,
+) {
+ if (selector) {
+ return useSyncExternalStoreWithSelector(
+ c => NoteCreatorState.hook(c),
+ () => NoteCreatorState.snapshot(),
+ undefined,
+ selector,
+ );
+ } else {
+ return useSyncExternalStore(
+ c => NoteCreatorState.hook(c),
+ () => NoteCreatorState.snapshot() as T,
+ );
+ }
+}
diff --git a/packages/app/src/State/ReBroadcast.ts b/packages/app/src/State/ReBroadcast.ts
deleted file mode 100644
index 23cc253b..00000000
--- a/packages/app/src/State/ReBroadcast.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { createSlice, PayloadAction } from "@reduxjs/toolkit";
-import { NostrEvent } from "@snort/system";
-
-interface ReBroadcastStore {
- show: boolean;
- selectedCustomRelays: false | Array