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 { Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||||
import { HomeView } from "./views/home";
|
import { HomeView } from "./views/home";
|
||||||
import { UserView } from "./views/user";
|
import { UserPage } from "./views/user";
|
||||||
import { ErrorBoundary } from "./components/error-boundary";
|
import { ErrorBoundary } from "./components/error-boundary";
|
||||||
import { Page } from "./components/page";
|
import { Page } from "./components/page";
|
||||||
import { SettingsView } from "./views/settings";
|
import { SettingsView } from "./views/settings";
|
||||||
import { GlobalView } from "./views/global";
|
import { GlobalView } from "./views/global";
|
||||||
import { LoginView } from "./views/login";
|
import { LoginView } from "./views/login";
|
||||||
import { ProfileView } from "./views/profile";
|
import { ProfileView } from "./views/profile";
|
||||||
|
import { EventPage } from "./views/event";
|
||||||
import useSubject from "./hooks/use-subject";
|
import useSubject from "./hooks/use-subject";
|
||||||
import identity from "./services/identity";
|
import identity from "./services/identity";
|
||||||
|
|
||||||
@@ -29,9 +30,15 @@ export const App = () => {
|
|||||||
path="/user/:pubkey"
|
path="/user/:pubkey"
|
||||||
element={
|
element={
|
||||||
<RequireSetup>
|
<RequireSetup>
|
||||||
<Page>
|
<UserPage />
|
||||||
<UserView />
|
</RequireSetup>
|
||||||
</Page>
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/e/:id"
|
||||||
|
element={
|
||||||
|
<RequireSetup>
|
||||||
|
<EventPage />
|
||||||
</RequireSetup>
|
</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 { ErrorBoundary } from "./error-boundary";
|
||||||
import { ConnectedRelays } from "./connected-relays";
|
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 { useIsMobile } from "../hooks/use-is-mobile";
|
||||||
import { ProfileButton } from "./profile-button";
|
import { ProfileButton } from "./profile-button";
|
||||||
import identity from "../services/identity";
|
import identity from "../services/identity";
|
||||||
|
import {
|
||||||
|
GlobalIcon,
|
||||||
|
HomeIcon,
|
||||||
|
LogoutIcon,
|
||||||
|
ProfileIcon,
|
||||||
|
SettingsIcon,
|
||||||
|
} from "./icons";
|
||||||
|
|
||||||
const MobileLayout = ({ children }: { children: React.ReactNode }) => {
|
const MobileLayout = ({ children }: { children: React.ReactNode }) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -22,28 +25,28 @@ const MobileLayout = ({ children }: { children: React.ReactNode }) => {
|
|||||||
</Flex>
|
</Flex>
|
||||||
<Flex flexShrink={0} gap="2" padding="2">
|
<Flex flexShrink={0} gap="2" padding="2">
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={<img src={homeIcon} />}
|
icon={<HomeIcon />}
|
||||||
aria-label="Home"
|
aria-label="Home"
|
||||||
onClick={() => navigate("/")}
|
onClick={() => navigate("/")}
|
||||||
flexGrow="1"
|
flexGrow="1"
|
||||||
size="lg"
|
size="lg"
|
||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={<img src={globalIcon} />}
|
icon={<GlobalIcon />}
|
||||||
aria-label="Global Feed"
|
aria-label="Global Feed"
|
||||||
onClick={() => navigate("/global")}
|
onClick={() => navigate("/global")}
|
||||||
flexGrow="1"
|
flexGrow="1"
|
||||||
size="lg"
|
size="lg"
|
||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={<img src={profileIcon} />}
|
icon={<ProfileIcon />}
|
||||||
aria-label="Profile"
|
aria-label="Profile"
|
||||||
onClick={() => navigate(`/profile`)}
|
onClick={() => navigate(`/profile`)}
|
||||||
flexGrow="1"
|
flexGrow="1"
|
||||||
size="lg"
|
size="lg"
|
||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={<img src={settingsIcon} />}
|
icon={<SettingsIcon />}
|
||||||
aria-label="Settings"
|
aria-label="Settings"
|
||||||
onClick={() => navigate("/settings")}
|
onClick={() => navigate("/settings")}
|
||||||
flexGrow="1"
|
flexGrow="1"
|
||||||
@@ -66,10 +69,21 @@ const DesktopLayout = ({ children }: { children: React.ReactNode }) => {
|
|||||||
>
|
>
|
||||||
<VStack width="15rem" pt="2" alignItems="stretch" flexShrink={0}>
|
<VStack width="15rem" pt="2" alignItems="stretch" flexShrink={0}>
|
||||||
<ProfileButton to="/profile" />
|
<ProfileButton to="/profile" />
|
||||||
<Button onClick={() => navigate("/")}>Home</Button>
|
<Button onClick={() => navigate("/")} leftIcon={<HomeIcon />}>
|
||||||
<Button onClick={() => navigate("/global")}>Global Feed</Button>
|
Home
|
||||||
<Button onClick={() => navigate("/settings")}>Settings</Button>
|
</Button>
|
||||||
<Button onClick={() => identity.logout()}>Logout</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 />
|
<ConnectedRelays />
|
||||||
</VStack>
|
</VStack>
|
||||||
<Flex flexGrow={1} direction="column" overflow="hidden">
|
<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 { Tooltip } from "@chakra-ui/react";
|
||||||
import { Link } from "react-router-dom";
|
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 { useUserMetadata } from "../hooks/use-user-metadata";
|
||||||
import { UserAvatar, UserAvatarProps } from "./user-avatar";
|
import { UserAvatar, UserAvatarProps } from "./user-avatar";
|
||||||
|
|
||||||
export const UserAvatarLink = ({ pubkey, ...props }: UserAvatarProps) => {
|
export const UserAvatarLink = React.memo(
|
||||||
const { metadata } = useUserMetadata(pubkey);
|
({ pubkey, ...props }: UserAvatarProps) => {
|
||||||
|
const { metadata } = useUserMetadata(pubkey);
|
||||||
|
|
||||||
let label = "Loading...";
|
let label = "Loading...";
|
||||||
if (metadata?.display_name && metadata?.name) {
|
if (metadata?.display_name && metadata?.name) {
|
||||||
label = `${metadata.display_name} (${metadata.name})`;
|
label = `${metadata.display_name} (${metadata.name})`;
|
||||||
} else if (metadata?.name) {
|
} else if (metadata?.name) {
|
||||||
label = metadata.name;
|
label = metadata.name;
|
||||||
} else {
|
} else {
|
||||||
label = normalizeToBech32(pubkey) ?? pubkey;
|
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) {
|
export function isPost(event: NostrEvent) {
|
||||||
return !isReply(event);
|
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";
|
import { BehaviorSubject, Subject } from "rxjs";
|
||||||
|
|
||||||
function useSubject<T>(subject: BehaviorSubject<T>): T;
|
function useSubject<T>(subject: BehaviorSubject<T>): T;
|
||||||
|
function useSubject<T>(subject: Subject<T>): T | undefined;
|
||||||
function useSubject<T>(subject: Subject<T>): T | undefined {
|
function useSubject<T>(subject: Subject<T>): T | undefined {
|
||||||
if (subject instanceof BehaviorSubject) {
|
if (subject instanceof BehaviorSubject) {
|
||||||
return useObservable(subject, subject.getValue());
|
return useObservable(subject, subject.getValue());
|
||||||
|
@@ -13,8 +13,8 @@ export type NostrQuery = {
|
|||||||
ids?: string[];
|
ids?: string[];
|
||||||
authors?: string[];
|
authors?: string[];
|
||||||
kinds?: number[];
|
kinds?: number[];
|
||||||
// "#e": <a list of event ids that are referenced in an "e" tag>,
|
"#e"?: string[];
|
||||||
// "#p": <a list of pubkeys that are referenced in a "p" tag>,
|
"#p"?: string[];
|
||||||
since?: number;
|
since?: number;
|
||||||
until?: number;
|
until?: number;
|
||||||
limit?: 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 {
|
import {
|
||||||
|
Alert,
|
||||||
|
AlertDescription,
|
||||||
|
AlertIcon,
|
||||||
|
AlertTitle,
|
||||||
Flex,
|
Flex,
|
||||||
Heading,
|
Heading,
|
||||||
SkeletonText,
|
SkeletonText,
|
||||||
@@ -8,6 +12,7 @@ import {
|
|||||||
TabPanels,
|
TabPanels,
|
||||||
Tabs,
|
Tabs,
|
||||||
Text,
|
Text,
|
||||||
|
Box,
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { UserPostsTab } from "./posts";
|
import { UserPostsTab } from "./posts";
|
||||||
@@ -19,17 +24,40 @@ import { UserRelaysTab } from "./relays";
|
|||||||
import { UserFollowingTab } from "./following";
|
import { UserFollowingTab } from "./following";
|
||||||
import { UserRepliesTab } from "./replies";
|
import { UserRepliesTab } from "./replies";
|
||||||
import { normalizeToBech32, normalizeToHex } from "../../helpers/nip-19";
|
import { normalizeToBech32, normalizeToHex } from "../../helpers/nip-19";
|
||||||
|
import { Page } from "../../components/page";
|
||||||
|
import { UserProfileMenu } from "./user-profile-menu";
|
||||||
|
|
||||||
export const UserView = () => {
|
export const UserPage = () => {
|
||||||
const isMobile = useIsMobile();
|
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
let id = normalizeToHex(params.pubkey ?? "");
|
||||||
|
|
||||||
if (!params.pubkey) {
|
if (!id) {
|
||||||
// TODO: better 404
|
return (
|
||||||
throw new Error("No pubkey");
|
<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 { metadata, loading: loadingMetadata } = useUserMetadata(pubkey, true);
|
||||||
const bech32Key = normalizeToBech32(pubkey);
|
const bech32Key = normalizeToBech32(pubkey);
|
||||||
@@ -49,6 +77,9 @@ export const UserView = () => {
|
|||||||
<Heading size={isMobile ? "md" : "lg"}>{label}</Heading>
|
<Heading size={isMobile ? "md" : "lg"}>{label}</Heading>
|
||||||
{loadingMetadata ? <SkeletonText /> : <Text>{metadata?.about}</Text>}
|
{loadingMetadata ? <SkeletonText /> : <Text>{metadata?.about}</Text>}
|
||||||
</Flex>
|
</Flex>
|
||||||
|
<Box ml="auto">
|
||||||
|
<UserProfileMenu pubkey={pubkey} />
|
||||||
|
</Box>
|
||||||
</Flex>
|
</Flex>
|
||||||
<Tabs
|
<Tabs
|
||||||
display="flex"
|
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>
|
||||||
|
);
|
||||||
|
};
|