diff --git a/package.json b/package.json index 527377aa7..884e66db0 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@chakra-ui/react": "^2.4.4", "@emotion/react": "^11.10.5", "@emotion/styled": "^11.10.5", + "bech32-buffer": "^0.2.1", "framer-motion": "^7.10.3", "idb": "^7.1.1", "identicon.js": "^2.3.3", diff --git a/src/components/icons/code-line.svg b/src/components/icons/code-line.svg new file mode 100644 index 000000000..4ece30acc --- /dev/null +++ b/src/components/icons/code-line.svg @@ -0,0 +1 @@ + diff --git a/src/components/icons/global.svg b/src/components/icons/global-line.svg similarity index 100% rename from src/components/icons/global.svg rename to src/components/icons/global-line.svg diff --git a/src/components/icons/home.svg b/src/components/icons/home-line.svg similarity index 100% rename from src/components/icons/home.svg rename to src/components/icons/home-line.svg diff --git a/src/components/icons/settings.svg b/src/components/icons/settings-2-line.svg similarity index 100% rename from src/components/icons/settings.svg rename to src/components/icons/settings-2-line.svg diff --git a/src/components/icons/profile.svg b/src/components/icons/user-line.svg similarity index 100% rename from src/components/icons/profile.svg rename to src/components/icons/user-line.svg diff --git a/src/components/page.tsx b/src/components/page.tsx index 3c5447f93..78f5df821 100644 --- a/src/components/page.tsx +++ b/src/components/page.tsx @@ -11,10 +11,10 @@ import { useNavigate } from "react-router-dom"; import { ErrorBoundary } from "./error-boundary"; import { ConnectedRelays } from "./connected-relays"; -import homeIcon from "./icons/home.svg"; -import globalIcon from "./icons/global.svg"; -import settingsIcon from "./icons/settings.svg"; -import profileIcon from "./icons/profile.svg"; +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"; diff --git a/src/components/post.tsx b/src/components/post.tsx index 4cfa78f60..2351f3fae 100644 --- a/src/components/post.tsx +++ b/src/components/post.tsx @@ -8,6 +8,7 @@ import { Flex, Heading, HStack, + IconButton, Text, useDisclosure, VStack, @@ -21,6 +22,13 @@ 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"; + +const SimpleIcon = styled.img` + width: 1.2em; +`; + export type PostProps = { event: NostrEvent; }; @@ -47,6 +55,17 @@ export const Post = React.memo(({ event }: PostProps) => { {moment(event.created_at * 1000).fromNow()} + } + aria-label="view raw" + title="view raw" + size="xs" + variant="link" + onClick={() => + window.open(`https://www.nostr.guru/e/${event.id}`, "_blank") + } + /> diff --git a/src/components/profile-button.tsx b/src/components/profile-button.tsx index f33478fb5..561a55256 100644 --- a/src/components/profile-button.tsx +++ b/src/components/profile-button.tsx @@ -4,6 +4,7 @@ import useSubject from "../hooks/use-subject"; import identity from "../services/identity"; import { UserAvatar } from "./user-avatar"; import { useUserMetadata } from "../hooks/use-user-metadata"; +import { normalizeToBech32 } from "../helpers/nip-19"; export type ProfileButtonProps = { to: string; @@ -28,7 +29,7 @@ export const ProfileButton = ({ to }: ProfileButtonProps) => { {metadata?.name} - {pubkey} + {normalizeToBech32(pubkey)} ); diff --git a/src/components/user-avatar-link.tsx b/src/components/user-avatar-link.tsx index 9ca0fd0de..a46b7c75c 100644 --- a/src/components/user-avatar-link.tsx +++ b/src/components/user-avatar-link.tsx @@ -1,5 +1,6 @@ import { Tooltip } from "@chakra-ui/react"; import { Link } from "react-router-dom"; +import { normalizeToBech32 } from "../helpers/nip-19"; import { useUserMetadata } from "../hooks/use-user-metadata"; import { UserAvatar, UserAvatarProps } from "./user-avatar"; @@ -12,7 +13,7 @@ export const UserAvatarLink = ({ pubkey, ...props }: UserAvatarProps) => { } else if (metadata?.name) { label = metadata.name; } else { - label = pubkey; + label = normalizeToBech32(pubkey) ?? pubkey; } return ( diff --git a/src/helpers/nip-19.ts b/src/helpers/nip-19.ts new file mode 100644 index 000000000..7f6701369 --- /dev/null +++ b/src/helpers/nip-19.ts @@ -0,0 +1,74 @@ +import { decode, encode } from "bech32-buffer"; + +export function isHex(key?: string) { + if (key?.toLowerCase()?.match(/^[0-9a-f]{64}$/)) return true; + return false; +} + +export enum Bech32Prefix { + Pubkey = "npub", + SecKey = "nsec", + Note = "note", +} + +export function isBech32Key(key: string) { + try { + let { prefix } = decode(key.toLowerCase()); + if (!["npub", "nsec", "note"].includes(prefix)) return false; + if (!isHex(bech32ToHex(key))) return false; + } catch (error) { + return false; + } + return true; +} + +export function bech32ToHex(key: string) { + try { + let { data } = decode(key); + return toHexString(data); + } catch (error) {} + return ""; +} + +export function hexToBech32(hex: string, prefix: Bech32Prefix) { + try { + let buffer = fromHexString(hex); + return buffer && encode(prefix, buffer, "bech32"); + } catch (error) { + // continue + } + return null; +} + +export function toHexString(buffer: Uint8Array) { + return buffer.reduce((s, byte) => { + let hex = byte.toString(16); + if (hex.length === 1) hex = "0" + hex; + return s + hex; + }, ""); +} + +export function fromHexString(str: string) { + if (str.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(str)) { + return null; + } + let buffer = new Uint8Array(str.length / 2); + for (let i = 0; i < buffer.length; i++) { + buffer[i] = parseInt(str.substr(2 * i, 2), 16); + } + return buffer; +} + +export function normalizeToBech32( + key: string, + prefix: Bech32Prefix = Bech32Prefix.Pubkey +) { + if (isHex(key)) return hexToBech32(key, prefix); + if (isBech32Key(key)) return key; + return null; +} +export function normalizeToHex(hex: string) { + if (isHex(hex)) return hex; + if (isBech32Key(hex)) return bech32ToHex(hex); + return null; +} diff --git a/src/helpers/nostr-event.ts b/src/helpers/nostr-event.ts new file mode 100644 index 000000000..8fb9e9eeb --- /dev/null +++ b/src/helpers/nostr-event.ts @@ -0,0 +1,9 @@ +import { NostrEvent } from "../types/nostr-event"; + +export function isReply(event: NostrEvent) { + return !!event.tags.find((t) => t[0] === "e"); +} + +export function isPost(event: NostrEvent) { + return !isReply(event); +} diff --git a/src/hooks/use-event-dir.ts b/src/hooks/use-event-dir.ts index 07ed94a0d..342bdb09d 100644 --- a/src/hooks/use-event-dir.ts +++ b/src/hooks/use-event-dir.ts @@ -2,11 +2,16 @@ import { useEffect, useState } from "react"; import { Subscription } from "../services/subscriptions"; import { NostrEvent } from "../types/nostr-event"; -export function useEventDir(subscription: Subscription) { +export function useEventDir( + subscription: Subscription, + filter?: (event: NostrEvent) => boolean +) { const [events, setEvents] = useState>({}); useEffect(() => { const s = subscription.onEvent.subscribe((event) => { + if (filter && !filter(event)) return; + setEvents((dir) => { if (!dir[event.id]) { return { [event.id]: event, ...dir }; diff --git a/src/services/user-contacts.ts b/src/services/user-contacts.ts index 5383b4ace..c5603cc5b 100644 --- a/src/services/user-contacts.ts +++ b/src/services/user-contacts.ts @@ -53,8 +53,8 @@ export class UserContactsService { ) .subscribe(async (event) => { const keys = event.tags - .filter((tag) => tag[0] === "p") - .map((tag) => ({ pubkey: tag[1], relay: tag[2] })); + .filter((tag) => tag[0] === "p" && tag[1]) + .map((tag) => ({ pubkey: tag[1] as string, relay: tag[2] })); const relays = safeParse( event.content, diff --git a/src/types/nostr-event.ts b/src/types/nostr-event.ts index 3ee16c2f6..c5d3ea532 100644 --- a/src/types/nostr-event.ts +++ b/src/types/nostr-event.ts @@ -3,7 +3,7 @@ export type NostrEvent = { pubkey: string; created_at: number; kind: number; - tags: [string] | [string, string] | [string, string, string]; + tags: ([string] | [string, string] | [string, string, string])[]; content: string; sig: string; }; diff --git a/src/views/global/index.tsx b/src/views/global/index.tsx index a8a0529e8..b977917e2 100644 --- a/src/views/global/index.tsx +++ b/src/views/global/index.tsx @@ -1,34 +1,23 @@ -import React, { useEffect, useState } from "react"; -import { Flex, SkeletonText } from "@chakra-ui/react"; +import { + Flex, + SkeletonText, + Tab, + TabList, + TabPanel, + TabPanels, + Tabs, +} from "@chakra-ui/react"; import { useSubscription } from "../../hooks/use-subscription"; import { Post } from "../../components/post"; import moment from "moment/moment"; -import { NostrEvent } from "../../types/nostr-event"; import settings from "../../services/settings"; import useSubject from "../../hooks/use-subject"; +import { useEventDir } from "../../hooks/use-event-dir"; +import { Subscription } from "../../services/subscriptions"; +import { isPost, isReply } from "../../helpers/nostr-event"; -export const GlobalView = () => { - const relays = useSubject(settings.relays); - const [events, setEvents] = useState>({}); - - const sub = useSubscription( - relays, - { kinds: [1], limit: 10, since: moment().startOf("day").valueOf() / 1000 }, - "global-events" - ); - - useEffect(() => { - const s = sub.onEvent.subscribe((event) => { - setEvents((dir) => { - if (!dir[event.id]) { - return { [event.id]: event, ...dir }; - } - return dir; - }); - }); - - return () => s.unsubscribe(); - }, [sub]); +const PostsTimeline = ({ sub }: { sub: Subscription }) => { + const { events } = useEventDir(sub, isPost); const timeline = Object.values(events).sort( (a, b) => b.created_at - a.created_at @@ -41,10 +30,65 @@ export const GlobalView = () => { if (timeline.length > 20) timeline.length = 20; return ( - + {timeline.map((event) => ( ))} ); }; + +const RepliesTimeline = ({ sub }: { sub: Subscription }) => { + const { events } = useEventDir(sub, isReply); + + const timeline = Object.values(events).sort( + (a, b) => b.created_at - a.created_at + ); + + if (timeline.length === 0) { + return ; + } + + if (timeline.length > 20) timeline.length = 20; + + return ( + + {timeline.map((event) => ( + + ))} + + ); +}; + +export const GlobalView = () => { + const relays = useSubject(settings.relays); + + const sub = useSubscription( + relays, + { kinds: [1], limit: 10, since: moment().startOf("day").valueOf() / 1000 }, + "global-events" + ); + + return ( + + + Posts + Replies + + + + + + + + + + + ); +}; diff --git a/src/views/home.tsx b/src/views/home.tsx index 180235fec..a86c3ffe1 100644 --- a/src/views/home.tsx +++ b/src/views/home.tsx @@ -1,25 +1,28 @@ import { HStack } from "@chakra-ui/react"; -import { Link } from "react-router-dom"; -import { UserAvatar } from "../components/user-avatar"; import { UserAvatarLink } from "../components/user-avatar-link"; +import { normalizeToHex } from "../helpers/nip-19"; export const HomeView = () => { return ( - <> - - - - - - - - - - - - - - - + + + + + + + + + + + + + + ); }; diff --git a/src/views/user/index.tsx b/src/views/user/index.tsx index 6134c2a16..b897f5281 100644 --- a/src/views/user/index.tsx +++ b/src/views/user/index.tsx @@ -17,17 +17,23 @@ import { getUserFullName } from "../../helpers/user-metadata"; import { useIsMobile } from "../../hooks/use-is-mobile"; import { UserRelaysTab } from "./relays"; import { UserFollowingTab } from "./following"; +import { UserRepliesTab } from "./replies"; +import { normalizeToBech32, normalizeToHex } from "../../helpers/nip-19"; export const UserView = () => { const isMobile = useIsMobile(); - const { pubkey } = useParams(); - if (!pubkey) { + const params = useParams(); + + if (!params.pubkey) { // TODO: better 404 throw new Error("No pubkey"); } + const pubkey = normalizeToHex(params.pubkey) ?? ""; + const { metadata, loading: loadingMetadata } = useUserMetadata(pubkey, true); - const label = metadata ? getUserFullName(metadata) || pubkey : pubkey; + const bech32Key = normalizeToBech32(pubkey); + const label = metadata ? getUserFullName(metadata) || bech32Key : bech32Key; return ( { flexDirection="column" flexGrow="1" overflow="hidden" + isManual > Posts + Replies Following Relays @@ -60,6 +68,9 @@ export const UserView = () => { + + + diff --git a/src/views/user/posts.tsx b/src/views/user/posts.tsx index 886d80080..2ccdfd4e6 100644 --- a/src/views/user/posts.tsx +++ b/src/views/user/posts.tsx @@ -1,8 +1,7 @@ -import React, { useEffect, useState } from "react"; +import { useEffect } from "react"; import { Flex, SkeletonText } from "@chakra-ui/react"; import { useSubscription } from "../../hooks/use-subscription"; import { Post } from "../../components/post"; -import { NostrEvent } from "../../types/nostr-event"; import settings from "../../services/settings"; import useSubject from "../../hooks/use-subject"; import { useEventDir } from "../../hooks/use-event-dir"; @@ -16,7 +15,10 @@ export const UserPostsTab = ({ pubkey }: { pubkey: string }) => { `${pubkey} posts` ); - const { events, reset } = useEventDir(sub); + const { events, reset } = useEventDir( + sub, + (event) => !event.tags.find((t) => t[0] === "e") + ); // clear events when pubkey changes useEffect(() => reset(), [pubkey]); diff --git a/src/views/user/replies.tsx b/src/views/user/replies.tsx new file mode 100644 index 000000000..4b392be73 --- /dev/null +++ b/src/views/user/replies.tsx @@ -0,0 +1,43 @@ +import { useEffect } from "react"; +import { Flex, SkeletonText } from "@chakra-ui/react"; +import { useSubscription } from "../../hooks/use-subscription"; +import { Post } from "../../components/post"; +import settings from "../../services/settings"; +import useSubject from "../../hooks/use-subject"; +import { useEventDir } from "../../hooks/use-event-dir"; + +export const UserRepliesTab = ({ pubkey }: { pubkey: string }) => { + const relays = useSubject(settings.relays); + + const sub = useSubscription( + relays, + { authors: [pubkey], kinds: [1] }, + `${pubkey} posts` + ); + + const { events, reset } = useEventDir( + sub, + (event) => !!event.tags.find((t) => t[0] === "e") + ); + + // clear events when pubkey changes + useEffect(() => reset(), [pubkey]); + + const timeline = Object.values(events).sort( + (a, b) => b.created_at - a.created_at + ); + + if (timeline.length === 0) { + return ; + } + + if (timeline.length > 20) timeline.length = 20; + + return ( + + {timeline.map((event) => ( + + ))} + + ); +}; diff --git a/yarn.lock b/yarn.lock index 1dc51a2ce..86559c4f0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2731,6 +2731,11 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== +bech32-buffer@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/bech32-buffer/-/bech32-buffer-0.2.1.tgz#8106f2f51bcb2ba1d9fb7718905c3042c5be2fcd" + integrity sha512-fCG1TyZuCN48Sdw97p/IR39fvqpFlWDVpG7qnuU1Uc3+Xtc/0uqAp8U7bMW/bGuVF5CcNVIXwxQsWwUr6un6FQ== + better-path-resolve@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/better-path-resolve/-/better-path-resolve-1.0.0.tgz#13a35a1104cdd48a7b74bf8758f96a1ee613f99d"