390 lines
12 KiB
TypeScript
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} />}
|
|
</>
|
|
);
|
|
}
|