add "open in" menus to posts and users

add basic event view
make user links use npub format
cleanup icons
This commit is contained in:
hzrd149
2023-02-07 17:04:18 -06:00
parent 6b45f6b42e
commit f8a77b8246
22 changed files with 423 additions and 136 deletions

View File

@@ -1,12 +1,13 @@
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
import { HomeView } from "./views/home";
import { UserView } from "./views/user";
import { UserPage } from "./views/user";
import { ErrorBoundary } from "./components/error-boundary";
import { Page } from "./components/page";
import { SettingsView } from "./views/settings";
import { GlobalView } from "./views/global";
import { LoginView } from "./views/login";
import { ProfileView } from "./views/profile";
import { EventPage } from "./views/event";
import useSubject from "./hooks/use-subject";
import identity from "./services/identity";
@@ -29,9 +30,15 @@ export const App = () => {
path="/user/:pubkey"
element={
<RequireSetup>
<Page>
<UserView />
</Page>
<UserPage />
</RequireSetup>
}
/>
<Route
path="/e/:id"
element={
<RequireSetup>
<EventPage />
</RequireSetup>
}
/>

46
src/components/icons.tsx Normal file
View File

@@ -0,0 +1,46 @@
import { createIcon } from "@chakra-ui/icons";
import astralIcon from "./icons/astral.png";
import nostrGuruIcon from "./icons/nostr-guru.jpg";
import brbIcon from "./icons/brb.png";
export const IMAGE_ICONS = {
astralIcon,
nostrGuruIcon,
brbIcon,
};
export const GlobalIcon = createIcon({
displayName: "global-line",
d: "M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm-2.29-2.333A17.9 17.9 0 0 1 8.027 13H4.062a8.008 8.008 0 0 0 5.648 6.667zM10.03 13c.151 2.439.848 4.73 1.97 6.752A15.905 15.905 0 0 0 13.97 13h-3.94zm9.908 0h-3.965a17.9 17.9 0 0 1-1.683 6.667A8.008 8.008 0 0 0 19.938 13zM4.062 11h3.965A17.9 17.9 0 0 1 9.71 4.333 8.008 8.008 0 0 0 4.062 11zm5.969 0h3.938A15.905 15.905 0 0 0 12 4.248 15.905 15.905 0 0 0 10.03 11zm4.259-6.667A17.9 17.9 0 0 1 15.973 11h3.965a8.008 8.008 0 0 0-5.648-6.667z",
});
export const HomeIcon = createIcon({
displayName: "home-line",
d: "M21 20a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V9.49a1 1 0 0 1 .386-.79l8-6.222a1 1 0 0 1 1.228 0l8 6.222a1 1 0 0 1 .386.79V20zm-2-1V9.978l-7-5.444-7 5.444V19h14z",
});
export const MoreIcon = createIcon({
displayName: "more-line",
d: "M4.5 10.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S6 12.825 6 12s-.675-1.5-1.5-1.5zm15 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S21 12.825 21 12s-.675-1.5-1.5-1.5zm-7.5 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z",
});
export const CodeIcon = createIcon({
displayName: "code-line",
d: `M23 12l-7.071 7.071-1.414-1.414L20.172 12l-5.657-5.657 1.414-1.414L23 12zM3.828 12l5.657 5.657-1.414 1.414L1 12l7.071-7.071 1.414 1.414L3.828 12z`,
});
export const SettingsIcon = createIcon({
displayName: "settings-2-line",
d: "M8.686 4l2.607-2.607a1 1 0 0 1 1.414 0L15.314 4H19a1 1 0 0 1 1 1v3.686l2.607 2.607a1 1 0 0 1 0 1.414L20 15.314V19a1 1 0 0 1-1 1h-3.686l-2.607 2.607a1 1 0 0 1-1.414 0L8.686 20H5a1 1 0 0 1-1-1v-3.686l-2.607-2.607a1 1 0 0 1 0-1.414L4 8.686V5a1 1 0 0 1 1-1h3.686zM6 6v3.515L3.515 12 6 14.485V18h3.515L12 20.485 14.485 18H18v-3.515L20.485 12 18 9.515V6h-3.515L12 3.515 9.515 6H6zm6 10a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm0-2a2 2 0 1 0 0-4 2 2 0 0 0 0 4z",
});
export const LogoutIcon = createIcon({
displayName: "logout-box-line",
d: "M4 18h2v2h12V4H6v2H4V3a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-3zm2-7h7v2H6v3l-5-4 5-4v3z",
});
export const ProfileIcon = createIcon({
displayName: "user-line",
d: "M4 22a8 8 0 1 1 16 0h-2a6 6 0 1 0-12 0H4zm8-9c-3.315 0-6-2.685-6-6s2.685-6 6-6 6 2.685 6 6-2.685 6-6 6zm0-2c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4z",
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M23 12l-7.071 7.071-1.414-1.414L20.172 12l-5.657-5.657 1.414-1.414L23 12zM3.828 12l5.657 5.657-1.414 1.414L1 12l7.071-7.071 1.414 1.414L3.828 12z"/></svg>

Before

Width:  |  Height:  |  Size: 284 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm-2.29-2.333A17.9 17.9 0 0 1 8.027 13H4.062a8.008 8.008 0 0 0 5.648 6.667zM10.03 13c.151 2.439.848 4.73 1.97 6.752A15.905 15.905 0 0 0 13.97 13h-3.94zm9.908 0h-3.965a17.9 17.9 0 0 1-1.683 6.667A8.008 8.008 0 0 0 19.938 13zM4.062 11h3.965A17.9 17.9 0 0 1 9.71 4.333 8.008 8.008 0 0 0 4.062 11zm5.969 0h3.938A15.905 15.905 0 0 0 12 4.248 15.905 15.905 0 0 0 10.03 11zm4.259-6.667A17.9 17.9 0 0 1 15.973 11h3.965a8.008 8.008 0 0 0-5.648-6.667z"/></svg>

Before

Width:  |  Height:  |  Size: 652 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M21 20a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V9.49a1 1 0 0 1 .386-.79l8-6.222a1 1 0 0 1 1.228 0l8 6.222a1 1 0 0 1 .386.79V20zm-2-1V9.978l-7-5.444-7 5.444V19h14z"/></svg>

Before

Width:  |  Height:  |  Size: 290 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M8.686 4l2.607-2.607a1 1 0 0 1 1.414 0L15.314 4H19a1 1 0 0 1 1 1v3.686l2.607 2.607a1 1 0 0 1 0 1.414L20 15.314V19a1 1 0 0 1-1 1h-3.686l-2.607 2.607a1 1 0 0 1-1.414 0L8.686 20H5a1 1 0 0 1-1-1v-3.686l-2.607-2.607a1 1 0 0 1 0-1.414L4 8.686V5a1 1 0 0 1 1-1h3.686zM6 6v3.515L3.515 12 6 14.485V18h3.515L12 20.485 14.485 18H18v-3.515L20.485 12 18 9.515V6h-3.515L12 3.515 9.515 6H6zm6 10a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm0-2a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"/></svg>

Before

Width:  |  Height:  |  Size: 580 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M4 22a8 8 0 1 1 16 0h-2a6 6 0 1 0-12 0H4zm8-9c-3.315 0-6-2.685-6-6s2.685-6 6-6 6 2.685 6 6-2.685 6-6 6zm0-2c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4z"/></svg>

Before

Width:  |  Height:  |  Size: 299 B

View File

@@ -0,0 +1,25 @@
import {
Menu,
MenuButton,
MenuList,
IconButton,
MenuListProps,
} from "@chakra-ui/react";
import { MoreIcon } from "./icons";
export type MenuIconButtonProps = {
children: MenuListProps["children"];
};
export const MenuIconButton = ({ children }: MenuIconButtonProps) => (
<Menu isLazy>
<MenuButton
as={IconButton}
icon={<MoreIcon />}
aria-label="view raw"
title="view raw"
size="xs"
/>
<MenuList>{children}</MenuList>
</Menu>
);

View File

@@ -4,13 +4,16 @@ import { useNavigate } from "react-router-dom";
import { ErrorBoundary } from "./error-boundary";
import { ConnectedRelays } from "./connected-relays";
import homeIcon from "./icons/home-line.svg";
import globalIcon from "./icons/global-line.svg";
import settingsIcon from "./icons/settings-2-line.svg";
import profileIcon from "./icons/user-line.svg";
import { useIsMobile } from "../hooks/use-is-mobile";
import { ProfileButton } from "./profile-button";
import identity from "../services/identity";
import {
GlobalIcon,
HomeIcon,
LogoutIcon,
ProfileIcon,
SettingsIcon,
} from "./icons";
const MobileLayout = ({ children }: { children: React.ReactNode }) => {
const navigate = useNavigate();
@@ -22,28 +25,28 @@ const MobileLayout = ({ children }: { children: React.ReactNode }) => {
</Flex>
<Flex flexShrink={0} gap="2" padding="2">
<IconButton
icon={<img src={homeIcon} />}
icon={<HomeIcon />}
aria-label="Home"
onClick={() => navigate("/")}
flexGrow="1"
size="lg"
/>
<IconButton
icon={<img src={globalIcon} />}
icon={<GlobalIcon />}
aria-label="Global Feed"
onClick={() => navigate("/global")}
flexGrow="1"
size="lg"
/>
<IconButton
icon={<img src={profileIcon} />}
icon={<ProfileIcon />}
aria-label="Profile"
onClick={() => navigate(`/profile`)}
flexGrow="1"
size="lg"
/>
<IconButton
icon={<img src={settingsIcon} />}
icon={<SettingsIcon />}
aria-label="Settings"
onClick={() => navigate("/settings")}
flexGrow="1"
@@ -66,10 +69,21 @@ const DesktopLayout = ({ children }: { children: React.ReactNode }) => {
>
<VStack width="15rem" pt="2" alignItems="stretch" flexShrink={0}>
<ProfileButton to="/profile" />
<Button onClick={() => navigate("/")}>Home</Button>
<Button onClick={() => navigate("/global")}>Global Feed</Button>
<Button onClick={() => navigate("/settings")}>Settings</Button>
<Button onClick={() => identity.logout()}>Logout</Button>
<Button onClick={() => navigate("/")} leftIcon={<HomeIcon />}>
Home
</Button>
<Button onClick={() => navigate("/global")} leftIcon={<GlobalIcon />}>
Global Feed
</Button>
<Button
onClick={() => navigate("/settings")}
leftIcon={<SettingsIcon />}
>
Settings
</Button>
<Button onClick={() => identity.logout()} leftIcon={<LogoutIcon />}>
Logout
</Button>
<ConnectedRelays />
</VStack>
<Flex flexGrow={1} direction="column" overflow="hidden">

View File

@@ -1,88 +0,0 @@
import React, { useRef } from "react";
import {
Box,
Button,
Card,
CardBody,
CardHeader,
Flex,
Heading,
HStack,
IconButton,
Text,
useDisclosure,
VStack,
} from "@chakra-ui/react";
import { Link } from "react-router-dom";
import moment from "moment";
import { PostModal } from "./post-modal";
import { NostrEvent } from "../types/nostr-event";
import { useUserMetadata } from "../hooks/use-user-metadata";
import { UserAvatarLink } from "./user-avatar-link";
import { getUserFullName } from "../helpers/user-metadata";
import codeIcon from "./icons/code-line.svg";
import styled from "@emotion/styled";
import { PostContents } from "./post-contents";
const SimpleIcon = styled.img`
width: 1.2em;
`;
export type PostProps = {
event: NostrEvent;
};
export const Post = React.memo(({ event }: PostProps) => {
const { isOpen, onClose, onOpen } = useDisclosure();
const { metadata } = useUserMetadata(event.pubkey);
const isLong = event.content.length > 800;
const username = metadata
? getUserFullName(metadata) || event.pubkey
: event.pubkey;
return (
<Card padding="2" variant="outline">
<CardHeader padding="0">
<HStack spacing="4">
<Flex flex="1" gap="2" alignItems="center" flexWrap="wrap">
<UserAvatarLink pubkey={event.pubkey} size="sm" />
<Box>
<Heading size="sm">
<Link to={`/user/${event.pubkey}`}>{username}</Link>
</Heading>
<Text>{moment(event.created_at * 1000).fromNow()}</Text>
</Box>
</Flex>
<IconButton
alignSelf="flex-start"
icon={<SimpleIcon src={codeIcon} />}
aria-label="view raw"
title="view raw"
size="xs"
variant="link"
onClick={() =>
window.open(`https://www.nostr.guru/e/${event.id}`, "_blank")
}
/>
</HStack>
</CardHeader>
<CardBody pt="2" pb="0" pr="0" pl="0">
<VStack alignItems="flex-start" justifyContent="stretch">
<Box overflow="hidden" width="100%">
<PostContents content={event.content} maxChars={300} />
</Box>
{isLong && (
<>
<Button size="sm" variant="link" onClick={onOpen}>
Read More
</Button>
<PostModal event={event} isOpen={isOpen} onClose={onClose} />
</>
)}
</VStack>
</CardBody>
</Card>
);
});

View File

@@ -0,0 +1,92 @@
import React from "react";
import { Link, useNavigate } from "react-router-dom";
import moment from "moment";
import {
Box,
Button,
Card,
CardBody,
CardFooter,
CardHeader,
Flex,
Heading,
HStack,
Text,
useDisclosure,
VStack,
} from "@chakra-ui/react";
import { useCopyToClipboard } from "react-use";
import { PostModal } from "../post-modal";
import { NostrEvent } from "../../types/nostr-event";
import { useUserMetadata } from "../../hooks/use-user-metadata";
import { UserAvatarLink } from "../user-avatar-link";
import { getUserFullName } from "../../helpers/user-metadata";
import { Bech32Prefix, normalizeToBech32 } from "../../helpers/nip-19";
import { PostContents } from "../post-contents";
import { PostMenu } from "./post-menu";
export type PostProps = {
event: NostrEvent;
};
export const Post = React.memo(({ event }: PostProps) => {
const [_clipboardState, copyToClipboard] = useCopyToClipboard();
const navigate = useNavigate();
const { isOpen, onClose, onOpen } = useDisclosure();
const { metadata } = useUserMetadata(event.pubkey);
const username = metadata
? getUserFullName(metadata) || event.pubkey
: event.pubkey;
return (
<Card padding="2" variant="outline">
<CardHeader padding="0" mb="2">
<HStack spacing="4">
<Flex flex="1" gap="2" alignItems="center" flexWrap="wrap">
<UserAvatarLink pubkey={event.pubkey} size="sm" />
<Box>
<Heading size="sm">
<Link
to={`/user/${normalizeToBech32(
event.pubkey,
Bech32Prefix.Pubkey
)}`}
>
{username}
</Link>
</Heading>
<Text>{moment(event.created_at * 1000).fromNow()}</Text>
</Box>
</Flex>
<PostMenu event={event} />
</HStack>
</CardHeader>
<CardBody padding="0" mb="2">
<VStack alignItems="flex-start" justifyContent="stretch">
<Box overflow="hidden" width="100%">
<PostContents content={event.content} maxChars={300} />
</Box>
</VStack>
</CardBody>
<CardFooter padding="0">
<Flex gap="2">
<Button
size="sm"
variant="link"
onClick={() =>
navigate(`/e/${normalizeToBech32(event.id, Bech32Prefix.Note)}`)
}
>
Replies
</Button>
<Button size="sm" variant="link" onClick={onOpen}>
Expand
</Button>
</Flex>
<PostModal event={event} isOpen={isOpen} onClose={onClose} />
</CardFooter>
</Card>
);
});

View File

@@ -0,0 +1,48 @@
import { Avatar, MenuItem } from "@chakra-ui/react";
import { useCopyToClipboard } from "react-use";
import { Bech32Prefix, normalizeToBech32 } from "../../helpers/nip-19";
import { NostrEvent } from "../../types/nostr-event";
import { MenuIconButton } from "../menu-icon-button";
import { truncatedId } from "../../helpers/nostr-event";
import { IMAGE_ICONS } from "../icons";
export const PostMenu = ({ event }: { event: NostrEvent }) => {
const [_clipboardState, copyToClipboard] = useCopyToClipboard();
return (
<MenuIconButton>
<MenuItem
as="a"
icon={<Avatar src={IMAGE_ICONS.nostrGuruIcon} size="xs" />}
href={`https://www.nostr.guru/e/${event.id}`}
target="_blank"
>
Open in Nostr.guru
</MenuItem>
<MenuItem
as="a"
icon={<Avatar src={IMAGE_ICONS.astralIcon} size="xs" />}
href={`https://astral.ninja/${normalizeToBech32(
event.id,
Bech32Prefix.Note
)}`}
target="_blank"
>
Open in astral
</MenuItem>
<MenuItem
as="a"
icon={<Avatar src={IMAGE_ICONS.brbIcon} size="xs" />}
href={`https://brb.io/n/${event.id}`}
target="_blank"
>
Open in BRB
</MenuItem>
<MenuItem onClick={() => copyToClipboard(event.id)}>
Copy {truncatedId(event.id)}
</MenuItem>
</MenuIconButton>
);
};

View File

@@ -1,26 +1,29 @@
import React from "react";
import { Tooltip } from "@chakra-ui/react";
import { Link } from "react-router-dom";
import { normalizeToBech32 } from "../helpers/nip-19";
import { Bech32Prefix, normalizeToBech32 } from "../helpers/nip-19";
import { useUserMetadata } from "../hooks/use-user-metadata";
import { UserAvatar, UserAvatarProps } from "./user-avatar";
export const UserAvatarLink = ({ pubkey, ...props }: UserAvatarProps) => {
const { metadata } = useUserMetadata(pubkey);
export const UserAvatarLink = React.memo(
({ pubkey, ...props }: UserAvatarProps) => {
const { metadata } = useUserMetadata(pubkey);
let label = "Loading...";
if (metadata?.display_name && metadata?.name) {
label = `${metadata.display_name} (${metadata.name})`;
} else if (metadata?.name) {
label = metadata.name;
} else {
label = normalizeToBech32(pubkey) ?? pubkey;
let label = "Loading...";
if (metadata?.display_name && metadata?.name) {
label = `${metadata.display_name} (${metadata.name})`;
} else if (metadata?.name) {
label = metadata.name;
} else {
label = normalizeToBech32(pubkey) ?? pubkey;
}
return (
<Tooltip label={label}>
<Link to={`/user/${normalizeToBech32(pubkey, Bech32Prefix.Pubkey)}`}>
<UserAvatar pubkey={pubkey} {...props} />
</Link>
</Tooltip>
);
}
return (
<Tooltip label={label}>
<Link to={`/user/${pubkey}`}>
<UserAvatar pubkey={pubkey} {...props} />
</Link>
</Tooltip>
);
};
);

View File

@@ -7,3 +7,7 @@ export function isReply(event: NostrEvent) {
export function isPost(event: NostrEvent) {
return !isReply(event);
}
export function truncatedId(id: string) {
return id.substring(0, 6) + "..." + id.substring(id.length - 6);
}

View File

@@ -2,6 +2,7 @@ import { useObservable } from "react-use";
import { BehaviorSubject, Subject } from "rxjs";
function useSubject<T>(subject: BehaviorSubject<T>): T;
function useSubject<T>(subject: Subject<T>): T | undefined;
function useSubject<T>(subject: Subject<T>): T | undefined {
if (subject instanceof BehaviorSubject) {
return useObservable(subject, subject.getValue());

View File

@@ -13,8 +13,8 @@ export type NostrQuery = {
ids?: string[];
authors?: string[];
kinds?: number[];
// "#e": <a list of event ids that are referenced in an "e" tag>,
// "#p": <a list of pubkeys that are referenced in a "p" tag>,
"#e"?: string[];
"#p"?: string[];
since?: number;
until?: number;
limit?: number;

68
src/views/event.tsx Normal file
View File

@@ -0,0 +1,68 @@
import {
Alert,
AlertDescription,
AlertIcon,
AlertTitle,
Flex,
} from "@chakra-ui/react";
import useSubject from "../hooks/use-subject";
import settings from "../services/settings";
import { useSubscription } from "../hooks/use-subscription";
import { Page } from "../components/page";
import { useParams } from "react-router-dom";
import { normalizeToHex } from "../helpers/nip-19";
import { Post } from "../components/post";
import { useEventDir } from "../hooks/use-event-dir";
export const EventPage = () => {
const params = useParams();
let id = normalizeToHex(params.id ?? "");
if (!id) {
return (
<Page>
<Alert status="error">
<AlertIcon />
<AlertTitle>Invalid event id</AlertTitle>
<AlertDescription>
"{params.id}" dose not look like a valid event id
</AlertDescription>
</Alert>
</Page>
);
}
return (
<Page>
<EventView eventId={id} />
</Page>
);
};
export type EventViewProps = {
/** id of event in hex format */
eventId: string;
};
export const EventView = ({ eventId }: EventViewProps) => {
const relays = useSubject(settings.relays);
const eventSub = useSubscription(relays, { ids: [eventId] });
const event = useSubject(eventSub.onEvent);
const replySub = useSubscription(relays, { "#e": [eventId] });
const { events } = useEventDir(replySub);
const timeline = Object.values(events).sort(
(a, b) => b.created_at - a.created_at
);
return (
<Flex direction="column" gap="2" flexGrow="1" overflow="auto">
{event && <Post event={event} />}
{timeline.map((event) => (
<Post key={event.id} event={event} />
))}
</Flex>
);
};

View File

@@ -1,4 +1,8 @@
import {
Alert,
AlertDescription,
AlertIcon,
AlertTitle,
Flex,
Heading,
SkeletonText,
@@ -8,6 +12,7 @@ import {
TabPanels,
Tabs,
Text,
Box,
} from "@chakra-ui/react";
import { useParams } from "react-router-dom";
import { UserPostsTab } from "./posts";
@@ -19,17 +24,40 @@ import { UserRelaysTab } from "./relays";
import { UserFollowingTab } from "./following";
import { UserRepliesTab } from "./replies";
import { normalizeToBech32, normalizeToHex } from "../../helpers/nip-19";
import { Page } from "../../components/page";
import { UserProfileMenu } from "./user-profile-menu";
export const UserView = () => {
const isMobile = useIsMobile();
export const UserPage = () => {
const params = useParams();
let id = normalizeToHex(params.pubkey ?? "");
if (!params.pubkey) {
// TODO: better 404
throw new Error("No pubkey");
if (!id) {
return (
<Page>
<Alert status="error">
<AlertIcon />
<AlertTitle>Invalid pubkey</AlertTitle>
<AlertDescription>
"{params.pubkey}" dose not look like a valid pubkey
</AlertDescription>
</Alert>
</Page>
);
}
const pubkey = normalizeToHex(params.pubkey) ?? "";
return (
<Page>
<UserView pubkey={id} />
</Page>
);
};
export type UserViewProps = {
pubkey: string;
};
export const UserView = ({ pubkey }: UserViewProps) => {
const isMobile = useIsMobile();
const { metadata, loading: loadingMetadata } = useUserMetadata(pubkey, true);
const bech32Key = normalizeToBech32(pubkey);
@@ -49,6 +77,9 @@ export const UserView = () => {
<Heading size={isMobile ? "md" : "lg"}>{label}</Heading>
{loadingMetadata ? <SkeletonText /> : <Text>{metadata?.about}</Text>}
</Flex>
<Box ml="auto">
<UserProfileMenu pubkey={pubkey} />
</Box>
</Flex>
<Tabs
display="flex"

View File

@@ -0,0 +1,41 @@
import { Avatar, MenuItem } from "@chakra-ui/react";
import { MenuIconButton } from "../../components/menu-icon-button";
import { IMAGE_ICONS } from "../../components/icons";
import { Bech32Prefix, normalizeToBech32 } from "../../helpers/nip-19";
import { useCopyToClipboard } from "react-use";
import { truncatedId } from "../../helpers/nostr-event";
export const UserProfileMenu = ({ pubkey }: { pubkey: string }) => {
return (
<MenuIconButton>
<MenuItem
as="a"
icon={<Avatar src={IMAGE_ICONS.nostrGuruIcon} size="xs" />}
href={`https://www.nostr.guru/p/${pubkey}`}
target="_blank"
>
Open in Nostr.guru
</MenuItem>
<MenuItem
as="a"
icon={<Avatar src={IMAGE_ICONS.astralIcon} size="xs" />}
href={`https://astral.ninja/${normalizeToBech32(
pubkey,
Bech32Prefix.Pubkey
)}`}
target="_blank"
>
Open in astral
</MenuItem>
<MenuItem
as="a"
icon={<Avatar src={IMAGE_ICONS.brbIcon} size="xs" />}
href={`https://brb.io/u/${pubkey}`}
target="_blank"
>
Open in BRB
</MenuItem>
</MenuIconButton>
);
};