add "open in" menus to posts and users
add basic event view make user links use npub format cleanup icons
15
src/app.tsx
@@ -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
@@ -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",
|
||||
});
|
BIN
src/components/icons/astral.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
src/components/icons/brb.png
Normal file
After Width: | Height: | Size: 26 KiB |
@@ -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 |
@@ -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 |
@@ -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 |
BIN
src/components/icons/nostr-guru.jpg
Normal file
After Width: | Height: | Size: 4.0 KiB |
@@ -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 |
@@ -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 |
25
src/components/menu-icon-button.tsx
Normal 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>
|
||||
);
|
@@ -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">
|
||||
|
@@ -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>
|
||||
);
|
||||
});
|
92
src/components/post/index.tsx
Normal 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>
|
||||
);
|
||||
});
|
48
src/components/post/post-menu.tsx
Normal 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>
|
||||
);
|
||||
};
|
@@ -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>
|
||||
);
|
||||
};
|
||||
);
|
||||
|
@@ -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);
|
||||
}
|
||||
|
@@ -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());
|
||||
|
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
@@ -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"
|
||||
|
41
src/views/user/user-profile-menu.tsx
Normal 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>
|
||||
);
|
||||
};
|