diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 9455ae81..60f81736 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -8,6 +8,7 @@
"build": "vite build"
},
"dependencies": {
+ "@columns/hashtag": "workspace:^",
"@columns/notification": "workspace:^",
"@columns/thread": "workspace:^",
"@columns/timeline": "workspace:^",
diff --git a/apps/desktop/src/routes/home/index.tsx b/apps/desktop/src/routes/home/index.tsx
index 99c0c3c4..fe586296 100644
--- a/apps/desktop/src/routes/home/index.tsx
+++ b/apps/desktop/src/routes/home/index.tsx
@@ -1,9 +1,10 @@
+import { Hashtag } from "@columns/hashtag";
import { Thread } from "@columns/thread";
import { Timeline } from "@columns/timeline";
import { User } from "@columns/user";
import { useColumnContext } from "@lume/ark";
import { IColumn } from "@lume/types";
-import { WIDGET_KIND } from "@lume/utils";
+import { COL_TYPES } from "@lume/utils";
import { useRef, useState } from "react";
import { VList, VListHandle } from "virtua";
@@ -14,12 +15,14 @@ export function HomeScreen() {
const renderItem = (column: IColumn) => {
switch (column.kind) {
- case WIDGET_KIND.newsfeed:
+ case COL_TYPES.newsfeed:
return ;
- case WIDGET_KIND.thread:
- return ;
- case WIDGET_KIND.user:
- return ;
+ case COL_TYPES.thread:
+ return ;
+ case COL_TYPES.user:
+ return ;
+ case COL_TYPES.hashtag:
+ return ;
default:
return ;
}
@@ -64,7 +67,6 @@ export function HomeScreen() {
}}
>
{columns.map((column) => renderItem(column))}
-
);
diff --git a/apps/desktop/src/routes/new/post.tsx b/apps/desktop/src/routes/new/post.tsx
index b87669a1..15332d00 100644
--- a/apps/desktop/src/routes/new/post.tsx
+++ b/apps/desktop/src/routes/new/post.tsx
@@ -1,6 +1,6 @@
import { MentionNote, useArk, useSuggestion, useWidget } from "@lume/ark";
import { CancelIcon, LoaderIcon } from "@lume/icons";
-import { WIDGET_KIND } from "@lume/utils";
+import { COL_TYPES } from "@lume/utils";
import { NDKKind } from "@nostr-dev-kit/ndk";
import CharacterCount from "@tiptap/extension-character-count";
import Image from "@tiptap/extension-image";
@@ -101,7 +101,7 @@ export function NewPostScreen() {
addWidget.mutate({
title: "Thread",
content: publish.id,
- kind: WIDGET_KIND.thread,
+ kind: COL_TYPES.thread,
});
}
diff --git a/packages/@columns/hashtag/package.json b/packages/@columns/hashtag/package.json
new file mode 100644
index 00000000..5bfa8a12
--- /dev/null
+++ b/packages/@columns/hashtag/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "@columns/hashtag",
+ "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.2",
+ "@tanstack/react-query": "^5.15.0",
+ "react": "^18.2.0",
+ "react-router-dom": "^6.21.1",
+ "sonner": "^1.3.1",
+ "virtua": "^0.18.0"
+ },
+ "devDependencies": {
+ "@lume/tailwindcss": "workspace:^",
+ "@lume/tsconfig": "workspace:^",
+ "@lume/types": "workspace:^",
+ "@types/react": "^18.2.46",
+ "tailwind": "^4.0.0",
+ "typescript": "^5.3.3"
+ }
+}
diff --git a/packages/@columns/hashtag/src/event.tsx b/packages/@columns/hashtag/src/event.tsx
new file mode 100644
index 00000000..1174c079
--- /dev/null
+++ b/packages/@columns/hashtag/src/event.tsx
@@ -0,0 +1,29 @@
+import { ThreadNote } from "@lume/ark";
+import { ArrowLeftIcon } from "@lume/icons";
+import { ReplyList } from "@lume/ui";
+import { useNavigate, useParams } from "react-router-dom";
+import { WVList } from "virtua";
+
+export function EventRoute() {
+ const { id } = useParams();
+ const navigate = useNavigate();
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/@columns/hashtag/src/home.tsx b/packages/@columns/hashtag/src/home.tsx
new file mode 100644
index 00000000..1f2bfd3c
--- /dev/null
+++ b/packages/@columns/hashtag/src/home.tsx
@@ -0,0 +1,109 @@
+import { TextNote, useArk } from "@lume/ark";
+import { ArrowRightCircleIcon, LoaderIcon } from "@lume/icons";
+import { FETCH_LIMIT } from "@lume/utils";
+import { NDKKind } from "@nostr-dev-kit/ndk";
+import { useInfiniteQuery } from "@tanstack/react-query";
+import { useEffect, useMemo, useRef } from "react";
+import { CacheSnapshot, VList, VListHandle } from "virtua";
+
+export function HomeRoute({
+ colKey,
+ hashtag,
+}: { colKey: string; hashtag: string }) {
+ const ark = useArk();
+ const ref = useRef();
+ const cacheKey = "hashtag-vlist";
+
+ const [offset, cache] = useMemo(() => {
+ const serialized = sessionStorage.getItem(cacheKey);
+ if (!serialized) return [];
+ return JSON.parse(serialized) as [number, CacheSnapshot];
+ }, []);
+
+ const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } =
+ useInfiniteQuery({
+ queryKey: [colKey],
+ initialPageParam: 0,
+ queryFn: async ({
+ signal,
+ pageParam,
+ }: {
+ signal: AbortSignal;
+ pageParam: number;
+ }) => {
+ const events = await ark.getInfiniteEvents({
+ filter: {
+ kinds: [NDKKind.Text],
+ "#t": [hashtag],
+ },
+ 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 allEvents = useMemo(
+ () => (data ? data.pages.flatMap((page) => page) : []),
+ [data],
+ );
+
+ useEffect(() => {
+ if (!ref.current) return;
+ const handle = ref.current;
+
+ if (offset) {
+ handle.scrollTo(offset);
+ }
+
+ return () => {
+ sessionStorage.setItem(
+ cacheKey,
+ JSON.stringify([handle.scrollOffset, handle.cache]),
+ );
+ };
+ }, []);
+
+ return (
+
+
+ {isLoading ? (
+
+
+
+ ) : (
+ allEvents.map((item) => (
+
+ ))
+ )}
+
+ {hasNextPage ? (
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/packages/@columns/hashtag/src/index.tsx b/packages/@columns/hashtag/src/index.tsx
new file mode 100644
index 00000000..7f3037f6
--- /dev/null
+++ b/packages/@columns/hashtag/src/index.tsx
@@ -0,0 +1,30 @@
+import { Column } from "@lume/ark";
+import { HashtagIcon, TimelineIcon } from "@lume/icons";
+import { IColumn } from "@lume/types";
+import { EventRoute } from "./event";
+import { HomeRoute } from "./home";
+import { UserRoute } from "./user";
+
+export function Hashtag({ column }: { column: IColumn }) {
+ const colKey = "hashtag";
+ const hashtag = column.content.replace("#", "");
+
+ return (
+
+ }
+ />
+
+ }
+ />
+ } />
+ } />
+
+
+ );
+}
diff --git a/packages/@columns/hashtag/src/user.tsx b/packages/@columns/hashtag/src/user.tsx
new file mode 100644
index 00000000..8d46f957
--- /dev/null
+++ b/packages/@columns/hashtag/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 ;
+ case NDKKind.Repost:
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ useEffect(() => {
+ if (storage.account.contacts.includes(id)) {
+ setFollowed(true);
+ }
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+

+
+ {followed ? (
+
+ ) : (
+
+ )}
+
+ Message
+
+
+
+
+
+
+ {user?.name ||
+ user?.display_name ||
+ user?.displayName ||
+ "Anon"}
+
+ {user?.nip05 ? (
+
+ ) : (
+
+ {displayNpub(id, 16)}
+
+ )}
+
+
+ {user?.about}
+
+
+
+
+
+ Latest posts
+
+
+ {isLoading ? (
+
+
+
+ ) : (
+ allEvents.map((item) => renderItem(item))
+ )}
+
+ {hasNextPage ? (
+
+ ) : null}
+
+
+
+
+
+ );
+}
diff --git a/packages/@columns/hashtag/tailwind.config.js b/packages/@columns/hashtag/tailwind.config.js
new file mode 100644
index 00000000..49c48c7a
--- /dev/null
+++ b/packages/@columns/hashtag/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/hashtag/tsconfig.json b/packages/@columns/hashtag/tsconfig.json
new file mode 100644
index 00000000..34a32891
--- /dev/null
+++ b/packages/@columns/hashtag/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/thread/src/index.tsx b/packages/@columns/thread/src/index.tsx
index a7e0b415..0cbb6150 100644
--- a/packages/@columns/thread/src/index.tsx
+++ b/packages/@columns/thread/src/index.tsx
@@ -1,15 +1,15 @@
import { Column } from "@lume/ark";
-import { WidgetProps } from "@lume/types";
+import { IColumn } from "@lume/types";
import { EventRoute } from "./event";
import { HomeRoute } from "./home";
import { UserRoute } from "./user";
-export function Thread({ thread }: { thread: WidgetProps }) {
+export function Thread({ column }: { column: IColumn }) {
return (
-
+
- } />
+ } />
} />
} />
diff --git a/packages/@columns/timeline/src/event.tsx b/packages/@columns/timeline/src/event.tsx
index dc9245cf..1174c079 100644
--- a/packages/@columns/timeline/src/event.tsx
+++ b/packages/@columns/timeline/src/event.tsx
@@ -1,4 +1,4 @@
-import { Note, ThreadNote } from "@lume/ark";
+import { ThreadNote } from "@lume/ark";
import { ArrowLeftIcon } from "@lume/icons";
import { ReplyList } from "@lume/ui";
import { useNavigate, useParams } from "react-router-dom";
diff --git a/packages/@columns/user/src/home.tsx b/packages/@columns/user/src/home.tsx
index 30f960e9..f6fe1590 100644
--- a/packages/@columns/user/src/home.tsx
+++ b/packages/@columns/user/src/home.tsx
@@ -11,7 +11,7 @@ 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 { Link, useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { WVList } from "virtua";
diff --git a/packages/@columns/user/src/index.tsx b/packages/@columns/user/src/index.tsx
index e34c21cd..5753b93d 100644
--- a/packages/@columns/user/src/index.tsx
+++ b/packages/@columns/user/src/index.tsx
@@ -1,20 +1,20 @@
import { Column } from "@lume/ark";
import { UserIcon } from "@lume/icons";
-import { WidgetProps } from "@lume/types";
+import { IColumn } from "@lume/types";
import { EventRoute } from "./event";
import { HomeRoute } from "./home";
import { UserRoute } from "./user";
-export function User({ user }: { user: WidgetProps }) {
+export function User({ column }: { column: IColumn }) {
return (
}
/>
- } />
+ } />
} />
} />
diff --git a/packages/ark/src/ark.ts b/packages/ark/src/ark.ts
index 574cbff6..d3c3313a 100644
--- a/packages/ark/src/ark.ts
+++ b/packages/ark/src/ark.ts
@@ -372,8 +372,6 @@ export class Ark {
signal?: AbortSignal;
dedup?: boolean;
}) {
- if (!filter?.authors?.length) return [];
-
const rootIds = new Set();
const dedupQueue = new Set();
const connectedRelays = this.ndk.pool
diff --git a/packages/ark/src/components/column/provider.tsx b/packages/ark/src/components/column/provider.tsx
index 6759d587..7f6583e6 100644
--- a/packages/ark/src/components/column/provider.tsx
+++ b/packages/ark/src/components/column/provider.tsx
@@ -1,5 +1,5 @@
import { IColumn } from "@lume/types";
-import { WIDGET_KIND } from "@lume/utils";
+import { COL_TYPES } from "@lume/utils";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import {
ReactNode,
@@ -27,7 +27,7 @@ export function ColumnProvider({ children }: { children: ReactNode }) {
id: "9999",
title: "Newsfeed",
content: "",
- kind: WIDGET_KIND.newsfeed,
+ kind: COL_TYPES.newsfeed,
},
]);
diff --git a/packages/ark/src/components/note/buttons/pin.tsx b/packages/ark/src/components/note/buttons/pin.tsx
index 139af123..7f5b0081 100644
--- a/packages/ark/src/components/note/buttons/pin.tsx
+++ b/packages/ark/src/components/note/buttons/pin.tsx
@@ -1,5 +1,5 @@
import { PinIcon } from "@lume/icons";
-import { WIDGET_KIND } from "@lume/utils";
+import { COL_TYPES } from "@lume/utils";
import * as Tooltip from "@radix-ui/react-tooltip";
import { useNoteContext } from "..";
import { useColumnContext } from "../../column";
@@ -16,7 +16,7 @@ export function NotePin() {
type="button"
onClick={() =>
addColumn({
- kind: WIDGET_KIND.thread,
+ kind: COL_TYPES.thread,
title: "Thread",
content: event.id,
})
diff --git a/packages/ark/src/components/note/mentions/hashtag.tsx b/packages/ark/src/components/note/mentions/hashtag.tsx
index 4bf27022..bfddf5f5 100644
--- a/packages/ark/src/components/note/mentions/hashtag.tsx
+++ b/packages/ark/src/components/note/mentions/hashtag.tsx
@@ -1,4 +1,4 @@
-import { WIDGET_KIND } from "@lume/utils";
+import { COL_TYPES } from "@lume/utils";
import { useColumnContext } from "../../column";
export function Hashtag({ tag }: { tag: string }) {
@@ -9,9 +9,9 @@ export function Hashtag({ tag }: { tag: string }) {
type="button"
onClick={() =>
addColumn({
- kind: WIDGET_KIND.hashtag,
+ kind: COL_TYPES.hashtag,
title: tag,
- content: tag.replace("#", ""),
+ content: tag,
})
}
className="cursor-default break-all text-blue-500 hover:text-blue-600"
diff --git a/packages/ark/src/components/note/mentions/user.tsx b/packages/ark/src/components/note/mentions/user.tsx
index 3c154acd..c19583c9 100644
--- a/packages/ark/src/components/note/mentions/user.tsx
+++ b/packages/ark/src/components/note/mentions/user.tsx
@@ -1,4 +1,4 @@
-import { WIDGET_KIND } from "@lume/utils";
+import { COL_TYPES } from "@lume/utils";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { memo } from "react";
import { Link } from "react-router-dom";
@@ -30,7 +30,7 @@ export const MentionUser = memo(function MentionUser({
type="button"
onClick={() =>
addColumn({
- kind: WIDGET_KIND.user,
+ kind: COL_TYPES.user,
title: user?.name || user?.displayName || "",
content: pubkey,
})
diff --git a/packages/icons/src/hashtag.tsx b/packages/icons/src/hashtag.tsx
index 98dc565c..167afaf5 100644
--- a/packages/icons/src/hashtag.tsx
+++ b/packages/icons/src/hashtag.tsx
@@ -1,22 +1,24 @@
-import { SVGProps } from 'react';
+import { SVGProps } from "react";
-export function HashtagIcon(props: JSX.IntrinsicAttributes & SVGProps) {
- return (
-
- );
+export function HashtagIcon(
+ props: JSX.IntrinsicAttributes & SVGProps,
+) {
+ return (
+
+ );
}
diff --git a/packages/utils/src/constants.ts b/packages/utils/src/constants.ts
index 911c7f49..b1f2a2fc 100644
--- a/packages/utils/src/constants.ts
+++ b/packages/utils/src/constants.ts
@@ -31,7 +31,7 @@ export const HASHTAGS = [
{ hashtag: "#primal" },
];
-export const WIDGET_KIND = {
+export const COL_TYPES = {
user: 1,
thread: 2,
group: 3,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 32c9d7f7..402e9641 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -20,6 +20,9 @@ importers:
apps/desktop:
dependencies:
+ '@columns/hashtag':
+ specifier: workspace:^
+ version: link:../../packages/@columns/hashtag
'@columns/notification':
specifier: workspace:^
version: link:../../packages/@columns/notification
@@ -286,6 +289,58 @@ importers:
specifier: ^4.2.3
version: 4.2.3(typescript@5.3.3)(vite@4.5.1)
+ packages/@columns/hashtag:
+ 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.2
+ version: 2.3.2(typescript@5.3.3)
+ '@tanstack/react-query':
+ specifier: ^5.15.0
+ version: 5.15.0(react@18.2.0)
+ react:
+ specifier: ^18.2.0
+ version: 18.2.0
+ react-router-dom:
+ specifier: ^6.21.1
+ version: 6.21.1(react-dom@18.2.0)(react@18.2.0)
+ sonner:
+ specifier: ^1.3.1
+ version: 1.3.1(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.46
+ version: 18.2.46
+ tailwind:
+ specifier: ^4.0.0
+ version: 4.0.0
+ typescript:
+ specifier: ^5.3.3
+ version: 5.3.3
+
packages/@columns/notification:
dependencies:
'@lume/ark':