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"