mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-04-02 08:58:36 +02:00
Merge branch 'cleanup' into next
This commit is contained in:
commit
b4878c5fce
5
.changeset/cuddly-bikes-drop.md
Normal file
5
.changeset/cuddly-bikes-drop.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Create about tab in profile view
|
5
.changeset/great-ties-compare.md
Normal file
5
.changeset/great-ties-compare.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Virtulize following and followers tabs in profile view
|
5
.changeset/nervous-otters-wink.md
Normal file
5
.changeset/nervous-otters-wink.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add profile stats from nostr.band
|
5
.changeset/soft-yaks-lie.md
Normal file
5
.changeset/soft-yaks-lie.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": patch
|
||||
---
|
||||
|
||||
Fix redirect not working on login view
|
@ -1,5 +1,5 @@
|
||||
describe("Profile view", () => {
|
||||
it("should load user on single relay", () => {
|
||||
it("should load a rss feed profile", () => {
|
||||
cy.visit(
|
||||
"#/u/nprofile1qqsp6hxqjatvxtesgszs8aee0fcjccxa3ef3mzjva4uv2yr5lucp6jcpzemhxue69uhhyumnd3shjtnwdaehgu3wd4hk2s8c5un"
|
||||
);
|
||||
@ -7,4 +7,10 @@ describe("Profile view", () => {
|
||||
cy.contains("fjsmu");
|
||||
cy.contains("https://rsshub.app/pixiv/user/7569500@rsslay.nostr.moe");
|
||||
});
|
||||
|
||||
it("should load a rss feed fiatjef", () => {
|
||||
cy.visit("#/u/npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft");
|
||||
|
||||
cy.contains("npub1l2vyh...3afqutajft");
|
||||
});
|
||||
});
|
||||
|
@ -30,16 +30,19 @@
|
||||
"react-router-dom": "^6.11.2",
|
||||
"react-singleton-hook": "^4.0.1",
|
||||
"react-use": "^17.4.0",
|
||||
"react-virtualized-auto-sizer": "^1.0.20",
|
||||
"react-window": "^1.8.9",
|
||||
"webln": "^0.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/cypress": "^9.0.0",
|
||||
"cypress": "^12.13.0",
|
||||
"@changesets/cli": "^2.26.1",
|
||||
"@testing-library/cypress": "^9.0.0",
|
||||
"@types/identicon.js": "^2.3.1",
|
||||
"@types/react": "^18.2.7",
|
||||
"@types/react-dom": "^18.2.4",
|
||||
"@types/react-window": "^1.8.5",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"cypress": "^12.13.0",
|
||||
"prettier": "^2.8.8",
|
||||
"typescript": "^5.0.4",
|
||||
"vite": "^4.3.8",
|
||||
|
@ -34,6 +34,7 @@ import appSettings from "./services/app-settings";
|
||||
import UserMediaTab from "./views/user/media";
|
||||
import ToolsHomeView from "./views/tools";
|
||||
import Nip19ToolsView from "./views/tools/nip19";
|
||||
import UserAboutTab from "./views/user/about";
|
||||
// code split search view because QrScanner library is 400kB
|
||||
const SearchView = React.lazy(() => import("./views/search"));
|
||||
|
||||
@ -64,7 +65,8 @@ const router = createHashRouter([
|
||||
path: "/u/:pubkey",
|
||||
element: <UserView />,
|
||||
children: [
|
||||
{ path: "", element: <UserNotesTab /> },
|
||||
{ path: "", element: <UserAboutTab /> },
|
||||
{ path: "about", element: <UserAboutTab /> },
|
||||
{ path: "notes", element: <UserNotesTab /> },
|
||||
{ path: "media", element: <UserMediaTab /> },
|
||||
{ path: "zaps", element: <UserZapsTab /> },
|
||||
|
@ -43,7 +43,7 @@ export function renderVideoUrl(match: URL) {
|
||||
return <video src={match.toString()} controls style={{ maxWidth: "30rem", maxHeight: "20rem" }} />;
|
||||
}
|
||||
|
||||
export function renderDefaultUrl(match: URL) {
|
||||
export function renderGenericUrl(match: URL) {
|
||||
return (
|
||||
<Link color="blue.500" href={match.toString()} target="_blank" isExternal>
|
||||
{match.toString()}
|
||||
|
@ -9,7 +9,7 @@ import { Link as RouterLink } from "react-router-dom";
|
||||
|
||||
// nostr:nevent1qqsthg2qlxp9l7egtwa92t8lusm7pjknmjwa75ctrrpcjyulr9754fqpz3mhxue69uhhyetvv9ujuerpd46hxtnfduq36amnwvaz7tmwdaehgu3dwp6kytnhv4kxcmmjv3jhytnwv46q2qg5q9
|
||||
// nostr:nevent1qqsq3wc73lqxd70lg43m5rul57d4mhcanttjat56e30yx5zla48qzlspz9mhxue69uhkummnw3e82efwvdhk6qgdwaehxw309ahx7uewd3hkcq5hsum
|
||||
export function embedNostrLinks(content: EmbedableContent, event: NostrEvent | DraftNostrEvent) {
|
||||
export function embedNostrLinks(content: EmbedableContent) {
|
||||
return embedJSX(content, {
|
||||
name: "nostr-link",
|
||||
regexp: /(nostr:|@)?((npub|note|nprofile|nevent)1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58,})/i,
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { replaceDomain } from "../../helpers/url";
|
||||
import appSettings from "../../services/app-settings";
|
||||
import { renderDefaultUrl } from "./common";
|
||||
import { renderGenericUrl } from "./common";
|
||||
|
||||
// copied from https://github.com/SimonBrazell/privacy-redirect/blob/master/src/assets/javascripts/helpers/reddit.js
|
||||
const REDDIT_DOMAINS = [
|
||||
@ -21,5 +21,5 @@ export function renderRedditUrl(match: URL) {
|
||||
const { redditRedirect } = appSettings.value;
|
||||
const fixed = redditRedirect ? replaceDomain(match, redditRedirect) : match;
|
||||
|
||||
return renderDefaultUrl(fixed);
|
||||
return renderGenericUrl(fixed);
|
||||
}
|
||||
|
@ -1,21 +1,15 @@
|
||||
import { replaceDomain } from "../../helpers/url";
|
||||
import appSettings from "../../services/app-settings";
|
||||
import { TweetEmbed } from "../tweet-embed";
|
||||
import { renderDefaultUrl } from "./common";
|
||||
import { renderGenericUrl } from "./common";
|
||||
|
||||
// copied from https://github.com/SimonBrazell/privacy-redirect/blob/master/src/assets/javascripts/helpers/twitter.js
|
||||
export const TWITTER_DOMAINS = [
|
||||
"twitter.com",
|
||||
"www.twitter.com",
|
||||
"mobile.twitter.com",
|
||||
"pbs.twimg.com",
|
||||
"video.twimg.com",
|
||||
];
|
||||
export const TWITTER_DOMAINS = ["twitter.com", "www.twitter.com", "mobile.twitter.com", "pbs.twimg.com"];
|
||||
|
||||
export function renderTwitterUrl(match: URL) {
|
||||
if (!TWITTER_DOMAINS.includes(match.hostname)) return null;
|
||||
|
||||
const { twitterRedirect } = appSettings.value;
|
||||
if (twitterRedirect) return renderDefaultUrl(replaceDomain(match, twitterRedirect));
|
||||
if (twitterRedirect) return renderGenericUrl(replaceDomain(match, twitterRedirect));
|
||||
else return <TweetEmbed href={match.toString()} conversation={false} />;
|
||||
}
|
||||
|
@ -247,3 +247,15 @@ export const ToolsIcon = createIcon({
|
||||
d: "M5.32894 3.27158C6.56203 2.8332 7.99181 3.10749 8.97878 4.09446C10.0997 5.21537 10.3014 6.90741 9.58382 8.23385L20.2925 18.9437L18.8783 20.3579L8.16933 9.64875C6.84277 10.3669 5.1502 10.1654 4.02903 9.04421C3.04178 8.05696 2.76761 6.62665 3.20652 5.39332L5.44325 7.63C6.02903 8.21578 6.97878 8.21578 7.56457 7.63C8.15035 7.04421 8.15035 6.09446 7.56457 5.50868L5.32894 3.27158ZM15.6963 5.15512L18.8783 3.38736L20.2925 4.80157L18.5247 7.98355L16.757 8.3371L14.6356 10.4584L13.2214 9.04421L15.3427 6.92289L15.6963 5.15512ZM8.97878 13.2868L10.393 14.7011L5.08969 20.0044C4.69917 20.3949 4.066 20.3949 3.67548 20.0044C3.31285 19.6417 3.28695 19.0699 3.59777 18.6774L3.67548 18.5902L8.97878 13.2868Z",
|
||||
defaultProps,
|
||||
});
|
||||
|
||||
export const EditIcon = createIcon({
|
||||
displayName: "EditIcon",
|
||||
d: "M6.41421 15.89L16.5563 5.74786L15.1421 4.33365L5 14.4758V15.89H6.41421ZM7.24264 17.89H3V13.6474L14.435 2.21233C14.8256 1.8218 15.4587 1.8218 15.8492 2.21233L18.6777 5.04075C19.0682 5.43128 19.0682 6.06444 18.6777 6.45497L7.24264 17.89ZM3 19.89H21V21.89H3V19.89Z",
|
||||
defaultProps,
|
||||
});
|
||||
|
||||
export const AtIcon = createIcon({
|
||||
displayName: "AtIcon",
|
||||
d: "M20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20C13.6418 20 15.1681 19.5054 16.4381 18.6571L17.5476 20.3214C15.9602 21.3818 14.0523 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12V13.5C22 15.433 20.433 17 18.5 17C17.2958 17 16.2336 16.3918 15.6038 15.4659C14.6942 16.4115 13.4158 17 12 17C9.23858 17 7 14.7614 7 12C7 9.23858 9.23858 7 12 7C13.1258 7 14.1647 7.37209 15.0005 8H17V13.5C17 14.3284 17.6716 15 18.5 15C19.3284 15 20 14.3284 20 13.5V12ZM12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9Z",
|
||||
defaultProps,
|
||||
});
|
||||
|
@ -11,7 +11,7 @@ import {
|
||||
embedNostrHashtags,
|
||||
renderWavlakeUrl,
|
||||
renderYoutubeUrl,
|
||||
renderDefaultUrl,
|
||||
renderGenericUrl,
|
||||
renderImageUrl,
|
||||
renderTwitterUrl,
|
||||
renderAppleMusicUrl,
|
||||
@ -37,14 +37,14 @@ function buildContents(event: NostrEvent | DraftNostrEvent, trusted = false) {
|
||||
renderTidalUrl,
|
||||
renderImageUrl,
|
||||
renderVideoUrl,
|
||||
renderDefaultUrl,
|
||||
renderGenericUrl,
|
||||
]);
|
||||
|
||||
// bitcoin
|
||||
content = embedLightningInvoice(content);
|
||||
|
||||
// nostr
|
||||
content = embedNostrLinks(content, event);
|
||||
content = embedNostrLinks(content);
|
||||
content = embedNostrMentions(content, event);
|
||||
content = embedNostrHashtags(content, event);
|
||||
|
||||
|
@ -18,7 +18,7 @@ import { useUserMetadata } from "../../hooks/use-user-metadata";
|
||||
import accountService from "../../services/account";
|
||||
import { AddIcon } from "../icons";
|
||||
import { UserAvatar } from "../user-avatar";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
function AccountItem({ pubkey }: { pubkey: string }) {
|
||||
const metadata = useUserMetadata(pubkey, []);
|
||||
@ -53,6 +53,7 @@ export function AccountSwitcherList() {
|
||||
const navigate = useNavigate();
|
||||
const accounts = useSubject(accountService.accounts);
|
||||
const current = useSubject(accountService.current);
|
||||
const location = useLocation();
|
||||
|
||||
const otherAccounts = accounts.filter((acc) => acc.pubkey !== current?.pubkey);
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { Box, Button, LinkBox, Text } from "@chakra-ui/react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { Link as RouterLink, useLocation } from "react-router-dom";
|
||||
import { UserAvatar } from "../user-avatar";
|
||||
import { useUserMetadata } from "../../hooks/use-user-metadata";
|
||||
import { Bech32Prefix, normalizeToBech32 } from "../../helpers/nip19";
|
||||
@ -29,6 +29,7 @@ function ProfileButton() {
|
||||
|
||||
export default function ProfileLink() {
|
||||
const account = useCurrentAccount();
|
||||
const location = useLocation();
|
||||
|
||||
if (account) return <ProfileButton />;
|
||||
else
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Spinner, Tooltip } from "@chakra-ui/react";
|
||||
import { Spinner, Text, Tooltip } from "@chakra-ui/react";
|
||||
import { useDnsIdentity } from "../hooks/use-dns-identity";
|
||||
import { useUserMetadata } from "../hooks/use-user-metadata";
|
||||
import { VerificationFailed, VerificationMissing, VerifiedIcon } from "./icons";
|
||||
@ -29,8 +29,8 @@ export const UserDnsIdentityIcon = ({ pubkey, onlyIcon }: { pubkey: string; only
|
||||
return <Tooltip label={metadata.nip05}>{renderIcon()}</Tooltip>;
|
||||
}
|
||||
return (
|
||||
<span>
|
||||
<Text as="span" whiteSpace="nowrap">
|
||||
{metadata.nip05} {renderIcon()}
|
||||
</span>
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
@ -2,8 +2,9 @@ import { Button, ButtonProps } from "@chakra-ui/react";
|
||||
import { useCurrentAccount } from "../hooks/use-current-account";
|
||||
import useSubject from "../hooks/use-subject";
|
||||
import clientFollowingService from "../services/client-following";
|
||||
import clientRelaysService from "../services/client-relays";
|
||||
import { useUserContacts } from "../hooks/use-user-contacts";
|
||||
import { useReadRelayUrls } from "../hooks/use-client-relays";
|
||||
import { useAdditionalRelayContext } from "../providers/additional-relay-context";
|
||||
|
||||
export const UserFollowButton = ({
|
||||
pubkey,
|
||||
@ -13,9 +14,11 @@ export const UserFollowButton = ({
|
||||
const following = useSubject(clientFollowingService.following) ?? [];
|
||||
const savingDraft = useSubject(clientFollowingService.savingDraft);
|
||||
|
||||
const isFollowing = following.some((t) => t[1] === pubkey);
|
||||
const readRelays = useReadRelayUrls(useAdditionalRelayContext());
|
||||
const userContacts = useUserContacts(pubkey, readRelays);
|
||||
|
||||
const userContacts = useUserContacts(pubkey, clientRelaysService.getReadUrls());
|
||||
const isFollowing = following.some((t) => t[1] === pubkey);
|
||||
const isFollowingMe = account && userContacts?.contacts.includes(account.pubkey);
|
||||
|
||||
const toggleFollow = async () => {
|
||||
if (isFollowing) {
|
||||
@ -29,13 +32,13 @@ export const UserFollowButton = ({
|
||||
|
||||
return (
|
||||
<Button
|
||||
colorScheme="brand"
|
||||
colorScheme={isFollowing ? "orange" : "brand"}
|
||||
{...props}
|
||||
isLoading={savingDraft}
|
||||
onClick={toggleFollow}
|
||||
isDisabled={account?.readonly ?? true}
|
||||
>
|
||||
{isFollowing ? "Unfollow" : account && userContacts?.contacts.includes(account.pubkey) ? "Follow Back" : "Follow"}
|
||||
{isFollowing ? "Unfollow" : isFollowingMe ? "Follow Back" : "Follow"}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
@ -22,6 +22,9 @@ export function parseKind0Event(event: NostrEvent): Kind0ParsedContent {
|
||||
// ensure nip05 is a string
|
||||
if (metadata.nip05 && typeof metadata.nip05 !== "string") metadata.nip05 = String(metadata.nip05);
|
||||
|
||||
// fix user website
|
||||
if (metadata.website) metadata.website = fixWebsiteUrl(metadata.website);
|
||||
|
||||
return metadata;
|
||||
} catch (e) {}
|
||||
return {};
|
||||
|
90
src/services/user-trusted-stats.ts
Normal file
90
src/services/user-trusted-stats.ts
Normal file
@ -0,0 +1,90 @@
|
||||
export type NostrBandProfileStats = {
|
||||
pubkey: string;
|
||||
pub_note_count: number;
|
||||
pub_post_count: number;
|
||||
pub_reply_count: number;
|
||||
pub_reaction_count: number;
|
||||
pub_repost_count: number;
|
||||
pub_report_count: number;
|
||||
pub_badge_definition_count: number;
|
||||
pub_long_note_count: number;
|
||||
pub_note_ref_event_count: number;
|
||||
pub_note_ref_pubkey_count: number;
|
||||
pub_reaction_ref_event_count: number;
|
||||
pub_reaction_ref_pubkey_count: number;
|
||||
pub_repost_ref_event_count: number;
|
||||
pub_repost_ref_pubkey_count: number;
|
||||
pub_report_ref_event_count: number;
|
||||
pub_report_ref_pubkey_count: number;
|
||||
pub_mute_ref_pubkey_count: number;
|
||||
pub_bookmark_ref_event_count: number;
|
||||
pub_badge_award_ref_pubkey_count: number;
|
||||
pub_profile_badge_ref_event_count: number;
|
||||
pub_following_pubkey_count: number;
|
||||
reaction_count: number;
|
||||
reaction_pubkey_count: number;
|
||||
repost_count: number;
|
||||
repost_pubkey_count: number;
|
||||
reply_count: number;
|
||||
reply_pubkey_count: number;
|
||||
report_count: number;
|
||||
report_pubkey_count: number;
|
||||
mute_pubkey_count: number;
|
||||
followers_pubkey_count: number;
|
||||
zaps_sent?: {
|
||||
count: number;
|
||||
zapper_count: number;
|
||||
target_event_count: number;
|
||||
target_pubkey_count: number;
|
||||
provider_count: number;
|
||||
msats: number;
|
||||
min_msats: number;
|
||||
max_msats: number;
|
||||
avg_msats: number;
|
||||
median_msats: number;
|
||||
};
|
||||
zaps_received?: {
|
||||
count: number;
|
||||
zapper_count: number;
|
||||
target_event_count: number;
|
||||
target_pubkey_count: number;
|
||||
provider_count: number;
|
||||
msats: number;
|
||||
min_msats: number;
|
||||
max_msats: number;
|
||||
avg_msats: number;
|
||||
median_msats: number;
|
||||
};
|
||||
};
|
||||
|
||||
class UserTrustedStatsService {
|
||||
private userStats = new Map<string, NostrBandProfileStats>();
|
||||
|
||||
async fetchUserStats(pubkey: string) {
|
||||
try {
|
||||
const stats = await fetch(`https://api.nostr.band/v0/stats/profile/${pubkey}`).then(
|
||||
(res) => res.json() as Promise<{ stats: Record<string, NostrBandProfileStats> }>
|
||||
);
|
||||
|
||||
if (stats?.stats[pubkey]) {
|
||||
this.userStats.set(pubkey, stats?.stats[pubkey]);
|
||||
return stats?.stats[pubkey];
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
private dedupe = new Map<string, Promise<NostrBandProfileStats | undefined>>();
|
||||
async getUserStats(pubkey: string, alwaysFetch = false) {
|
||||
if (this.userStats.has(pubkey) && !alwaysFetch) return this.userStats.get(pubkey)!;
|
||||
|
||||
if (this.dedupe.has(pubkey)) this.dedupe.get(pubkey)!;
|
||||
const p = this.fetchUserStats(pubkey);
|
||||
this.dedupe.set(pubkey, p);
|
||||
p.then(() => this.dedupe.delete(pubkey));
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
const userTrustedStatsService = new UserTrustedStatsService();
|
||||
|
||||
export default userTrustedStatsService;
|
@ -18,7 +18,7 @@ import directMessagesService, { getMessageRecipient } from "../../services/direc
|
||||
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
|
||||
import DecryptPlaceholder from "./decrypt-placeholder";
|
||||
import { EmbedableContent, embedUrls } from "../../helpers/embeds";
|
||||
import { embedNostrLinks, renderDefaultUrl, renderImageUrl, renderVideoUrl } from "../../components/embed-types";
|
||||
import { embedNostrLinks, renderGenericUrl, renderImageUrl, renderVideoUrl } from "../../components/embed-types";
|
||||
import RequireCurrentAccount from "../../providers/require-current-account";
|
||||
|
||||
function MessageContent({ event, text }: { event: NostrEvent; text: string }) {
|
||||
@ -26,7 +26,7 @@ function MessageContent({ event, text }: { event: NostrEvent; text: string }) {
|
||||
|
||||
content = embedNostrLinks(content, event);
|
||||
|
||||
content = embedUrls(content, [renderImageUrl, renderVideoUrl, renderDefaultUrl]);
|
||||
content = embedUrls(content, [renderImageUrl, renderVideoUrl, renderGenericUrl]);
|
||||
|
||||
return <Box whiteSpace="pre-wrap">{content}</Box>;
|
||||
}
|
||||
|
248
src/views/user/about.tsx
Normal file
248
src/views/user/about.tsx
Normal file
@ -0,0 +1,248 @@
|
||||
import React from "react";
|
||||
import { useNavigate, useOutletContext } from "react-router-dom";
|
||||
import moment from "moment";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionButton,
|
||||
AccordionIcon,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
Box,
|
||||
Flex,
|
||||
IconButton,
|
||||
Image,
|
||||
Link,
|
||||
Stat,
|
||||
StatGroup,
|
||||
StatHelpText,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
Text,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react";
|
||||
import { useAsync } from "react-use";
|
||||
import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
|
||||
import { useUserMetadata } from "../../hooks/use-user-metadata";
|
||||
import { embedNostrLinks, renderGenericUrl } from "../../components/embed-types";
|
||||
import { EmbedableContent, embedUrls } from "../../helpers/embeds";
|
||||
import { useCurrentAccount } from "../../hooks/use-current-account";
|
||||
import { ArrowDownSIcon, ArrowUpSIcon, AtIcon, ExternalLinkIcon, KeyIcon } from "../../components/icons";
|
||||
import { normalizeToBech32 } from "../../helpers/nip19";
|
||||
import { Bech32Prefix } from "../../helpers/nip19";
|
||||
import { truncatedId } from "../../helpers/nostr-event";
|
||||
import { CopyIconButton } from "../../components/copy-icon-button";
|
||||
import { QrIconButton } from "./components/share-qr-button";
|
||||
import { UserDnsIdentityIcon } from "../../components/user-dns-identity-icon";
|
||||
import { useUserContacts } from "../../hooks/use-user-contacts";
|
||||
import { convertTimestampToDate } from "../../helpers/date";
|
||||
import userTrustedStatsService from "../../services/user-trusted-stats";
|
||||
import { readablizeSats } from "../../helpers/bolt11";
|
||||
|
||||
function buildDescriptionContent(description: string) {
|
||||
let content: EmbedableContent = [description.trim()];
|
||||
|
||||
content = embedNostrLinks(content);
|
||||
content = embedUrls(content, [renderGenericUrl]);
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
export default function UserAboutTab() {
|
||||
const navigate = useNavigate();
|
||||
const expanded = useDisclosure();
|
||||
const { pubkey } = useOutletContext() as { pubkey: string };
|
||||
const contextRelays = useAdditionalRelayContext();
|
||||
|
||||
const metadata = useUserMetadata(pubkey, contextRelays);
|
||||
const contacts = useUserContacts(pubkey, contextRelays);
|
||||
const npub = normalizeToBech32(pubkey, Bech32Prefix.Pubkey);
|
||||
|
||||
const { value: stats } = useAsync(() => userTrustedStatsService.getUserStats(pubkey), [pubkey]);
|
||||
|
||||
const account = useCurrentAccount();
|
||||
const isSelf = pubkey === account?.pubkey;
|
||||
|
||||
const aboutContent = metadata?.about && buildDescriptionContent(metadata?.about);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
overflowY="auto"
|
||||
overflowX="hidden"
|
||||
direction="column"
|
||||
gap="2"
|
||||
pt={metadata?.banner ? 0 : "2"}
|
||||
pb="8"
|
||||
h="full"
|
||||
>
|
||||
{metadata?.banner && (
|
||||
<Box
|
||||
pt={!expanded.isOpen ? "20vh" : 0}
|
||||
px={!expanded.isOpen ? "2" : 0}
|
||||
pb={!expanded.isOpen ? "4" : 0}
|
||||
w="full"
|
||||
position="relative"
|
||||
backgroundImage={!expanded.isOpen ? metadata.banner : ""}
|
||||
backgroundPosition="center"
|
||||
backgroundSize="cover"
|
||||
backgroundRepeat="no-repeat"
|
||||
>
|
||||
{expanded.isOpen && <Image src={metadata?.banner} w="full" />}
|
||||
<IconButton
|
||||
icon={expanded.isOpen ? <ArrowUpSIcon /> : <ArrowDownSIcon />}
|
||||
aria-label="expand"
|
||||
onClick={expanded.onToggle}
|
||||
top="2"
|
||||
right="2"
|
||||
variant="solid"
|
||||
position="absolute"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{aboutContent && (
|
||||
<Text whiteSpace="pre-wrap" px="2">
|
||||
{aboutContent.map((part, i) =>
|
||||
typeof part === "string" ? (
|
||||
<Text as="span" key={"part-" + i}>
|
||||
{part}
|
||||
</Text>
|
||||
) : (
|
||||
React.cloneElement(part, { key: "part-" + i })
|
||||
)
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Flex gap="2" px="2" direction="column">
|
||||
{metadata?.nip05 && (
|
||||
<Flex gap="2">
|
||||
<AtIcon />
|
||||
<UserDnsIdentityIcon pubkey={pubkey} />
|
||||
</Flex>
|
||||
)}
|
||||
{metadata?.website && (
|
||||
<Flex gap="2">
|
||||
<ExternalLinkIcon />
|
||||
<Link href={metadata.website} target="_blank" color="blue.500" isExternal>
|
||||
{metadata.website}
|
||||
</Link>
|
||||
</Flex>
|
||||
)}
|
||||
{npub && (
|
||||
<Flex gap="2">
|
||||
<KeyIcon />
|
||||
<Text>{truncatedId(npub, 10)}</Text>
|
||||
<CopyIconButton text={npub} title="Copy npub" aria-label="Copy npub" size="xs" />
|
||||
<QrIconButton pubkey={pubkey} title="Show QrCode" aria-label="Show QrCode" size="xs" />
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<Accordion allowToggle allowMultiple>
|
||||
<AccordionItem>
|
||||
<h2>
|
||||
<AccordionButton>
|
||||
<Box as="span" flex="1" textAlign="left">
|
||||
Network Stats
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
</h2>
|
||||
<AccordionPanel pb="2">
|
||||
<StatGroup gap="4" whiteSpace="pre">
|
||||
<Stat>
|
||||
<StatLabel>Following</StatLabel>
|
||||
<StatNumber>{contacts ? readablizeSats(contacts.contacts.length) : "Unknown"}</StatNumber>
|
||||
{contacts && (
|
||||
<StatHelpText>Updated {moment(convertTimestampToDate(contacts.created_at)).fromNow()}</StatHelpText>
|
||||
)}
|
||||
</Stat>
|
||||
|
||||
{stats && (
|
||||
<>
|
||||
<Stat>
|
||||
<StatLabel>Followers</StatLabel>
|
||||
<StatNumber>{readablizeSats(stats.followers_pubkey_count) || 0}</StatNumber>
|
||||
</Stat>
|
||||
|
||||
<Stat>
|
||||
<StatLabel>Published Notes</StatLabel>
|
||||
<StatNumber>{readablizeSats(stats.pub_post_count) || 0}</StatNumber>
|
||||
</Stat>
|
||||
|
||||
<Stat>
|
||||
<StatLabel>Reactions</StatLabel>
|
||||
<StatNumber>{readablizeSats(stats.pub_reaction_count) || 0}</StatNumber>
|
||||
</Stat>
|
||||
</>
|
||||
)}
|
||||
</StatGroup>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
|
||||
{(stats?.zaps_sent || stats?.zaps_received) && (
|
||||
<AccordionItem>
|
||||
<h2>
|
||||
<AccordionButton>
|
||||
<Box as="span" flex="1" textAlign="left">
|
||||
Zap Stats
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
</h2>
|
||||
<AccordionPanel pb="2">
|
||||
<StatGroup gap="4" whiteSpace="pre">
|
||||
{stats.zaps_sent && (
|
||||
<>
|
||||
<Stat>
|
||||
<StatLabel>Zap Sent</StatLabel>
|
||||
<StatNumber>{stats.zaps_sent.count}</StatNumber>
|
||||
</Stat>
|
||||
<Stat>
|
||||
<StatLabel>Total Sats Sent</StatLabel>
|
||||
<StatNumber>{readablizeSats(stats.zaps_sent.msats / 1000)}</StatNumber>
|
||||
</Stat>
|
||||
<Stat>
|
||||
<StatLabel>Avg Zap Sent</StatLabel>
|
||||
<StatNumber>{readablizeSats(stats.zaps_sent.avg_msats / 1000)}</StatNumber>
|
||||
</Stat>
|
||||
<Stat>
|
||||
<StatLabel>Biggest Zap Sent</StatLabel>
|
||||
<StatNumber>{readablizeSats(stats.zaps_sent.max_msats / 1000)}</StatNumber>
|
||||
</Stat>
|
||||
</>
|
||||
)}
|
||||
|
||||
{stats.zaps_received && (
|
||||
<>
|
||||
<Stat>
|
||||
<StatLabel>Zap Received</StatLabel>
|
||||
<StatNumber>{stats.zaps_received.count}</StatNumber>
|
||||
</Stat>
|
||||
<Stat>
|
||||
<StatLabel>Total Sats Received</StatLabel>
|
||||
<StatNumber>{readablizeSats(stats.zaps_received.msats / 1000)}</StatNumber>
|
||||
</Stat>
|
||||
<Stat>
|
||||
<StatLabel>Avg Zap Received</StatLabel>
|
||||
<StatNumber>{readablizeSats(stats.zaps_received.avg_msats / 1000)}</StatNumber>
|
||||
</Stat>
|
||||
<Stat>
|
||||
<StatLabel>Biggest Zap Received</StatLabel>
|
||||
<StatNumber>{readablizeSats(stats.zaps_received.max_msats / 1000)}</StatNumber>
|
||||
</Stat>
|
||||
</>
|
||||
)}
|
||||
</StatGroup>
|
||||
<Text color="slategrey">
|
||||
Stats from{" "}
|
||||
<Link href="https://nostr.band" isExternal color="blue.500">
|
||||
nostr.band
|
||||
</Link>
|
||||
</Text>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
)}
|
||||
</Accordion>
|
||||
</Flex>
|
||||
);
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
import { Flex, Heading, SkeletonText, Text, Link, IconButton } from "@chakra-ui/react";
|
||||
import { Flex, Heading, SkeletonText, Text, Link, IconButton, Spacer } from "@chakra-ui/react";
|
||||
import { useNavigate, Link as RouterLink } from "react-router-dom";
|
||||
import { CopyIconButton } from "../../../components/copy-icon-button";
|
||||
import { ChatIcon, ExternalLinkIcon, KeyIcon, SettingsIcon } from "../../../components/icons";
|
||||
import { ChatIcon, EditIcon, ExternalLinkIcon, KeyIcon, SettingsIcon } from "../../../components/icons";
|
||||
import { QrIconButton } from "./share-qr-button";
|
||||
import { UserAvatar } from "../../../components/user-avatar";
|
||||
import { UserDnsIdentityIcon } from "../../../components/user-dns-identity-icon";
|
||||
@ -15,7 +15,7 @@ import { useIsMobile } from "../../../hooks/use-is-mobile";
|
||||
import { useUserMetadata } from "../../../hooks/use-user-metadata";
|
||||
import { UserProfileMenu } from "./user-profile-menu";
|
||||
import { embedUrls } from "../../../helpers/embeds";
|
||||
import { renderDefaultUrl } from "../../../components/embed-types";
|
||||
import { renderGenericUrl } from "../../../components/embed-types";
|
||||
|
||||
export default function Header({
|
||||
pubkey,
|
||||
@ -34,56 +34,23 @@ export default function Header({
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="2" px="2" pt="2">
|
||||
<Flex gap="4">
|
||||
<UserAvatar pubkey={pubkey} size={isMobile ? "md" : "xl"} noProxy />
|
||||
<Flex direction="column" gap={isMobile ? 0 : 2} grow="1" overflow="hidden">
|
||||
<Flex gap="2" justifyContent="space-between" width="100%">
|
||||
<Flex gap="2" alignItems="center" wrap="wrap">
|
||||
<Heading size={isMobile ? "md" : "lg"}>{getUserDisplayName(metadata, pubkey)}</Heading>
|
||||
<UserDnsIdentityIcon pubkey={pubkey} />
|
||||
</Flex>
|
||||
<Flex gap="2">
|
||||
<UserTipButton pubkey={pubkey} size="sm" variant="link" />
|
||||
<UserProfileMenu
|
||||
pubkey={pubkey}
|
||||
aria-label="More Options"
|
||||
size="sm"
|
||||
variant="link"
|
||||
showRelaySelectionModal={showRelaySelectionModal}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
{metadata?.about && <Text>{embedUrls([metadata.about], [renderDefaultUrl])}</Text>}
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Flex wrap="wrap" gap="2">
|
||||
{metadata?.website && (
|
||||
<Text>
|
||||
<ExternalLinkIcon />{" "}
|
||||
<Link href={fixWebsiteUrl(metadata.website)} target="_blank" color="blue.500">
|
||||
{metadata.website}
|
||||
</Link>
|
||||
</Text>
|
||||
<Flex gap="2" alignItems="center">
|
||||
<UserAvatar pubkey={pubkey} size="sm" noProxy mr="2" />
|
||||
<Heading size="md">{getUserDisplayName(metadata, pubkey)}</Heading>
|
||||
<UserDnsIdentityIcon pubkey={pubkey} onlyIcon={isMobile} />
|
||||
<Spacer />
|
||||
{isSelf && (
|
||||
<IconButton
|
||||
icon={<EditIcon />}
|
||||
aria-label="Edit profile"
|
||||
title="Edit profile"
|
||||
size="sm"
|
||||
onClick={() => navigate("/profile")}
|
||||
/>
|
||||
)}
|
||||
{npub && (
|
||||
<Flex gap="2">
|
||||
<KeyIcon />
|
||||
<Text>{truncatedId(npub, 10)}</Text>
|
||||
<CopyIconButton text={npub} title="Copy npub" aria-label="Copy npub" size="xs" />
|
||||
<QrIconButton pubkey={pubkey} title="Show QrCode" aria-label="Show QrCode" size="xs" />
|
||||
</Flex>
|
||||
)}
|
||||
<Flex gap="2" ml="auto">
|
||||
{isMobile && isSelf && (
|
||||
<IconButton
|
||||
icon={<SettingsIcon />}
|
||||
aria-label="Settings"
|
||||
title="Settings"
|
||||
size="sm"
|
||||
onClick={() => navigate("/settings")}
|
||||
/>
|
||||
)}
|
||||
{!isSelf && (
|
||||
{!isSelf && (
|
||||
<>
|
||||
<UserTipButton pubkey={pubkey} size="sm" variant="link" />
|
||||
<IconButton
|
||||
as={RouterLink}
|
||||
size="sm"
|
||||
@ -91,9 +58,15 @@ export default function Header({
|
||||
aria-label="Message"
|
||||
to={`/dm/${npub ?? pubkey}`}
|
||||
/>
|
||||
)}
|
||||
{!isSelf && <UserFollowButton pubkey={pubkey} size="sm" />}
|
||||
</Flex>
|
||||
<UserFollowButton pubkey={pubkey} size="sm" />
|
||||
</>
|
||||
)}
|
||||
<UserProfileMenu
|
||||
pubkey={pubkey}
|
||||
aria-label="More Options"
|
||||
size="sm"
|
||||
showRelaySelectionModal={showRelaySelectionModal}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Box, Flex, Heading, Link, Text } from "@chakra-ui/react";
|
||||
import { Box, Code, Flex, Heading, Input, Link, Spacer, Text } from "@chakra-ui/react";
|
||||
import { Link as ReactRouterLink } from "react-router-dom";
|
||||
|
||||
import { useUserMetadata } from "../../../hooks/use-user-metadata";
|
||||
@ -6,22 +6,37 @@ import { getUserDisplayName } from "../../../helpers/user-metadata";
|
||||
import { UserAvatar } from "../../../components/user-avatar";
|
||||
import { Bech32Prefix, normalizeToBech32 } from "../../../helpers/nip19";
|
||||
import { UserDnsIdentityIcon } from "../../../components/user-dns-identity-icon";
|
||||
import { UserFollowButton } from "../../../components/user-follow-button";
|
||||
import { useIsMobile } from "../../../hooks/use-is-mobile";
|
||||
|
||||
export const UserCard = ({ pubkey, relay }: { pubkey: string; relay?: string }) => {
|
||||
const isMobile = useIsMobile();
|
||||
const metadata = useUserMetadata(pubkey, relay ? [relay] : []);
|
||||
|
||||
return (
|
||||
<Box borderWidth="1px" borderRadius="lg" pl="3" pr="3" pt="2" pb="2" overflow="hidden">
|
||||
<Flex gap="4" alignItems="center">
|
||||
<UserAvatar pubkey={pubkey} />
|
||||
<Box>
|
||||
<Link as={ReactRouterLink} to={`/u/${normalizeToBech32(pubkey, Bech32Prefix.Pubkey)}`}>
|
||||
<Heading size="sm">{getUserDisplayName(metadata, pubkey)}</Heading>
|
||||
</Link>
|
||||
<UserDnsIdentityIcon pubkey={pubkey} />
|
||||
{relay && <Text>{relay}</Text>}
|
||||
</Box>
|
||||
<Box
|
||||
borderWidth="1px"
|
||||
borderRadius="lg"
|
||||
pl="3"
|
||||
pr="3"
|
||||
pt="2"
|
||||
pb="2"
|
||||
overflow="hidden"
|
||||
gap="4"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
>
|
||||
<UserAvatar pubkey={pubkey} />
|
||||
<Flex direction="column" flex={1} overflowY="hidden" overflowX="auto">
|
||||
<Link as={ReactRouterLink} to={`/u/${normalizeToBech32(pubkey, Bech32Prefix.Pubkey)}`}>
|
||||
<Heading size="sm" whiteSpace="nowrap">
|
||||
{getUserDisplayName(metadata, pubkey)}
|
||||
</Heading>
|
||||
</Link>
|
||||
<UserDnsIdentityIcon pubkey={pubkey} />
|
||||
</Flex>
|
||||
{relay && !isMobile && <Input readOnly value={relay} w="xs" />}
|
||||
<UserFollowButton pubkey={pubkey} size="sm" variant="outline" flexShrink={0} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
@ -10,6 +10,7 @@ import { RelayMode } from "../../../classes/relay";
|
||||
import UserDebugModal from "../../../components/debug-modals/user-debug-modal";
|
||||
import { useCopyToClipboard } from "react-use";
|
||||
import { useSharableProfileId } from "../../../hooks/use-shareable-profile-id";
|
||||
import { truncatedId } from "../../../helpers/nostr-event";
|
||||
|
||||
export const UserProfileMenu = ({
|
||||
pubkey,
|
||||
@ -39,7 +40,7 @@ export const UserProfileMenu = ({
|
||||
<>
|
||||
<MenuIconButton {...props}>
|
||||
<MenuItem icon={<SpyIcon fontSize="1.5em" />} onClick={() => loginAsUser()}>
|
||||
Login as {getUserDisplayName(metadata, pubkey)}
|
||||
Login as {truncatedId(getUserDisplayName(metadata, pubkey))}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => window.open(`https://nostrapp.link/#${sharableId}?select=true`, "_blank")}
|
||||
|
@ -1,41 +1,51 @@
|
||||
import { Flex, FormControl, FormLabel, Grid, SkeletonText, Switch, useDisclosure } from "@chakra-ui/react";
|
||||
import AutoSizer from "react-virtualized-auto-sizer";
|
||||
import { Box, Flex, Spinner } from "@chakra-ui/react";
|
||||
import { useOutletContext } from "react-router-dom";
|
||||
import { FixedSizeList, ListChildComponentProps } from "react-window";
|
||||
|
||||
import { UserCard } from "./components/user-card";
|
||||
import { useUserFollowers } from "../../hooks/use-user-followers";
|
||||
import { useOutletContext } from "react-router-dom";
|
||||
import { usePaginatedList } from "../../hooks/use-paginated-list";
|
||||
import { PaginationControls } from "../../components/pagination-controls";
|
||||
import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
|
||||
import { useReadRelayUrls } from "../../hooks/use-client-relays";
|
||||
|
||||
const UserFollowersTab = () => {
|
||||
const { pubkey } = useOutletContext() as { pubkey: string };
|
||||
const { isOpen, onToggle } = useDisclosure();
|
||||
const contextRelays = useAdditionalRelayContext();
|
||||
const relays = useReadRelayUrls(contextRelays);
|
||||
const followers = useUserFollowers(pubkey, relays, isOpen);
|
||||
|
||||
const pagination = usePaginatedList(followers ?? [], { pageSize: 3 * 10 });
|
||||
function FollowerItem({ index, style, data: followers }: ListChildComponentProps<string[]>) {
|
||||
const pubkey = followers[index];
|
||||
|
||||
return (
|
||||
<Flex gap="2" direction="column">
|
||||
<FormControl display="flex" alignItems="center">
|
||||
<FormLabel htmlFor="fetch-followers" mb="0">
|
||||
Fetch Followers
|
||||
</FormLabel>
|
||||
<Switch id="fetch-followers" isChecked={isOpen} onChange={onToggle} />
|
||||
</FormControl>
|
||||
<div style={style}>
|
||||
<UserCard key={pubkey + index} pubkey={pubkey} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const UserFollowersTab = () => {
|
||||
const { pubkey } = useOutletContext() as { pubkey: string };
|
||||
|
||||
const relays = useReadRelayUrls(useAdditionalRelayContext());
|
||||
const followers = useUserFollowers(pubkey, relays, true);
|
||||
|
||||
return (
|
||||
<Flex gap="2" direction="column" overflowY="auto" p="2" h="full">
|
||||
{followers ? (
|
||||
<>
|
||||
<Grid templateColumns={{ base: "1fr", xl: "repeat(2, 1fr)", "2xl": "repeat(3, 1fr)" }} gap="2">
|
||||
{pagination.pageItems.map((pubkey) => (
|
||||
<UserCard key={pubkey} pubkey={pubkey} />
|
||||
))}
|
||||
</Grid>
|
||||
<PaginationControls {...pagination} buttonSize="sm" />
|
||||
</>
|
||||
<Box flex={1}>
|
||||
<AutoSizer disableWidth>
|
||||
{({ height }: { height: number }) => (
|
||||
<FixedSizeList
|
||||
itemCount={followers.length}
|
||||
itemData={followers}
|
||||
itemSize={70}
|
||||
itemKey={(i, d) => d[i]}
|
||||
width="100%"
|
||||
height={height}
|
||||
overscanCount={10}
|
||||
>
|
||||
{FollowerItem}
|
||||
</FixedSizeList>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</Box>
|
||||
) : (
|
||||
<SkeletonText />
|
||||
<Spinner />
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
|
@ -1,36 +1,51 @@
|
||||
import moment from "moment";
|
||||
import { Flex, Grid, SkeletonText } from "@chakra-ui/react";
|
||||
import { Box, Flex, Spinner } from "@chakra-ui/react";
|
||||
import { useOutletContext } from "react-router-dom";
|
||||
import AutoSizer from "react-virtualized-auto-sizer";
|
||||
import { FixedSizeList, ListChildComponentProps } from "react-window";
|
||||
|
||||
import { UserCard } from "./components/user-card";
|
||||
import { useUserContacts } from "../../hooks/use-user-contacts";
|
||||
import { useOutletContext } from "react-router-dom";
|
||||
import { usePaginatedList } from "../../hooks/use-paginated-list";
|
||||
import { PaginationControls } from "../../components/pagination-controls";
|
||||
import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
|
||||
import { UserContacts } from "../../services/user-contacts";
|
||||
|
||||
const UserFollowingTab = () => {
|
||||
function ContactItem({ index, style, data: contacts }: ListChildComponentProps<UserContacts>) {
|
||||
const pubkey = contacts.contacts[index];
|
||||
|
||||
return (
|
||||
<div style={style}>
|
||||
<UserCard key={pubkey + index} pubkey={pubkey} relay={contacts.contactRelay[pubkey]} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UserFollowingTab() {
|
||||
const { pubkey } = useOutletContext() as { pubkey: string };
|
||||
const contextRelays = useAdditionalRelayContext();
|
||||
const contacts = useUserContacts(pubkey, contextRelays, true);
|
||||
|
||||
const pagination = usePaginatedList(contacts?.contacts ?? [], { pageSize: 3 * 10 });
|
||||
|
||||
return (
|
||||
<Flex gap="2" direction="column">
|
||||
<Flex gap="2" direction="column" overflowY="auto" p="2" h="full">
|
||||
{contacts ? (
|
||||
<>
|
||||
<Grid templateColumns={{ base: "1fr", xl: "repeat(2, 1fr)", "2xl": "repeat(3, 1fr)" }} gap="2">
|
||||
{pagination.pageItems.map((pubkey, i) => (
|
||||
<UserCard key={pubkey + i} pubkey={pubkey} relay={contacts.contactRelay[pubkey]} />
|
||||
))}
|
||||
</Grid>
|
||||
<PaginationControls {...pagination} buttonSize="sm" />
|
||||
</>
|
||||
<Box flex={1}>
|
||||
<AutoSizer disableWidth>
|
||||
{({ height }: { height: number }) => (
|
||||
<FixedSizeList
|
||||
itemCount={contacts.contacts.length}
|
||||
itemData={contacts}
|
||||
itemSize={70}
|
||||
itemKey={(i, d) => d.contacts[i]}
|
||||
width="100%"
|
||||
height={height}
|
||||
overscanCount={10}
|
||||
>
|
||||
{ContactItem}
|
||||
</FixedSizeList>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</Box>
|
||||
) : (
|
||||
<SkeletonText />
|
||||
<Spinner />
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserFollowingTab;
|
||||
}
|
||||
|
@ -27,10 +27,8 @@ import {
|
||||
import { Outlet, useMatches, useNavigate, useParams } from "react-router-dom";
|
||||
import { useUserMetadata } from "../../hooks/use-user-metadata";
|
||||
import { getUserDisplayName } from "../../helpers/user-metadata";
|
||||
import { useIsMobile } from "../../hooks/use-is-mobile";
|
||||
import { Bech32Prefix, isHex, normalizeToBech32 } from "../../helpers/nip19";
|
||||
import { useAppTitle } from "../../hooks/use-app-title";
|
||||
import Header from "./components/header";
|
||||
import { Suspense, useState } from "react";
|
||||
import { useReadRelayUrls } from "../../hooks/use-client-relays";
|
||||
import relayScoreboardService from "../../services/relay-scoreboard";
|
||||
@ -40,15 +38,17 @@ import { nip19 } from "nostr-tools";
|
||||
import { unique } from "../../helpers/array";
|
||||
import { RelayFavicon } from "../../components/relay-favicon";
|
||||
import { useUserRelays } from "../../hooks/use-user-relays";
|
||||
import Header from "./components/header";
|
||||
|
||||
const tabs = [
|
||||
{ label: "About", path: "about" },
|
||||
{ label: "Notes", path: "notes" },
|
||||
{ label: "Media", path: "media" },
|
||||
{ label: "Zaps", path: "zaps" },
|
||||
{ label: "Followers", path: "followers" },
|
||||
{ label: "Following", path: "following" },
|
||||
{ label: "Relays", path: "relays" },
|
||||
{ label: "Reports", path: "reports" },
|
||||
{ label: "Followers", path: "followers" },
|
||||
];
|
||||
|
||||
function useUserPointer() {
|
||||
@ -82,7 +82,6 @@ function useUserTopRelays(pubkey: string, count: number = 4) {
|
||||
|
||||
const UserView = () => {
|
||||
const { pubkey, relays: pointerRelays } = useUserPointer();
|
||||
const isMobile = useIsMobile();
|
||||
const navigate = useNavigate();
|
||||
const [relayCount, setRelayCount] = useState(4);
|
||||
const userTopRelays = useUserTopRelays(pubkey, relayCount);
|
||||
@ -91,7 +90,7 @@ const UserView = () => {
|
||||
const matches = useMatches();
|
||||
const lastMatch = matches[matches.length - 1];
|
||||
|
||||
const activeTab = tabs.indexOf(tabs.find((t) => lastMatch.pathname.includes(t.path)) ?? tabs[0]);
|
||||
const activeTab = tabs.indexOf(tabs.find((t) => lastMatch.pathname.endsWith(t.path)) ?? tabs[0]);
|
||||
|
||||
const metadata = useUserMetadata(pubkey, userTopRelays, true);
|
||||
const npub = normalizeToBech32(pubkey, Bech32Prefix.Pubkey);
|
||||
@ -101,14 +100,13 @@ const UserView = () => {
|
||||
return (
|
||||
<>
|
||||
<AdditionalRelayProvider relays={unique([...userTopRelays, ...pointerRelays])}>
|
||||
<Flex direction="column" alignItems="stretch" gap="2" overflow={isMobile ? "auto" : "hidden"} height="100%">
|
||||
{/* {metadata?.banner && <Image src={metadata.banner} mb={-120} />} */}
|
||||
<Flex direction="column" alignItems="stretch" gap="2" overflow="hidden" h="full">
|
||||
<Header pubkey={pubkey} showRelaySelectionModal={relayModal.onOpen} />
|
||||
<Tabs
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
flexGrow="1"
|
||||
overflow={isMobile ? undefined : "hidden"}
|
||||
overflow="hidden"
|
||||
isLazy
|
||||
index={activeTab}
|
||||
onChange={(v) => navigate(tabs[v].path)}
|
||||
@ -120,9 +118,9 @@ const UserView = () => {
|
||||
))}
|
||||
</TabList>
|
||||
|
||||
<TabPanels overflow={isMobile ? undefined : "auto"} height="100%">
|
||||
<TabPanels overflow="hidden" h="full">
|
||||
{tabs.map(({ label }) => (
|
||||
<TabPanel key={label} pr={0} pl={0}>
|
||||
<TabPanel key={label} p={0} h="full" overflow="hidden">
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<Outlet context={{ pubkey, setRelayCount }} />
|
||||
</Suspense>
|
||||
|
@ -1,19 +1,20 @@
|
||||
import { Box, Button, Flex, Grid, IconButton, Spinner } from "@chakra-ui/react";
|
||||
import { Link as RouterLink, useOutletContext } from "react-router-dom";
|
||||
import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Box, Button, Flex, Grid, IconButton, Spinner } from "@chakra-ui/react";
|
||||
import { useNavigate, useOutletContext } from "react-router-dom";
|
||||
import { useMount, useUnmount } from "react-use";
|
||||
import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
|
||||
import { matchImageUrls } from "../../helpers/regexp";
|
||||
import { useIsMobile } from "../../hooks/use-is-mobile";
|
||||
import { ImageGalleryLink, ImageGalleryProvider } from "../../components/image-gallery";
|
||||
import { ExternalLinkIcon } from "../../components/icons";
|
||||
import { getSharableNoteId } from "../../helpers/nip19";
|
||||
import { useMount, useUnmount } from "react-use";
|
||||
import useSubject from "../../hooks/use-subject";
|
||||
import userTimelineService from "../../services/user-timeline";
|
||||
|
||||
const matchAllImages = new RegExp(matchImageUrls, "ig");
|
||||
|
||||
const UserMediaTab = () => {
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
const { pubkey } = useOutletContext() as { pubkey: string };
|
||||
const contextRelays = useAdditionalRelayContext();
|
||||
@ -48,7 +49,7 @@ const UserMediaTab = () => {
|
||||
}, [filteredEvents]);
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="2" pr="2" pl="2">
|
||||
<Flex direction="column" gap="2" px="2" pb="8" h="full" overflowY="auto">
|
||||
<ImageGalleryProvider>
|
||||
<Grid templateColumns={`repeat(${isMobile ? 2 : 5}, 1fr)`} gap="4">
|
||||
{images.map((image) => (
|
||||
@ -60,23 +61,28 @@ const UserMediaTab = () => {
|
||||
backgroundPosition="center"
|
||||
/>
|
||||
<IconButton
|
||||
as={RouterLink}
|
||||
icon={<ExternalLinkIcon />}
|
||||
aria-label="Open note"
|
||||
to={`/n/${getSharableNoteId(image.eventId)}`}
|
||||
position="absolute"
|
||||
right="2"
|
||||
top="2"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
navigate(`/n/${getSharableNoteId(image.eventId)}`);
|
||||
}}
|
||||
/>
|
||||
</ImageGalleryLink>
|
||||
))}
|
||||
</Grid>
|
||||
</ImageGalleryProvider>
|
||||
{loading ? (
|
||||
<Spinner ml="auto" mr="auto" mt="8" mb="8" />
|
||||
<Spinner ml="auto" mr="auto" mt="8" mb="8" flexShrink={0} />
|
||||
) : (
|
||||
<Button onClick={() => timeline.loadMore()}>Load More</Button>
|
||||
<Button onClick={() => timeline.loadMore()} flexShrink={0}>
|
||||
Load More
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
|
@ -35,8 +35,8 @@ const UserNotesTab = () => {
|
||||
});
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="2" pr="2" pl="2">
|
||||
<FormControl display="flex" alignItems="center">
|
||||
<Flex direction="column" gap="2" pt="4" pb="8" h="full" overflowY="auto" overflowX="hidden">
|
||||
<FormControl display="flex" alignItems="center" mx="2">
|
||||
<Switch id="replies" mr="2" isChecked={showReplies} onChange={toggleReplies} />
|
||||
<FormLabel htmlFor="replies" mb="0">
|
||||
Replies
|
||||
@ -55,9 +55,11 @@ const UserNotesTab = () => {
|
||||
)
|
||||
)}
|
||||
{loading ? (
|
||||
<Spinner ml="auto" mr="auto" mt="8" mb="8" />
|
||||
<Spinner ml="auto" mr="auto" mt="8" mb="8" flexShrink={0} />
|
||||
) : (
|
||||
<Button onClick={() => timeline.loadMore()}>Load More</Button>
|
||||
<Button onClick={() => timeline.loadMore()} flexShrink={0}>
|
||||
Load More
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
|
@ -54,7 +54,13 @@ export default function UserReportsTab() {
|
||||
{reports.map((report) => (
|
||||
<ReportEvent key={report.id} report={report} />
|
||||
))}
|
||||
{loading ? <Spinner ml="auto" mr="auto" mt="8" mb="8" /> : <Button onClick={() => loadMore()}>Load More</Button>}
|
||||
{loading ? (
|
||||
<Spinner ml="auto" mr="auto" mt="8" mb="8" flexShrink={0} />
|
||||
) : (
|
||||
<Button onClick={() => loadMore()} flexShrink={0}>
|
||||
Load More
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { Box, Button, Flex, Select, Spinner, Text, useDisclosure } from "@chakra-ui/react";
|
||||
import moment from "moment";
|
||||
import moment, { isMoment } from "moment";
|
||||
import { useState } from "react";
|
||||
import { useOutletContext } from "react-router-dom";
|
||||
import { ErrorBoundary, ErrorFallback } from "../../components/error-boundary";
|
||||
@ -30,6 +30,7 @@ const Zap = ({ zapEvent }: { zapEvent: NostrEvent }) => {
|
||||
display="flex"
|
||||
gap="2"
|
||||
flexDirection="column"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Flex gap="2" alignItems="center" wrap="wrap">
|
||||
<UserAvatarLink pubkey={request.pubkey} size="xs" />
|
||||
@ -37,10 +38,10 @@ const Zap = ({ zapEvent }: { zapEvent: NostrEvent }) => {
|
||||
<Text>Zapped</Text>
|
||||
{eventId && <NoteLink noteId={eventId} />}
|
||||
{payment.amount && (
|
||||
<>
|
||||
<Flex gap="2">
|
||||
<LightningIcon color="yellow.400" />
|
||||
<Text>{readablizeSats(payment.amount / 1000)} sats</Text>
|
||||
</>
|
||||
</Flex>
|
||||
)}
|
||||
{request.content && (
|
||||
<Button variant="link" onClick={onToggle}>
|
||||
@ -79,21 +80,21 @@ const UserZapsTab = () => {
|
||||
filter === "note" ? events.filter(isNoteZap) : filter === "profile" ? events.filter(isProfileZap) : events;
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="2" pr="2" pl="2">
|
||||
<Flex gap="2" alignItems="center">
|
||||
<Select value={filter} onChange={(e) => setFilter(e.target.value)} maxW="lg">
|
||||
<Flex direction="column" gap="2" p="2" pb="8" h="full" overflowY="auto">
|
||||
<Flex gap="2" alignItems="center" wrap="wrap">
|
||||
<Select value={filter} onChange={(e) => setFilter(e.target.value)} maxW="md">
|
||||
<option value="both">Note & Profile Zaps</option>
|
||||
<option value="note">Note Zaps</option>
|
||||
<option value="profile">Profile Zaps</option>
|
||||
</Select>
|
||||
{timeline.length && (
|
||||
<>
|
||||
<Flex gap="2">
|
||||
<LightningIcon color="yellow.400" />
|
||||
<Text>
|
||||
{readablizeSats(totalZaps(timeline) / 1000)} sats in the last{" "}
|
||||
{moment(convertTimestampToDate(timeline[timeline.length - 1].created_at)).fromNow(true)}
|
||||
</Text>
|
||||
</>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
{timeline.map((event) => (
|
||||
@ -101,7 +102,13 @@ const UserZapsTab = () => {
|
||||
<Zap zapEvent={event} />
|
||||
</ErrorBoundary>
|
||||
))}
|
||||
{loading ? <Spinner ml="auto" mr="auto" mt="8" mb="8" /> : <Button onClick={() => loadMore()}>Load More</Button>}
|
||||
{loading ? (
|
||||
<Spinner ml="auto" mr="auto" mt="8" mb="8" flexShrink={0} />
|
||||
) : (
|
||||
<Button onClick={() => loadMore()} flexShrink={0}>
|
||||
Load More
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
25
yarn.lock
25
yarn.lock
@ -2683,6 +2683,13 @@
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react-window@^1.8.5":
|
||||
version "1.8.5"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.8.5.tgz#285fcc5cea703eef78d90f499e1457e9b5c02fc1"
|
||||
integrity sha512-V9q3CvhC9Jk9bWBOysPGaWy/Z0lxYcTXLtLipkt2cnRj1JOSFNF7wqGpkScSXMgBwC+fnVRg/7shwgddBG5ICw==
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react@*":
|
||||
version "18.0.34"
|
||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.34.tgz#e553444a578f023e6e1ac499514688fb80b0a984"
|
||||
@ -4827,6 +4834,11 @@ mdn-data@2.0.14:
|
||||
resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50"
|
||||
integrity sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==
|
||||
|
||||
"memoize-one@>=3.1.1 <6":
|
||||
version "5.2.1"
|
||||
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e"
|
||||
integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==
|
||||
|
||||
meow@^6.0.0:
|
||||
version "6.1.1"
|
||||
resolved "https://registry.yarnpkg.com/meow/-/meow-6.1.1.tgz#1ad64c4b76b2a24dfb2f635fddcadf320d251467"
|
||||
@ -5432,11 +5444,24 @@ react-use@^17.4.0:
|
||||
ts-easing "^0.2.0"
|
||||
tslib "^2.1.0"
|
||||
|
||||
react-virtualized-auto-sizer@^1.0.20:
|
||||
version "1.0.20"
|
||||
resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.20.tgz#d9a907253a7c221c52fa57dc775a6ef40c182645"
|
||||
integrity sha512-OdIyHwj4S4wyhbKHOKM1wLSj/UDXm839Z3Cvfg2a9j+He6yDa6i5p0qQvEiCnyQlGO/HyfSnigQwuxvYalaAXA==
|
||||
|
||||
react-webcam@^5.0.1:
|
||||
version "5.2.4"
|
||||
resolved "https://registry.yarnpkg.com/react-webcam/-/react-webcam-5.2.4.tgz#714b4460ea43ac7ed081824299cd2a580f764478"
|
||||
integrity sha512-Qqj14t68Ke1eoEYjFde+N48HtuIJg0ePIQRpFww9eZt5oBcDpe/l60h+m3VRFJAR5/E3dOhSU5R8EJEcdCq/Eg==
|
||||
|
||||
react-window@^1.8.9:
|
||||
version "1.8.9"
|
||||
resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.9.tgz#24bc346be73d0468cdf91998aac94e32bc7fa6a8"
|
||||
integrity sha512-+Eqx/fj1Aa5WnhRfj9dJg4VYATGwIUP2ItwItiJ6zboKWA6EX3lYDAXfGF2hyNqplEprhbtjbipiADEcwQ823Q==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.0.0"
|
||||
memoize-one ">=3.1.1 <6"
|
||||
|
||||
react@^18.2.0:
|
||||
version "18.2.0"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
|
||||
|
Loading…
x
Reference in New Issue
Block a user