Files
snort/packages/app/src/Pages/Profile/ProfilePage.tsx

390 lines
12 KiB
TypeScript

import "./ProfilePage.css";
import { useEffect, useState } from "react";
import { FormattedMessage } from "react-intl";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import {
encodeTLV,
encodeTLVEntries,
EventKind,
MetadataCache,
NostrLink,
NostrPrefix,
TLVEntryType,
tryParseNostrLink,
} from "@snort/system";
import { LNURL, fetchNip05Pubkey } from "@snort/shared";
import { useUserProfile } from "@snort/system-react";
import { findTag, getLinkReactions, unwrap } from "SnortUtils";
import Note from "Element/Event/Note";
import { Tab, TabElement } from "Element/Tabs";
import Icon from "Icons/Icon";
import useFollowsFeed from "Feed/FollowsFeed";
import useProfileBadges from "Feed/BadgesFeed";
import useModeration from "Hooks/useModeration";
import FollowButton from "Element/User/FollowButton";
import { parseId, hexToBech32 } from "SnortUtils";
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/User/Nip05";
import Copy from "Element/Copy";
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/User/FollowsYou";
import QrCode from "Element/QrCode";
import Modal from "Element/Modal";
import BadgeList from "Element/User/BadgeList";
import { ProxyImg } from "Element/ProxyImg";
import useHorizontalScroll from "Hooks/useHorizontalScroll";
import { EmailRegex } from "Const";
import useLogin from "Hooks/useLogin";
import { ZapTarget } from "Zapper";
import { useStatusFeed } from "Feed/StatusFeed";
import { SpotlightMediaModal } from "Element/SpotlightMedia";
import ProfileTab, {
BookMarksTab,
FollowersTab,
FollowsTab,
ProfileTabType,
RelaysTab,
ZapsProfileTab,
} from "Pages/Profile/ProfileTab";
import DisplayName from "Element/User/DisplayName";
import { UserWebsiteLink } from "Element/User/UserWebsiteLink";
import { useMuteList, usePinList } from "Hooks/useLists";
import messages from "../messages";
interface ProfilePageProps {
id?: string;
state?: MetadataCache;
}
export default function ProfilePage({ id: propId, state }: ProfilePageProps) {
const params = useParams();
const location = useLocation();
const profileState = (location.state as MetadataCache | undefined) || state;
const navigate = useNavigate();
const [id, setId] = useState<string | undefined>(profileState?.pubkey);
const [relays, setRelays] = useState<Array<string>>();
const user = useUserProfile(profileState ? undefined : id) || profileState;
const login = useLogin();
const loginPubKey = login.publicKey;
const isMe = loginPubKey === id;
const [showLnQr, setShowLnQr] = useState<boolean>(false);
const [showProfileQr, setShowProfileQr] = useState<boolean>(false);
const [modalImage, setModalImage] = useState<string>("");
const aboutText = user?.about || "";
const npub = !id?.startsWith(NostrPrefix.PublicKey) ? hexToBech32(NostrPrefix.PublicKey, id || undefined) : id;
const lnurl = (() => {
try {
return new LNURL(user?.lud16 || user?.lud06 || "");
} catch {
// ignored
}
})();
const showBadges = login.preferences.showBadges ?? false;
const showStatus = login.preferences.showStatus ?? true;
// feeds
const { blocked } = useModeration();
const pinned = usePinList(id);
const muted = useMuteList(id);
const badges = useProfileBadges(showBadges ? id : undefined);
const follows = useFollowsFeed(id);
const status = useStatusFeed(showStatus ? id : undefined, true);
// tabs
const [tab, setTab] = useState<Tab>(ProfileTab.Notes);
const optionalTabs = [ProfileTab.Zaps, ProfileTab.Relays, ProfileTab.Bookmarks, ProfileTab.Muted].filter(a =>
unwrap(a),
) as Tab[];
const horizontalScroll = useHorizontalScroll();
useEffect(() => {
if (!id) {
const resolvedId = propId || params.id;
if (resolvedId?.match(EmailRegex)) {
const [name, domain] = resolvedId.split("@");
fetchNip05Pubkey(name, domain).then(a => {
setId(a);
});
} else {
const nav = tryParseNostrLink(resolvedId ?? "");
if (nav?.type === NostrPrefix.PublicKey || nav?.type === NostrPrefix.Profile) {
setId(nav.id);
setRelays(nav.relays);
} else {
setId(parseId(resolvedId ?? ""));
}
}
}
setTab(ProfileTab.Notes);
}, [id, propId, params]);
function musicStatus() {
if (!status.music) return;
const link = findTag(status.music, "r");
const cover = findTag(status.music, "cover");
const inner = () => {
return (
<div className="flex g8">
{cover && <ProxyImg src={cover} size={40} />}
🎵 {unwrap(status.music).content}
</div>
);
};
if (link) {
return (
<a href={link} rel="noreferer" target="_blank" className="ext">
{inner()}
</a>
);
}
return inner();
}
function username() {
return (
<>
<div className="flex flex-col g4">
<h2 className="flex items-center g4">
<DisplayName user={user} pubkey={user?.pubkey ?? ""} />
<FollowsYou followsMe={follows.includes(loginPubKey ?? "")} />
</h2>
{user?.nip05 && <Nip05 nip05={user.nip05} pubkey={user.pubkey} />}
</div>
{showBadges && <BadgeList badges={badges} />}
{showStatus && <>{musicStatus()}</>}
<div className="link-section">
<Copy text={npub} />
{links()}
</div>
</>
);
}
function links() {
return (
<>
<UserWebsiteLink user={user} />
{lnurl && (
<div className="link lnurl f-ellipsis" onClick={() => setShowLnQr(true)}>
<Icon name="zapCircle" size={16} />
{lnurl.name}
</div>
)}
<SendSats
targets={
lnurl?.lnurl && id
? [
{
type: "lnurl",
value: lnurl?.lnurl,
weight: 1,
name: user?.display_name || user?.name,
zap: { pubkey: id },
} as ZapTarget,
]
: undefined
}
show={showLnQr}
onClose={() => setShowLnQr(false)}
/>
</>
);
}
function bio() {
if (!id) return null;
return (
aboutText.length > 0 && (
<div dir="auto" className="about">
<Text
id={id}
content={aboutText}
tags={[]}
creator={id}
disableMedia={true}
disableLinkPreview={true}
disableMediaSpotlight={true}
/>
</div>
)
);
}
function tabContent() {
if (!id) return null;
switch (tab.value) {
case ProfileTabType.NOTES:
return (
<>
{pinned
.filter(a => a.kind === EventKind.TextNote)
.map(n => {
return (
<Note
key={`pinned-${n.id}`}
data={n}
related={getLinkReactions(pinned, NostrLink.fromEvent(n))}
options={{ showTime: false, showPinned: true, canUnpin: id === loginPubKey }}
/>
);
})}
<Timeline
key={id}
subject={{
type: "pubkey",
items: [id],
discriminator: id.slice(0, 12),
relay: relays,
}}
postsOnly={false}
method={"LIMIT_UNTIL"}
loadMore={false}
ignoreModeration={true}
window={60 * 60 * 6}
/>
</>
);
case ProfileTabType.ZAPS: {
return <ZapsProfileTab id={id} />;
}
case ProfileTabType.FOLLOWS: {
if (isMe) {
return <FollowsList pubkeys={follows} showFollowAll={!isMe} showAbout={false} className="p" />;
} else {
return <FollowsTab id={id} />;
}
}
case ProfileTabType.FOLLOWERS: {
return <FollowersTab id={id} />;
}
case ProfileTabType.MUTED: {
return <MutedList pubkeys={muted.map(a => a.id)} />;
}
case ProfileTabType.BLOCKED: {
return <BlockList />;
}
case ProfileTabType.RELAYS: {
return <RelaysTab id={id} />;
}
case ProfileTabType.BOOKMARKS: {
return <BookMarksTab id={id} />;
}
}
}
function avatar() {
return (
<div className="avatar-wrapper w-max">
<Avatar pubkey={id ?? ""} user={user} onClick={() => setModalImage(user?.picture || "")} className="pointer" />
<div className="profile-actions">
{renderIcons()}
{!isMe && id && <FollowButton pubkey={id} />}
</div>
</div>
);
}
function renderIcons() {
if (!id) return;
const link = encodeTLV(NostrPrefix.Profile, id);
return (
<>
<IconButton onClick={() => setShowProfileQr(true)} icon={{ name: "qr", size: 16 }} />
{showProfileQr && (
<Modal id="profile-qr" className="qr-modal" onClose={() => setShowProfileQr(false)}>
<ProfileImage pubkey={id} />
<QrCode data={link} className="m10 align-center" />
<Copy text={link} className="align-center" />
</Modal>
)}
{isMe ? (
<>
<button type="button" onClick={() => navigate("/settings")}>
<FormattedMessage {...messages.Settings} />
</button>
</>
) : (
<>
{lnurl && <IconButton onClick={() => setShowLnQr(true)} icon={{ name: "zap", size: 16 }} />}
{loginPubKey && !login.readonly && (
<>
<IconButton
onClick={() =>
navigate(
`/messages/${encodeTLVEntries("chat4" as NostrPrefix, {
type: TLVEntryType.Author,
length: 32,
value: id,
})}`,
)
}
icon={{ name: "envelope", size: 16 }}
/>
</>
)}
</>
)}
</>
);
}
function userDetails() {
if (!id) return;
return (
<div className="details-wrapper w-max">
{username()}
{bio()}
</div>
);
}
function renderTab(v: Tab) {
return <TabElement key={v.value} t={v} tab={tab} setTab={setTab} />;
}
const w = window.document.querySelector(".page")?.clientWidth;
return (
<>
<div className="profile">
{user?.banner && (
<ProxyImg
alt="banner"
className="banner pointer"
src={user.banner}
size={w}
onClick={() => setModalImage(user.banner || "")}
/>
)}
<div className="profile-wrapper w-max">
{avatar()}
{userDetails()}
</div>
</div>
<div className="main-content">
<div className="tabs p" ref={horizontalScroll}>
{[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows].map(renderTab)}
{optionalTabs.map(renderTab)}
{isMe && blocked.length > 0 && renderTab(ProfileTab.Blocked)}
</div>
</div>
<div className="main-content">{tabContent()}</div>
{modalImage && <SpotlightMediaModal onClose={() => setModalImage("")} images={[modalImage]} idx={0} />}
</>
);
}