From 4fc3cc8a80a7cd3489eb876f07fb82738d79b1d6 Mon Sep 17 00:00:00 2001
From: reya <reya@lume.nu>
Date: Thu, 28 Dec 2023 11:31:47 +0700
Subject: [PATCH] feat(column): add thread and user columns

---
 apps/desktop/package.json                     |   2 +
 apps/desktop/src/routes/home/index.tsx        |   7 +-
 packages/@columns/thread/package.json         |  26 +++
 packages/@columns/thread/src/event.tsx        |  28 +++
 packages/@columns/thread/src/home.tsx         |  13 ++
 packages/@columns/thread/src/index.tsx        |  18 ++
 packages/@columns/thread/src/user.tsx         | 213 ++++++++++++++++++
 packages/@columns/thread/tailwind.config.js   |   8 +
 packages/@columns/thread/tsconfig.json        |   8 +
 packages/@columns/user/package.json           |  26 +++
 packages/@columns/user/src/event.tsx          |  28 +++
 packages/@columns/user/src/home.tsx           | 202 +++++++++++++++++
 packages/@columns/user/src/index.tsx          |  23 ++
 packages/@columns/user/src/user.tsx           | 213 ++++++++++++++++++
 packages/@columns/user/tailwind.config.js     |   8 +
 packages/@columns/user/tsconfig.json          |   8 +
 .../ark/src/components/note/preview/image.tsx |   2 +-
 .../ark/src/components/note/preview/link.tsx  |   4 +-
 .../ark/src/components/note/preview/video.tsx |  30 +--
 packages/ark/src/components/note/provider.tsx |   2 +-
 pnpm-lock.yaml                                |  74 +++++-
 21 files changed, 921 insertions(+), 22 deletions(-)
 create mode 100644 packages/@columns/thread/package.json
 create mode 100644 packages/@columns/thread/src/event.tsx
 create mode 100644 packages/@columns/thread/src/home.tsx
 create mode 100644 packages/@columns/thread/src/index.tsx
 create mode 100644 packages/@columns/thread/src/user.tsx
 create mode 100644 packages/@columns/thread/tailwind.config.js
 create mode 100644 packages/@columns/thread/tsconfig.json
 create mode 100644 packages/@columns/user/package.json
 create mode 100644 packages/@columns/user/src/event.tsx
 create mode 100644 packages/@columns/user/src/home.tsx
 create mode 100644 packages/@columns/user/src/index.tsx
 create mode 100644 packages/@columns/user/src/user.tsx
 create mode 100644 packages/@columns/user/tailwind.config.js
 create mode 100644 packages/@columns/user/tsconfig.json

diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 50b74f54..2e526e49 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -9,7 +9,9 @@
 	},
 	"dependencies": {
 		"@columns/notification": "workspace:^",
+		"@columns/thread": "workspace:^",
 		"@columns/timeline": "workspace:^",
+		"@columns/user": "workspace:^",
 		"@getalby/sdk": "^3.2.1",
 		"@lume/ark": "workspace:^",
 		"@lume/icons": "workspace:^",
diff --git a/apps/desktop/src/routes/home/index.tsx b/apps/desktop/src/routes/home/index.tsx
index 9b49aa91..81ae7732 100644
--- a/apps/desktop/src/routes/home/index.tsx
+++ b/apps/desktop/src/routes/home/index.tsx
@@ -1,5 +1,6 @@
-import { NotificationColumn } from "@columns/notification";
+import { Thread } from "@columns/thread";
 import { Timeline } from "@columns/timeline";
+import { User } from "@columns/user";
 import { useStorage } from "@lume/ark";
 import { LoaderIcon } from "@lume/icons";
 import { WidgetProps } from "@lume/types";
@@ -39,6 +40,10 @@ export function HomeScreen() {
 		switch (widget.kind) {
 			case WIDGET_KIND.newsfeed:
 				return <Timeline key={widget.id} />;
+			case WIDGET_KIND.thread:
+				return <Thread key={widget.id} thread={widget} />;
+			case WIDGET_KIND.user:
+				return <User key={widget.id} user={widget} />;
 			default:
 				return <Timeline key={widget.id} />;
 		}
diff --git a/packages/@columns/thread/package.json b/packages/@columns/thread/package.json
new file mode 100644
index 00000000..44b26cf3
--- /dev/null
+++ b/packages/@columns/thread/package.json
@@ -0,0 +1,26 @@
+{
+	"name": "@columns/thread",
+	"version": "0.0.0",
+	"private": true,
+	"main": "./src/index.tsx",
+	"dependencies": {
+		"@lume/ark": "workspace:^",
+		"@lume/icons": "workspace:^",
+		"@lume/ui": "workspace:^",
+		"@lume/utils": "workspace:^",
+		"@nostr-dev-kit/ndk": "^2.3.1",
+		"@tanstack/react-query": "^5.14.2",
+		"react": "^18.2.0",
+		"react-router-dom": "^6.21.0",
+		"sonner": "^1.2.4",
+		"virtua": "^0.18.0"
+	},
+	"devDependencies": {
+		"@lume/tailwindcss": "workspace:^",
+		"@lume/tsconfig": "workspace:^",
+		"@lume/types": "workspace:^",
+		"@types/react": "^18.2.45",
+		"tailwind": "^4.0.0",
+		"typescript": "^5.3.3"
+	}
+}
diff --git a/packages/@columns/thread/src/event.tsx b/packages/@columns/thread/src/event.tsx
new file mode 100644
index 00000000..2aa10da8
--- /dev/null
+++ b/packages/@columns/thread/src/event.tsx
@@ -0,0 +1,28 @@
+import { Note, ThreadNote } from "@lume/ark";
+import { ArrowLeftIcon } from "@lume/icons";
+import { useNavigate, useParams } from "react-router-dom";
+import { WVList } from "virtua";
+
+export function EventRoute() {
+	const { id } = useParams();
+	const navigate = useNavigate();
+
+	return (
+		<WVList className="pb-5 overflow-y-auto">
+			<div className="h-11 bg-neutral-50 dark:bg-neutral-950 border-b flex items-center px-3 border-neutral-100 dark:border-neutral-900 mb-3">
+				<button
+					type="button"
+					className="inline-flex items-center gap-2.5 text-sm font-medium"
+					onClick={() => navigate(-1)}
+				>
+					<ArrowLeftIcon className="size-4" />
+					Back
+				</button>
+			</div>
+			<div className="px-3">
+				<ThreadNote eventId={id} />
+				<Note.ReplyList eventId={id} title="All replies" className="mt-5" />
+			</div>
+		</WVList>
+	);
+}
diff --git a/packages/@columns/thread/src/home.tsx b/packages/@columns/thread/src/home.tsx
new file mode 100644
index 00000000..47f381d3
--- /dev/null
+++ b/packages/@columns/thread/src/home.tsx
@@ -0,0 +1,13 @@
+import { Note, ThreadNote } from "@lume/ark";
+import { WVList } from "virtua";
+
+export function HomeRoute({ id }: { id: string }) {
+	return (
+		<WVList className="pb-5 overflow-y-auto">
+			<div className="px-3">
+				<ThreadNote eventId={id} />
+				<Note.ReplyList eventId={id} title="All replies" className="mt-5" />
+			</div>
+		</WVList>
+	);
+}
diff --git a/packages/@columns/thread/src/index.tsx b/packages/@columns/thread/src/index.tsx
new file mode 100644
index 00000000..a7e0b415
--- /dev/null
+++ b/packages/@columns/thread/src/index.tsx
@@ -0,0 +1,18 @@
+import { Column } from "@lume/ark";
+import { WidgetProps } from "@lume/types";
+import { EventRoute } from "./event";
+import { HomeRoute } from "./home";
+import { UserRoute } from "./user";
+
+export function Thread({ thread }: { thread: WidgetProps }) {
+	return (
+		<Column.Root>
+			<Column.Header id={thread.id} title={thread.title} />
+			<Column.Content>
+				<Column.Route path="/" element={<HomeRoute id={thread.content} />} />
+				<Column.Route path="/events/:id" element={<EventRoute />} />
+				<Column.Route path="/users/:id" element={<UserRoute />} />
+			</Column.Content>
+		</Column.Root>
+	);
+}
diff --git a/packages/@columns/thread/src/user.tsx b/packages/@columns/thread/src/user.tsx
new file mode 100644
index 00000000..8d46f957
--- /dev/null
+++ b/packages/@columns/thread/src/user.tsx
@@ -0,0 +1,213 @@
+import {
+	RepostNote,
+	TextNote,
+	useArk,
+	useProfile,
+	useStorage,
+} from "@lume/ark";
+import { ArrowLeftIcon, ArrowRightCircleIcon, LoaderIcon } from "@lume/icons";
+import { NIP05 } from "@lume/ui";
+import { FETCH_LIMIT, displayNpub } from "@lume/utils";
+import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
+import { useInfiniteQuery } from "@tanstack/react-query";
+import { useEffect, useMemo, useState } from "react";
+import { Link, useNavigate, useParams } from "react-router-dom";
+import { toast } from "sonner";
+import { WVList } from "virtua";
+
+export function UserRoute() {
+	const ark = useArk();
+	const storage = useStorage();
+	const navigate = useNavigate();
+
+	const { id } = useParams();
+	const { user } = useProfile(id);
+	const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } =
+		useInfiniteQuery({
+			queryKey: ["user-posts", id],
+			initialPageParam: 0,
+			queryFn: async ({
+				signal,
+				pageParam,
+			}: {
+				signal: AbortSignal;
+				pageParam: number;
+			}) => {
+				const events = await ark.getInfiniteEvents({
+					filter: {
+						kinds: [NDKKind.Text, NDKKind.Repost],
+						authors: [id],
+					},
+					limit: FETCH_LIMIT,
+					pageParam,
+					signal,
+				});
+
+				return events;
+			},
+			getNextPageParam: (lastPage) => {
+				const lastEvent = lastPage.at(-1);
+				if (!lastEvent) return;
+				return lastEvent.created_at - 1;
+			},
+			refetchOnWindowFocus: false,
+		});
+
+	const [followed, setFollowed] = useState(false);
+
+	const allEvents = useMemo(
+		() => (data ? data.pages.flatMap((page) => page) : []),
+		[data],
+	);
+
+	const follow = async (pubkey: string) => {
+		try {
+			const add = await ark.createContact({ pubkey });
+			if (add) {
+				setFollowed(true);
+			} else {
+				toast.success("You already follow this user");
+			}
+		} catch (error) {
+			console.log(error);
+		}
+	};
+
+	const unfollow = async (pubkey: string) => {
+		try {
+			const remove = await ark.deleteContact({ pubkey });
+			if (remove) {
+				setFollowed(false);
+			}
+		} catch (error) {
+			console.log(error);
+		}
+	};
+
+	const renderItem = (event: NDKEvent) => {
+		switch (event.kind) {
+			case NDKKind.Text:
+				return <TextNote key={event.id} event={event} className="mt-3" />;
+			case NDKKind.Repost:
+				return <RepostNote key={event.id} event={event} className="mt-3" />;
+			default:
+				return <TextNote key={event.id} event={event} className="mt-3" />;
+		}
+	};
+
+	useEffect(() => {
+		if (storage.account.contacts.includes(id)) {
+			setFollowed(true);
+		}
+	}, []);
+
+	return (
+		<WVList className="pb-5 overflow-y-auto">
+			<div className="h-11 bg-neutral-50 dark:bg-neutral-950 border-b flex items-center px-3 border-neutral-100 dark:border-neutral-900 mb-3">
+				<button
+					type="button"
+					className="inline-flex items-center gap-2.5 text-sm font-medium"
+					onClick={() => navigate(-1)}
+				>
+					<ArrowLeftIcon className="size-4" />
+					Back
+				</button>
+			</div>
+			<div className="px-3">
+				<div className="flex flex-col gap-2">
+					<div className="flex items-center justify-between">
+						<img
+							src={user?.picture || user?.image}
+							alt={id}
+							className="h-12 w-12 shrink-0 rounded-lg object-cover"
+							loading="lazy"
+							decoding="async"
+						/>
+						<div className="inline-flex items-center gap-2">
+							{followed ? (
+								<button
+									type="button"
+									onClick={() => unfollow(id)}
+									className="inline-flex h-9 w-28 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-800"
+								>
+									Unfollow
+								</button>
+							) : (
+								<button
+									type="button"
+									onClick={() => follow(id)}
+									className="inline-flex h-9 w-28 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-800"
+								>
+									Follow
+								</button>
+							)}
+							<Link
+								to={`/chats/${id}`}
+								className="inline-flex h-9 w-28 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-800"
+							>
+								Message
+							</Link>
+						</div>
+					</div>
+					<div className="flex flex-1 flex-col gap-1.5">
+						<div className="flex flex-col">
+							<h5 className="text-lg font-semibold">
+								{user?.name ||
+									user?.display_name ||
+									user?.displayName ||
+									"Anon"}
+							</h5>
+							{user?.nip05 ? (
+								<NIP05
+									pubkey={id}
+									nip05={user?.nip05}
+									className="max-w-[15rem] truncate text-sm text-neutral-600 dark:text-neutral-400"
+								/>
+							) : (
+								<span className="max-w-[15rem] truncate text-sm text-neutral-600 dark:text-neutral-400">
+									{displayNpub(id, 16)}
+								</span>
+							)}
+						</div>
+						<div className="max-w-[500px] select-text break-words text-neutral-900 dark:text-neutral-100">
+							{user?.about}
+						</div>
+					</div>
+				</div>
+				<div className="pt-2 mt-2 border-t border-neutral-100 dark:border-neutral-900">
+					<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
+						Latest posts
+					</h3>
+					<div className="flex h-full w-full flex-col justify-between gap-1.5 pb-10">
+						{isLoading ? (
+							<div className="flex items-center justify-center">
+								<LoaderIcon className="h-4 w-4 animate-spin" />
+							</div>
+						) : (
+							allEvents.map((item) => renderItem(item))
+						)}
+						<div className="flex h-16 items-center justify-center px-3 pb-3">
+							{hasNextPage ? (
+								<button
+									type="button"
+									onClick={() => fetchNextPage()}
+									disabled={!hasNextPage || isFetchingNextPage}
+									className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
+								>
+									{isFetchingNextPage ? (
+										<LoaderIcon className="h-4 w-4 animate-spin" />
+									) : (
+										<>
+											<ArrowRightCircleIcon className="h-5 w-5" />
+											Load more
+										</>
+									)}
+								</button>
+							) : null}
+						</div>
+					</div>
+				</div>
+			</div>
+		</WVList>
+	);
+}
diff --git a/packages/@columns/thread/tailwind.config.js b/packages/@columns/thread/tailwind.config.js
new file mode 100644
index 00000000..49c48c7a
--- /dev/null
+++ b/packages/@columns/thread/tailwind.config.js
@@ -0,0 +1,8 @@
+import sharedConfig from "@lume/tailwindcss";
+
+const config = {
+	content: ["./src/**/*.{js,ts,jsx,tsx}"],
+	presets: [sharedConfig],
+};
+
+export default config;
diff --git a/packages/@columns/thread/tsconfig.json b/packages/@columns/thread/tsconfig.json
new file mode 100644
index 00000000..34a32891
--- /dev/null
+++ b/packages/@columns/thread/tsconfig.json
@@ -0,0 +1,8 @@
+{
+	"extends": "@lume/tsconfig/base.json",
+	"compilerOptions": {
+		"outDir": "dist"
+	},
+	"include": ["src"],
+	"exclude": ["node_modules", "dist"]
+}
diff --git a/packages/@columns/user/package.json b/packages/@columns/user/package.json
new file mode 100644
index 00000000..bb5d61fc
--- /dev/null
+++ b/packages/@columns/user/package.json
@@ -0,0 +1,26 @@
+{
+	"name": "@columns/user",
+	"version": "0.0.0",
+	"private": true,
+	"main": "./src/index.tsx",
+	"dependencies": {
+		"@lume/ark": "workspace:^",
+		"@lume/icons": "workspace:^",
+		"@lume/ui": "workspace:^",
+		"@lume/utils": "workspace:^",
+		"@nostr-dev-kit/ndk": "^2.3.1",
+		"@tanstack/react-query": "^5.14.2",
+		"react": "^18.2.0",
+		"react-router-dom": "^6.21.0",
+		"sonner": "^1.2.4",
+		"virtua": "^0.18.0"
+	},
+	"devDependencies": {
+		"@lume/tailwindcss": "workspace:^",
+		"@lume/tsconfig": "workspace:^",
+		"@lume/types": "workspace:^",
+		"@types/react": "^18.2.45",
+		"tailwind": "^4.0.0",
+		"typescript": "^5.3.3"
+	}
+}
diff --git a/packages/@columns/user/src/event.tsx b/packages/@columns/user/src/event.tsx
new file mode 100644
index 00000000..2aa10da8
--- /dev/null
+++ b/packages/@columns/user/src/event.tsx
@@ -0,0 +1,28 @@
+import { Note, ThreadNote } from "@lume/ark";
+import { ArrowLeftIcon } from "@lume/icons";
+import { useNavigate, useParams } from "react-router-dom";
+import { WVList } from "virtua";
+
+export function EventRoute() {
+	const { id } = useParams();
+	const navigate = useNavigate();
+
+	return (
+		<WVList className="pb-5 overflow-y-auto">
+			<div className="h-11 bg-neutral-50 dark:bg-neutral-950 border-b flex items-center px-3 border-neutral-100 dark:border-neutral-900 mb-3">
+				<button
+					type="button"
+					className="inline-flex items-center gap-2.5 text-sm font-medium"
+					onClick={() => navigate(-1)}
+				>
+					<ArrowLeftIcon className="size-4" />
+					Back
+				</button>
+			</div>
+			<div className="px-3">
+				<ThreadNote eventId={id} />
+				<Note.ReplyList eventId={id} title="All replies" className="mt-5" />
+			</div>
+		</WVList>
+	);
+}
diff --git a/packages/@columns/user/src/home.tsx b/packages/@columns/user/src/home.tsx
new file mode 100644
index 00000000..30f960e9
--- /dev/null
+++ b/packages/@columns/user/src/home.tsx
@@ -0,0 +1,202 @@
+import {
+	RepostNote,
+	TextNote,
+	useArk,
+	useProfile,
+	useStorage,
+} from "@lume/ark";
+import { ArrowLeftIcon, ArrowRightCircleIcon, LoaderIcon } from "@lume/icons";
+import { NIP05 } from "@lume/ui";
+import { FETCH_LIMIT, displayNpub } from "@lume/utils";
+import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
+import { useInfiniteQuery } from "@tanstack/react-query";
+import { useEffect, useMemo, useState } from "react";
+import { Link, useNavigate, useParams } from "react-router-dom";
+import { toast } from "sonner";
+import { WVList } from "virtua";
+
+export function HomeRoute({ id }: { id: string }) {
+	const ark = useArk();
+	const storage = useStorage();
+	const navigate = useNavigate();
+
+	const { user } = useProfile(id);
+	const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } =
+		useInfiniteQuery({
+			queryKey: ["user-posts", id],
+			initialPageParam: 0,
+			queryFn: async ({
+				signal,
+				pageParam,
+			}: {
+				signal: AbortSignal;
+				pageParam: number;
+			}) => {
+				const events = await ark.getInfiniteEvents({
+					filter: {
+						kinds: [NDKKind.Text, NDKKind.Repost],
+						authors: [id],
+					},
+					limit: FETCH_LIMIT,
+					pageParam,
+					signal,
+				});
+
+				return events;
+			},
+			getNextPageParam: (lastPage) => {
+				const lastEvent = lastPage.at(-1);
+				if (!lastEvent) return;
+				return lastEvent.created_at - 1;
+			},
+			refetchOnWindowFocus: false,
+		});
+
+	const [followed, setFollowed] = useState(false);
+
+	const allEvents = useMemo(
+		() => (data ? data.pages.flatMap((page) => page) : []),
+		[data],
+	);
+
+	const follow = async (pubkey: string) => {
+		try {
+			const add = await ark.createContact({ pubkey });
+			if (add) {
+				setFollowed(true);
+			} else {
+				toast.success("You already follow this user");
+			}
+		} catch (error) {
+			console.log(error);
+		}
+	};
+
+	const unfollow = async (pubkey: string) => {
+		try {
+			const remove = await ark.deleteContact({ pubkey });
+			if (remove) {
+				setFollowed(false);
+			}
+		} catch (error) {
+			console.log(error);
+		}
+	};
+
+	const renderItem = (event: NDKEvent) => {
+		switch (event.kind) {
+			case NDKKind.Text:
+				return <TextNote key={event.id} event={event} className="mt-3" />;
+			case NDKKind.Repost:
+				return <RepostNote key={event.id} event={event} className="mt-3" />;
+			default:
+				return <TextNote key={event.id} event={event} className="mt-3" />;
+		}
+	};
+
+	useEffect(() => {
+		if (storage.account.contacts.includes(id)) {
+			setFollowed(true);
+		}
+	}, []);
+
+	return (
+		<WVList className="py-5 overflow-y-auto">
+			<div className="px-3">
+				<div className="flex flex-col gap-2">
+					<div className="flex items-center justify-between">
+						<img
+							src={user?.picture || user?.image}
+							alt={id}
+							className="h-12 w-12 shrink-0 rounded-lg object-cover"
+							loading="lazy"
+							decoding="async"
+						/>
+						<div className="inline-flex items-center gap-2">
+							{followed ? (
+								<button
+									type="button"
+									onClick={() => unfollow(id)}
+									className="inline-flex h-9 w-28 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-800"
+								>
+									Unfollow
+								</button>
+							) : (
+								<button
+									type="button"
+									onClick={() => follow(id)}
+									className="inline-flex h-9 w-28 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-800"
+								>
+									Follow
+								</button>
+							)}
+							<Link
+								to={`/chats/${id}`}
+								className="inline-flex h-9 w-28 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-800"
+							>
+								Message
+							</Link>
+						</div>
+					</div>
+					<div className="flex flex-1 flex-col gap-1.5">
+						<div className="flex flex-col">
+							<h5 className="text-lg font-semibold">
+								{user?.name ||
+									user?.display_name ||
+									user?.displayName ||
+									"Anon"}
+							</h5>
+							{user?.nip05 ? (
+								<NIP05
+									pubkey={id}
+									nip05={user?.nip05}
+									className="max-w-[15rem] truncate text-sm text-neutral-600 dark:text-neutral-400"
+								/>
+							) : (
+								<span className="max-w-[15rem] truncate text-sm text-neutral-600 dark:text-neutral-400">
+									{displayNpub(id, 16)}
+								</span>
+							)}
+						</div>
+						<div className="max-w-[500px] select-text break-words text-neutral-900 dark:text-neutral-100">
+							{user?.about}
+						</div>
+					</div>
+				</div>
+				<div className="pt-2 mt-2 border-t border-neutral-100 dark:border-neutral-900">
+					<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
+						Latest posts
+					</h3>
+					<div className="flex h-full w-full flex-col justify-between gap-1.5 pb-10">
+						{isLoading ? (
+							<div className="flex items-center justify-center">
+								<LoaderIcon className="h-4 w-4 animate-spin" />
+							</div>
+						) : (
+							allEvents.map((item) => renderItem(item))
+						)}
+						<div className="flex h-16 items-center justify-center px-3 pb-3">
+							{hasNextPage ? (
+								<button
+									type="button"
+									onClick={() => fetchNextPage()}
+									disabled={!hasNextPage || isFetchingNextPage}
+									className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
+								>
+									{isFetchingNextPage ? (
+										<LoaderIcon className="h-4 w-4 animate-spin" />
+									) : (
+										<>
+											<ArrowRightCircleIcon className="h-5 w-5" />
+											Load more
+										</>
+									)}
+								</button>
+							) : null}
+						</div>
+					</div>
+				</div>
+			</div>
+		</WVList>
+	);
+}
diff --git a/packages/@columns/user/src/index.tsx b/packages/@columns/user/src/index.tsx
new file mode 100644
index 00000000..e34c21cd
--- /dev/null
+++ b/packages/@columns/user/src/index.tsx
@@ -0,0 +1,23 @@
+import { Column } from "@lume/ark";
+import { UserIcon } from "@lume/icons";
+import { WidgetProps } from "@lume/types";
+import { EventRoute } from "./event";
+import { HomeRoute } from "./home";
+import { UserRoute } from "./user";
+
+export function User({ user }: { user: WidgetProps }) {
+	return (
+		<Column.Root>
+			<Column.Header
+				id={user.id}
+				title={user.title}
+				icon={<UserIcon className="size-4" />}
+			/>
+			<Column.Content>
+				<Column.Route path="/" element={<HomeRoute id={user.content} />} />
+				<Column.Route path="/events/:id" element={<EventRoute />} />
+				<Column.Route path="/users/:id" element={<UserRoute />} />
+			</Column.Content>
+		</Column.Root>
+	);
+}
diff --git a/packages/@columns/user/src/user.tsx b/packages/@columns/user/src/user.tsx
new file mode 100644
index 00000000..8d46f957
--- /dev/null
+++ b/packages/@columns/user/src/user.tsx
@@ -0,0 +1,213 @@
+import {
+	RepostNote,
+	TextNote,
+	useArk,
+	useProfile,
+	useStorage,
+} from "@lume/ark";
+import { ArrowLeftIcon, ArrowRightCircleIcon, LoaderIcon } from "@lume/icons";
+import { NIP05 } from "@lume/ui";
+import { FETCH_LIMIT, displayNpub } from "@lume/utils";
+import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
+import { useInfiniteQuery } from "@tanstack/react-query";
+import { useEffect, useMemo, useState } from "react";
+import { Link, useNavigate, useParams } from "react-router-dom";
+import { toast } from "sonner";
+import { WVList } from "virtua";
+
+export function UserRoute() {
+	const ark = useArk();
+	const storage = useStorage();
+	const navigate = useNavigate();
+
+	const { id } = useParams();
+	const { user } = useProfile(id);
+	const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } =
+		useInfiniteQuery({
+			queryKey: ["user-posts", id],
+			initialPageParam: 0,
+			queryFn: async ({
+				signal,
+				pageParam,
+			}: {
+				signal: AbortSignal;
+				pageParam: number;
+			}) => {
+				const events = await ark.getInfiniteEvents({
+					filter: {
+						kinds: [NDKKind.Text, NDKKind.Repost],
+						authors: [id],
+					},
+					limit: FETCH_LIMIT,
+					pageParam,
+					signal,
+				});
+
+				return events;
+			},
+			getNextPageParam: (lastPage) => {
+				const lastEvent = lastPage.at(-1);
+				if (!lastEvent) return;
+				return lastEvent.created_at - 1;
+			},
+			refetchOnWindowFocus: false,
+		});
+
+	const [followed, setFollowed] = useState(false);
+
+	const allEvents = useMemo(
+		() => (data ? data.pages.flatMap((page) => page) : []),
+		[data],
+	);
+
+	const follow = async (pubkey: string) => {
+		try {
+			const add = await ark.createContact({ pubkey });
+			if (add) {
+				setFollowed(true);
+			} else {
+				toast.success("You already follow this user");
+			}
+		} catch (error) {
+			console.log(error);
+		}
+	};
+
+	const unfollow = async (pubkey: string) => {
+		try {
+			const remove = await ark.deleteContact({ pubkey });
+			if (remove) {
+				setFollowed(false);
+			}
+		} catch (error) {
+			console.log(error);
+		}
+	};
+
+	const renderItem = (event: NDKEvent) => {
+		switch (event.kind) {
+			case NDKKind.Text:
+				return <TextNote key={event.id} event={event} className="mt-3" />;
+			case NDKKind.Repost:
+				return <RepostNote key={event.id} event={event} className="mt-3" />;
+			default:
+				return <TextNote key={event.id} event={event} className="mt-3" />;
+		}
+	};
+
+	useEffect(() => {
+		if (storage.account.contacts.includes(id)) {
+			setFollowed(true);
+		}
+	}, []);
+
+	return (
+		<WVList className="pb-5 overflow-y-auto">
+			<div className="h-11 bg-neutral-50 dark:bg-neutral-950 border-b flex items-center px-3 border-neutral-100 dark:border-neutral-900 mb-3">
+				<button
+					type="button"
+					className="inline-flex items-center gap-2.5 text-sm font-medium"
+					onClick={() => navigate(-1)}
+				>
+					<ArrowLeftIcon className="size-4" />
+					Back
+				</button>
+			</div>
+			<div className="px-3">
+				<div className="flex flex-col gap-2">
+					<div className="flex items-center justify-between">
+						<img
+							src={user?.picture || user?.image}
+							alt={id}
+							className="h-12 w-12 shrink-0 rounded-lg object-cover"
+							loading="lazy"
+							decoding="async"
+						/>
+						<div className="inline-flex items-center gap-2">
+							{followed ? (
+								<button
+									type="button"
+									onClick={() => unfollow(id)}
+									className="inline-flex h-9 w-28 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-800"
+								>
+									Unfollow
+								</button>
+							) : (
+								<button
+									type="button"
+									onClick={() => follow(id)}
+									className="inline-flex h-9 w-28 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-800"
+								>
+									Follow
+								</button>
+							)}
+							<Link
+								to={`/chats/${id}`}
+								className="inline-flex h-9 w-28 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-800"
+							>
+								Message
+							</Link>
+						</div>
+					</div>
+					<div className="flex flex-1 flex-col gap-1.5">
+						<div className="flex flex-col">
+							<h5 className="text-lg font-semibold">
+								{user?.name ||
+									user?.display_name ||
+									user?.displayName ||
+									"Anon"}
+							</h5>
+							{user?.nip05 ? (
+								<NIP05
+									pubkey={id}
+									nip05={user?.nip05}
+									className="max-w-[15rem] truncate text-sm text-neutral-600 dark:text-neutral-400"
+								/>
+							) : (
+								<span className="max-w-[15rem] truncate text-sm text-neutral-600 dark:text-neutral-400">
+									{displayNpub(id, 16)}
+								</span>
+							)}
+						</div>
+						<div className="max-w-[500px] select-text break-words text-neutral-900 dark:text-neutral-100">
+							{user?.about}
+						</div>
+					</div>
+				</div>
+				<div className="pt-2 mt-2 border-t border-neutral-100 dark:border-neutral-900">
+					<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
+						Latest posts
+					</h3>
+					<div className="flex h-full w-full flex-col justify-between gap-1.5 pb-10">
+						{isLoading ? (
+							<div className="flex items-center justify-center">
+								<LoaderIcon className="h-4 w-4 animate-spin" />
+							</div>
+						) : (
+							allEvents.map((item) => renderItem(item))
+						)}
+						<div className="flex h-16 items-center justify-center px-3 pb-3">
+							{hasNextPage ? (
+								<button
+									type="button"
+									onClick={() => fetchNextPage()}
+									disabled={!hasNextPage || isFetchingNextPage}
+									className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
+								>
+									{isFetchingNextPage ? (
+										<LoaderIcon className="h-4 w-4 animate-spin" />
+									) : (
+										<>
+											<ArrowRightCircleIcon className="h-5 w-5" />
+											Load more
+										</>
+									)}
+								</button>
+							) : null}
+						</div>
+					</div>
+				</div>
+			</div>
+		</WVList>
+	);
+}
diff --git a/packages/@columns/user/tailwind.config.js b/packages/@columns/user/tailwind.config.js
new file mode 100644
index 00000000..49c48c7a
--- /dev/null
+++ b/packages/@columns/user/tailwind.config.js
@@ -0,0 +1,8 @@
+import sharedConfig from "@lume/tailwindcss";
+
+const config = {
+	content: ["./src/**/*.{js,ts,jsx,tsx}"],
+	presets: [sharedConfig],
+};
+
+export default config;
diff --git a/packages/@columns/user/tsconfig.json b/packages/@columns/user/tsconfig.json
new file mode 100644
index 00000000..34a32891
--- /dev/null
+++ b/packages/@columns/user/tsconfig.json
@@ -0,0 +1,8 @@
+{
+	"extends": "@lume/tsconfig/base.json",
+	"compilerOptions": {
+		"outDir": "dist"
+	},
+	"include": ["src"],
+	"exclude": ["node_modules", "dist"]
+}
diff --git a/packages/ark/src/components/note/preview/image.tsx b/packages/ark/src/components/note/preview/image.tsx
index bf90af2e..2743b7ff 100644
--- a/packages/ark/src/components/note/preview/image.tsx
+++ b/packages/ark/src/components/note/preview/image.tsx
@@ -31,7 +31,7 @@ export function ImagePreview({ url }: { url: string }) {
 
 	return (
 		// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
-		<div onClick={open} className="group relative my-2">
+		<div onClick={open} className="group relative">
 			<img
 				src={url}
 				alt={url}
diff --git a/packages/ark/src/components/note/preview/link.tsx b/packages/ark/src/components/note/preview/link.tsx
index 4c737d7f..bb448a40 100644
--- a/packages/ark/src/components/note/preview/link.tsx
+++ b/packages/ark/src/components/note/preview/link.tsx
@@ -11,7 +11,7 @@ export function LinkPreview({ url }: { url: string }) {
 
 	if (status === "pending") {
 		return (
-			<div className="my-2 flex w-full flex-col rounded-lg bg-neutral-100 dark:bg-neutral-900">
+			<div className="flex w-full flex-col rounded-lg bg-neutral-100 dark:bg-neutral-900">
 				<div className="h-48 w-full animate-pulse bg-neutral-300 dark:bg-neutral-700" />
 				<div className="flex flex-col gap-2 px-3 py-3">
 					<div className="h-3 w-2/3 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
@@ -42,7 +42,7 @@ export function LinkPreview({ url }: { url: string }) {
 			to={url}
 			target="_blank"
 			rel="noreferrer"
-			className="my-2 flex w-full flex-col rounded-lg bg-neutral-100 dark:bg-neutral-900"
+			className="flex w-full flex-col rounded-lg bg-neutral-100 dark:bg-neutral-900"
 		>
 			{isImage(data.image) ? (
 				<img
diff --git a/packages/ark/src/components/note/preview/video.tsx b/packages/ark/src/components/note/preview/video.tsx
index a21ab4ae..5c3b757c 100644
--- a/packages/ark/src/components/note/preview/video.tsx
+++ b/packages/ark/src/components/note/preview/video.tsx
@@ -1,19 +1,19 @@
-import { MediaPlayer, MediaProvider } from '@vidstack/react';
+import { MediaPlayer, MediaProvider } from "@vidstack/react";
 import {
-  DefaultVideoLayout,
-  defaultLayoutIcons,
-} from '@vidstack/react/player/layouts/default';
+	DefaultVideoLayout,
+	defaultLayoutIcons,
+} from "@vidstack/react/player/layouts/default";
 
 export function VideoPreview({ url }: { url: string }) {
-  return (
-    <MediaPlayer
-      src={url}
-      className="my-2 w-full overflow-hidden rounded-lg"
-      aspectRatio="16/9"
-      load="visible"
-    >
-      <MediaProvider />
-      <DefaultVideoLayout icons={defaultLayoutIcons} />
-    </MediaPlayer>
-  );
+	return (
+		<MediaPlayer
+			src={url}
+			className="w-full overflow-hidden rounded-lg"
+			aspectRatio="16/9"
+			load="visible"
+		>
+			<MediaProvider />
+			<DefaultVideoLayout icons={defaultLayoutIcons} />
+		</MediaPlayer>
+	);
 }
diff --git a/packages/ark/src/components/note/provider.tsx b/packages/ark/src/components/note/provider.tsx
index d5ee6920..c2cf4d45 100644
--- a/packages/ark/src/components/note/provider.tsx
+++ b/packages/ark/src/components/note/provider.tsx
@@ -14,7 +14,7 @@ export function NoteProvider({
 
 export function useNoteContext() {
 	const context = useContext(EventContext);
-	if (context === undefined) {
+	if (!context) {
 		throw new Error("Please import Note Provider to use useNoteContext() hook");
 	}
 	return context;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d2413c3f..d42f5031 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -23,9 +23,15 @@ importers:
       '@columns/notification':
         specifier: workspace:^
         version: link:../../packages/@columns/notification
+      '@columns/thread':
+        specifier: workspace:^
+        version: link:../../packages/@columns/thread
       '@columns/timeline':
         specifier: workspace:^
         version: link:../../packages/@columns/timeline
+      '@columns/user':
+        specifier: workspace:^
+        version: link:../../packages/@columns/user
       '@getalby/sdk':
         specifier: ^3.2.1
         version: 3.2.1(typescript@5.3.3)
@@ -283,7 +289,7 @@ importers:
         specifier: ^4.2.2
         version: 4.2.2(typescript@5.3.3)(vite@4.5.1)
 
-  packages/@columns/newsfeed:
+  packages/@columns/notification:
     dependencies:
       '@lume/ark':
         specifier: workspace:^
@@ -323,7 +329,7 @@ importers:
         specifier: ^5.3.3
         version: 5.3.3
 
-  packages/@columns/notification:
+  packages/@columns/thread:
     dependencies:
       '@lume/ark':
         specifier: workspace:^
@@ -331,6 +337,9 @@ importers:
       '@lume/icons':
         specifier: workspace:^
         version: link:../../icons
+      '@lume/ui':
+        specifier: workspace:^
+        version: link:../../ui
       '@lume/utils':
         specifier: workspace:^
         version: link:../../utils
@@ -343,6 +352,12 @@ importers:
       react:
         specifier: ^18.2.0
         version: 18.2.0
+      react-router-dom:
+        specifier: ^6.21.0
+        version: 6.21.0(react-dom@18.2.0)(react@18.2.0)
+      sonner:
+        specifier: ^1.2.4
+        version: 1.2.4(react-dom@18.2.0)(react@18.2.0)
       virtua:
         specifier: ^0.18.0
         version: 0.18.0(react-dom@18.2.0)(react@18.2.0)
@@ -353,6 +368,9 @@ importers:
       '@lume/tsconfig':
         specifier: workspace:^
         version: link:../../tsconfig
+      '@lume/types':
+        specifier: workspace:^
+        version: link:../../types
       '@types/react':
         specifier: ^18.2.45
         version: 18.2.45
@@ -412,6 +430,58 @@ importers:
         specifier: ^5.3.3
         version: 5.3.3
 
+  packages/@columns/user:
+    dependencies:
+      '@lume/ark':
+        specifier: workspace:^
+        version: link:../../ark
+      '@lume/icons':
+        specifier: workspace:^
+        version: link:../../icons
+      '@lume/ui':
+        specifier: workspace:^
+        version: link:../../ui
+      '@lume/utils':
+        specifier: workspace:^
+        version: link:../../utils
+      '@nostr-dev-kit/ndk':
+        specifier: ^2.3.1
+        version: 2.3.1(typescript@5.3.3)
+      '@tanstack/react-query':
+        specifier: ^5.14.2
+        version: 5.14.2(react@18.2.0)
+      react:
+        specifier: ^18.2.0
+        version: 18.2.0
+      react-router-dom:
+        specifier: ^6.21.0
+        version: 6.21.0(react-dom@18.2.0)(react@18.2.0)
+      sonner:
+        specifier: ^1.2.4
+        version: 1.2.4(react-dom@18.2.0)(react@18.2.0)
+      virtua:
+        specifier: ^0.18.0
+        version: 0.18.0(react-dom@18.2.0)(react@18.2.0)
+    devDependencies:
+      '@lume/tailwindcss':
+        specifier: workspace:^
+        version: link:../../tailwindcss
+      '@lume/tsconfig':
+        specifier: workspace:^
+        version: link:../../tsconfig
+      '@lume/types':
+        specifier: workspace:^
+        version: link:../../types
+      '@types/react':
+        specifier: ^18.2.45
+        version: 18.2.45
+      tailwind:
+        specifier: ^4.0.0
+        version: 4.0.0
+      typescript:
+        specifier: ^5.3.3
+        version: 5.3.3
+
   packages/ark:
     dependencies:
       '@getalby/sdk':