From 292bc8f6eafa99626658793c15854e10a9a39f20 Mon Sep 17 00:00:00 2001 From: codytseng Date: Wed, 27 Nov 2024 22:31:59 +0800 Subject: [PATCH] feat: search --- package-lock.json | 361 ++++++++++++++++++ package.json | 1 + src/renderer/src/PageManager.tsx | 8 +- .../components/Embedded/EmbeddedHashtag.tsx | 4 +- .../src/components/RelaySettings/RelayUrl.tsx | 11 +- .../RelaySettings/TemporaryRelayGroup.tsx | 13 +- .../src/components/SearchButton/index.tsx | 24 ++ .../src/components/SearchDialog/index.tsx | 160 ++++++++ src/renderer/src/components/Sidebar/index.tsx | 4 +- .../src/components/UserItem/index.tsx | 22 ++ src/renderer/src/components/ui/command.tsx | 143 +++++++ src/renderer/src/hooks/index.tsx | 3 + src/renderer/src/hooks/useFetchEventById.tsx | 1 + src/renderer/src/hooks/useFetchProfile.tsx | 1 + src/renderer/src/hooks/useFetchRelayInfos.tsx | 23 ++ src/renderer/src/hooks/useSearchParams.tsx | 24 ++ src/renderer/src/hooks/useSearchProfiles.tsx | 39 ++ src/renderer/src/i18n/en.ts | 9 +- src/renderer/src/i18n/zh.ts | 8 +- .../src/layouts/PrimaryPageLayout/index.tsx | 51 ++- src/renderer/src/lib/link.ts | 16 +- .../secondary/FollowingListPage/index.tsx | 24 +- .../src/pages/secondary/HashtagPage/index.tsx | 18 - .../pages/secondary/NotFoundPage/index.tsx | 7 - .../pages/secondary/NoteListPage/index.tsx | 40 ++ .../pages/secondary/ProfileListPage/index.tsx | 89 +++++ .../src/pages/secondary/ProfilePage/index.tsx | 4 +- .../src/providers/RelaySettingsProvider.tsx | 15 + src/renderer/src/routes.tsx | 8 +- src/renderer/src/services/client.service.ts | 36 +- src/renderer/src/types.ts | 5 + 31 files changed, 1076 insertions(+), 96 deletions(-) create mode 100644 src/renderer/src/components/SearchButton/index.tsx create mode 100644 src/renderer/src/components/SearchDialog/index.tsx create mode 100644 src/renderer/src/components/UserItem/index.tsx create mode 100644 src/renderer/src/components/ui/command.tsx create mode 100644 src/renderer/src/hooks/useFetchRelayInfos.tsx create mode 100644 src/renderer/src/hooks/useSearchParams.tsx create mode 100644 src/renderer/src/hooks/useSearchProfiles.tsx delete mode 100644 src/renderer/src/pages/secondary/HashtagPage/index.tsx create mode 100644 src/renderer/src/pages/secondary/NoteListPage/index.tsx create mode 100644 src/renderer/src/pages/secondary/ProfileListPage/index.tsx diff --git a/package-lock.json b/package-lock.json index d40d62ff..e9bf35fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "@radix-ui/react-toast": "^1.2.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "cmdk": "^1.0.0", "dataloader": "^2.2.2", "dayjs": "^1.11.13", "framer-motion": "^11.11.17", @@ -4693,6 +4694,366 @@ "node": ">=6" } }, + "node_modules/cmdk": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.0.0.tgz", + "integrity": "sha512-gDzVf0a09TvoJ5jnuPvygTB77+XdOSwEmJ88L6XPFPlv7T3RxbP9jgenfylrAMD0+Le1aO0nVjQUzl2g+vjz5Q==", + "dependencies": { + "@radix-ui/react-dialog": "1.0.5", + "@radix-ui/react-primitive": "1.0.3" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/cmdk/node_modules/@radix-ui/primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.1.tgz", + "integrity": "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==", + "dependencies": { + "@babel/runtime": "^7.13.10" + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-compose-refs": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz", + "integrity": "sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-context": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.1.tgz", + "integrity": "sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-dialog": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.0.5.tgz", + "integrity": "sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-dismissable-layer": "1.0.5", + "@radix-ui/react-focus-guards": "1.0.1", + "@radix-ui/react-focus-scope": "1.0.4", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-portal": "1.0.4", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2", + "@radix-ui/react-use-controllable-state": "1.0.1", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.5.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.5.tgz", + "integrity": "sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-escape-keydown": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-focus-guards": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.1.tgz", + "integrity": "sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-focus-scope": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.4.tgz", + "integrity": "sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-id": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.1.tgz", + "integrity": "sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-layout-effect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-portal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.0.4.tgz", + "integrity": "sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-primitive": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-presence": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.1.tgz", + "integrity": "sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-primitive": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz", + "integrity": "sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-slot": "1.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-slot": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", + "integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz", + "integrity": "sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz", + "integrity": "sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz", + "integrity": "sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz", + "integrity": "sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/react-remove-scroll": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz", + "integrity": "sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==", + "dependencies": { + "react-remove-scroll-bar": "^2.3.3", + "react-style-singleton": "^2.2.1", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.0", + "use-sidecar": "^1.1.2" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/color": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", diff --git a/package.json b/package.json index 3af75472..47d50ee0 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@radix-ui/react-toast": "^1.2.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "cmdk": "^1.0.0", "dataloader": "^2.2.2", "dayjs": "^1.11.13", "framer-motion": "^11.11.17", diff --git a/src/renderer/src/PageManager.tsx b/src/renderer/src/PageManager.tsx index ef2f0bfe..bb0e3895 100644 --- a/src/renderer/src/PageManager.tsx +++ b/src/renderer/src/PageManager.tsx @@ -56,9 +56,8 @@ export function PageManager({ const [secondaryStack, setSecondaryStack] = useState([]) useEffect(() => { - const url = window.location.pathname - if (url !== '/') { - pushSecondary(url) + if (window.location.pathname !== '/') { + pushSecondary(window.location.pathname + window.location.search) } const onPopState = (e: PopStateEvent) => { @@ -175,8 +174,9 @@ function isCurrentPage(stack: TStackItem[], url: string) { } function findAndCreateComponent(url: string) { + const path = url.split('?')[0] for (const { matcher, element } of routes) { - const match = matcher(url) + const match = matcher(path) if (!match) continue if (!element) return diff --git a/src/renderer/src/components/Embedded/EmbeddedHashtag.tsx b/src/renderer/src/components/Embedded/EmbeddedHashtag.tsx index bdaee0f8..19faea0b 100644 --- a/src/renderer/src/components/Embedded/EmbeddedHashtag.tsx +++ b/src/renderer/src/components/Embedded/EmbeddedHashtag.tsx @@ -1,4 +1,4 @@ -import { toHashtag } from '@renderer/lib/link' +import { toNoteList } from '@renderer/lib/link' import { SecondaryPageLink } from '@renderer/PageManager' import { TEmbeddedRenderer } from './types' @@ -6,7 +6,7 @@ export function EmbeddedHashtag({ hashtag }: { hashtag: string }) { return ( e.stopPropagation()} > #{hashtag} diff --git a/src/renderer/src/components/RelaySettings/RelayUrl.tsx b/src/renderer/src/components/RelaySettings/RelayUrl.tsx index 2b99bcb8..1338540c 100644 --- a/src/renderer/src/components/RelaySettings/RelayUrl.tsx +++ b/src/renderer/src/components/RelaySettings/RelayUrl.tsx @@ -1,9 +1,10 @@ import { Button } from '@renderer/components/ui/button' import { Input } from '@renderer/components/ui/input' +import { useFetchRelayInfos } from '@renderer/hooks' import { isWebsocketUrl, normalizeUrl } from '@renderer/lib/url' import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider' import client from '@renderer/services/client.service' -import { CircleX } from 'lucide-react' +import { CircleX, SearchCheck } from 'lucide-react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -111,6 +112,9 @@ function RelayUrl({ isConnected: boolean onRemove: () => void }) { + const { t } = useTranslation() + const [relayInfo] = useFetchRelayInfos([url]) + return (
@@ -122,6 +126,11 @@ function RelayUrl({
)}
{url}
+ {relayInfo?.supported_nips?.includes(50) && ( +
+ +
+ )}
(temporaryRelayUrls.map((url) => ({ url, isConnected: false }))) + const relayInfos = useFetchRelayInfos(relays.map((relay) => relay.url)) useEffect(() => { const interval = setInterval(() => { @@ -64,6 +68,11 @@ export default function TemporaryRelayGroup() {
)}
{relay.url}
+ {relayInfos[index]?.supported_nips?.includes(50) && ( +
+ +
+ )}
))} diff --git a/src/renderer/src/components/SearchButton/index.tsx b/src/renderer/src/components/SearchButton/index.tsx new file mode 100644 index 00000000..49ca1a57 --- /dev/null +++ b/src/renderer/src/components/SearchButton/index.tsx @@ -0,0 +1,24 @@ +import { Button } from '@renderer/components/ui/button' +import { Search } from 'lucide-react' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { SearchDialog } from '../SearchDialog' + +export default function RefreshButton({ + variant = 'titlebar' +}: { + variant?: 'titlebar' | 'sidebar' +}) { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + + return ( + <> + + + + ) +} diff --git a/src/renderer/src/components/SearchDialog/index.tsx b/src/renderer/src/components/SearchDialog/index.tsx new file mode 100644 index 00000000..717e8e80 --- /dev/null +++ b/src/renderer/src/components/SearchDialog/index.tsx @@ -0,0 +1,160 @@ +import { SecondaryPageLink } from '@renderer/PageManager' +import { Avatar, AvatarFallback, AvatarImage } from '@renderer/components/ui/avatar' +import { + CommandDialog, + CommandInput, + CommandItem, + CommandList +} from '@renderer/components/ui/command' +import { useSearchProfiles } from '@renderer/hooks' +import { toNote, toNoteList, toProfile, toProfileList } from '@renderer/lib/link' +import { generateImageByPubkey } from '@renderer/lib/pubkey' +import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider' +import { TProfile } from '@renderer/types' +import { Hash, Notebook, UserRound } from 'lucide-react' +import { nip19 } from 'nostr-tools' +import { Dispatch, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' + +export function SearchDialog({ open, setOpen }: { open: boolean; setOpen: Dispatch }) { + const { t } = useTranslation() + const [input, setInput] = useState('') + const [debouncedInput, setDebouncedInput] = useState(input) + const { profiles } = useSearchProfiles(debouncedInput, 10) + + const list = useMemo(() => { + const search = input.trim() + if (!search) return + + if (/^[0-9a-f]{64}$/.test(search)) { + return ( + <> + setOpen(false)} /> + setOpen(false)} /> + + ) + } + + try { + let id = search + if (id.startsWith('nostr:')) { + id = id.slice(6) + } + const { type } = nip19.decode(id) + if (['nprofile', 'npub'].includes(type)) { + return setOpen(false)} /> + } + if (['nevent', 'naddr', 'note'].includes(type)) { + return setOpen(false)} /> + } + } catch { + // ignore + } + + return ( + <> + setOpen(false)} /> + setOpen(false)} /> + {profiles.map((profile) => ( + setOpen(false)} /> + ))} + {profiles.length >= 10 && ( + setOpen(false)}> + setOpen(false)} className="text-center"> +
{t('Show more...')}
+
+
+ )} + + ) + }, [input, profiles]) + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedInput(input) + }, 500) + + return () => { + clearTimeout(handler) + } + }, [input]) + + return ( + + + {list} + + ) +} + +function NormalItem({ search, onClick }: { search: string; onClick?: () => void }) { + const { searchableRelayUrls } = useRelaySettings() + + if (searchableRelayUrls.length === 0) { + return null + } + + return ( + + + +
{search}
+
+
+ ) +} + +function HashtagItem({ search, onClick }: { search: string; onClick?: () => void }) { + const hashtag = search.match(/[\p{L}\p{N}\p{M}]+/u)?.[0].toLowerCase() + return ( + + + +
{hashtag}
+
+
+ ) +} + +function NoteItem({ id, onClick }: { id: string; onClick?: () => void }) { + return ( + + + +
{id}
+
+
+ ) +} + +function ProfileIdItem({ id, onClick }: { id: string; onClick?: () => void }) { + return ( + + + +
{id}
+
+
+ ) +} + +function ProfileItem({ profile, onClick }: { profile: TProfile; onClick?: () => void }) { + return ( + + +
+ + + + {profile.username} + + +
+
{profile.username}
+
{profile.about}
+
+
+
+
+ ) +} diff --git a/src/renderer/src/components/Sidebar/index.tsx b/src/renderer/src/components/Sidebar/index.tsx index 0620e49e..35b01d2c 100644 --- a/src/renderer/src/components/Sidebar/index.tsx +++ b/src/renderer/src/components/Sidebar/index.tsx @@ -3,12 +3,13 @@ import { IS_ELECTRON } from '@renderer/lib/env' import { toHome } from '@renderer/lib/link' import { SecondaryPageLink } from '@renderer/PageManager' import { Info } from 'lucide-react' +import { useTranslation } from 'react-i18next' import AboutInfoDialog from '../AboutInfoDialog' import AccountButton from '../AccountButton' import PostButton from '../PostButton' import RefreshButton from '../RefreshButton' import RelaySettingsPopover from '../RelaySettingsPopover' -import { useTranslation } from 'react-i18next' +import SearchButton from '../SearchButton' export default function PrimaryPageSidebar() { const { t } = useTranslation() @@ -20,6 +21,7 @@ export default function PrimaryPageSidebar() { + {!IS_ELECTRON && ( diff --git a/src/renderer/src/components/UserItem/index.tsx b/src/renderer/src/components/UserItem/index.tsx new file mode 100644 index 00000000..b20ee111 --- /dev/null +++ b/src/renderer/src/components/UserItem/index.tsx @@ -0,0 +1,22 @@ +import FollowButton from '@renderer/components/FollowButton' +import Nip05 from '@renderer/components/Nip05' +import UserAvatar from '@renderer/components/UserAvatar' +import Username from '@renderer/components/Username' +import { useFetchProfile } from '@renderer/hooks' + +export default function UserItem({ pubkey }: { pubkey: string }) { + const { profile } = useFetchProfile(pubkey) + const { nip05, about } = profile || {} + + return ( +
+ +
+ + +
{about}
+
+ +
+ ) +} diff --git a/src/renderer/src/components/ui/command.tsx b/src/renderer/src/components/ui/command.tsx new file mode 100644 index 00000000..a17bec38 --- /dev/null +++ b/src/renderer/src/components/ui/command.tsx @@ -0,0 +1,143 @@ +import { type DialogProps } from '@radix-ui/react-dialog' +import { Command as CommandPrimitive } from 'cmdk' +import { Search } from 'lucide-react' +import * as React from 'react' + +import { Dialog, DialogContent } from '@renderer/components/ui/dialog' +import { ScrollArea } from '@renderer/components/ui/scroll-area' +import { cn } from '@renderer/lib/utils' + +const Command = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Command.displayName = CommandPrimitive.displayName + +const CommandDialog = ({ children, ...props }: DialogProps) => { + return ( + + + + {children} + + + + ) +} + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)) + +CommandInput.displayName = CommandPrimitive.Input.displayName + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) + +CommandList.displayName = CommandPrimitive.List.displayName + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)) + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandGroup.displayName = CommandPrimitive.Group.displayName + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +CommandSeparator.displayName = CommandPrimitive.Separator.displayName + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandItem.displayName = CommandPrimitive.Item.displayName + +const CommandShortcut = ({ className, ...props }: React.HTMLAttributes) => { + return ( + + ) +} +CommandShortcut.displayName = 'CommandShortcut' + +export { + Command, + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, + CommandShortcut +} diff --git a/src/renderer/src/hooks/index.tsx b/src/renderer/src/hooks/index.tsx index e8504007..626ea691 100644 --- a/src/renderer/src/hooks/index.tsx +++ b/src/renderer/src/hooks/index.tsx @@ -3,3 +3,6 @@ export * from './useFetchEventById' export * from './useFetchFollowings' export * from './useFetchNip05' export * from './useFetchProfile' +export * from './useFetchRelayInfos' +export * from './useSearchParams' +export * from './useSearchProfiles' diff --git a/src/renderer/src/hooks/useFetchEventById.tsx b/src/renderer/src/hooks/useFetchEventById.tsx index c99c92c1..d25d7b93 100644 --- a/src/renderer/src/hooks/useFetchEventById.tsx +++ b/src/renderer/src/hooks/useFetchEventById.tsx @@ -9,6 +9,7 @@ export function useFetchEventById(id?: string) { useEffect(() => { const fetchEvent = async () => { + setIsFetching(true) if (!id) { setIsFetching(false) setError(new Error('No id provided')) diff --git a/src/renderer/src/hooks/useFetchProfile.tsx b/src/renderer/src/hooks/useFetchProfile.tsx index e5e462dc..9abb6636 100644 --- a/src/renderer/src/hooks/useFetchProfile.tsx +++ b/src/renderer/src/hooks/useFetchProfile.tsx @@ -9,6 +9,7 @@ export function useFetchProfile(id?: string) { useEffect(() => { const fetchProfile = async () => { + setIsFetching(true) try { if (!id) { setIsFetching(false) diff --git a/src/renderer/src/hooks/useFetchRelayInfos.tsx b/src/renderer/src/hooks/useFetchRelayInfos.tsx new file mode 100644 index 00000000..ce001493 --- /dev/null +++ b/src/renderer/src/hooks/useFetchRelayInfos.tsx @@ -0,0 +1,23 @@ +import client from '@renderer/services/client.service' +import { TRelayInfo } from '@renderer/types' +import { useEffect, useState } from 'react' + +export function useFetchRelayInfos(urls: string[]) { + const [relayInfos, setRelayInfos] = useState<(TRelayInfo | undefined)[]>([]) + + useEffect(() => { + const fetchRelayInfos = async () => { + if (urls.length === 0) return + try { + const relayInfos = await client.fetchRelayInfos(urls) + setRelayInfos(relayInfos) + } catch (err) { + console.error(err) + } + } + + fetchRelayInfos() + }, [JSON.stringify(urls)]) + + return relayInfos +} diff --git a/src/renderer/src/hooks/useSearchParams.tsx b/src/renderer/src/hooks/useSearchParams.tsx new file mode 100644 index 00000000..2f492d35 --- /dev/null +++ b/src/renderer/src/hooks/useSearchParams.tsx @@ -0,0 +1,24 @@ +export function useSearchParams() { + const searchParams = new URLSearchParams(window.location.search) + + return { + searchParams, + get: (key: string) => searchParams.get(key), + set: (key: string, value: string) => { + searchParams.set(key, value) + window.history.replaceState( + null, + '', + `${window.location.pathname}?${searchParams.toString()}` + ) + }, + delete: (key: string) => { + searchParams.delete(key) + window.history.replaceState( + null, + '', + `${window.location.pathname}?${searchParams.toString()}` + ) + } + } +} diff --git a/src/renderer/src/hooks/useSearchProfiles.tsx b/src/renderer/src/hooks/useSearchProfiles.tsx new file mode 100644 index 00000000..fb59527f --- /dev/null +++ b/src/renderer/src/hooks/useSearchProfiles.tsx @@ -0,0 +1,39 @@ +import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider' +import client from '@renderer/services/client.service' +import { TProfile } from '@renderer/types' +import { useEffect, useState } from 'react' + +export function useSearchProfiles(search: string, limit: number) { + const { searchableRelayUrls } = useRelaySettings() + const [isFetching, setIsFetching] = useState(true) + const [error, setError] = useState(null) + const [profiles, setProfiles] = useState([]) + + useEffect(() => { + const fetchProfiles = async () => { + setIsFetching(true) + setProfiles([]) + if (searchableRelayUrls.length === 0) { + setIsFetching(false) + return + } + try { + const profiles = await client.fetchProfiles(searchableRelayUrls, { + search, + limit + }) + if (profiles) { + setProfiles(profiles) + } + } catch (err) { + setError(err as Error) + } finally { + setIsFetching(false) + } + } + + fetchProfiles() + }, [searchableRelayUrls, search, limit]) + + return { isFetching, error, profiles } +} diff --git a/src/renderer/src/i18n/en.ts b/src/renderer/src/i18n/en.ts index b82250f8..42fcd8e4 100644 --- a/src/renderer/src/i18n/en.ts +++ b/src/renderer/src/i18n/en.ts @@ -62,6 +62,13 @@ export default { 'Lost in the void': 'Lost in the void', 'Carry me home': 'Carry me home', 'no replies': 'no replies', - 'Reply to': 'Reply to' + 'Reply to': 'Reply to', + Search: 'Search', + search: 'search', + 'The relays you are connected to do not support search': + 'The relays you are connected to do not support search', + 'supports search': 'supports search', + 'Show more...': 'Show more...', + 'all users': 'all users' } } diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index 672f6f4b..58467ef4 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -62,6 +62,12 @@ export default { 'Lost in the void': '迷失在虚空中', 'Carry me home': '带我回家', 'no replies': '暂无回复', - 'Reply to': '回复' + 'Reply to': '回复', + Search: '搜索', + search: '搜索', + 'The relays you are connected to do not support search': '您连接的服务器不支持搜索', + 'supports search': '支持搜索', + 'Show more...': '查看更多...', + 'all users': '所有用户' } } diff --git a/src/renderer/src/layouts/PrimaryPageLayout/index.tsx b/src/renderer/src/layouts/PrimaryPageLayout/index.tsx index eae817ef..18fd1538 100644 --- a/src/renderer/src/layouts/PrimaryPageLayout/index.tsx +++ b/src/renderer/src/layouts/PrimaryPageLayout/index.tsx @@ -3,40 +3,33 @@ import PostButton from '@renderer/components/PostButton' import RefreshButton from '@renderer/components/RefreshButton' import RelaySettingsPopover from '@renderer/components/RelaySettingsPopover' import ScrollToTopButton from '@renderer/components/ScrollToTopButton' +import SearchButton from '@renderer/components/SearchButton' import { Titlebar } from '@renderer/components/Titlebar' import { ScrollArea } from '@renderer/components/ui/scroll-area' import { isMacOS } from '@renderer/lib/env' import { forwardRef, useImperativeHandle, useRef } from 'react' -const PrimaryPageLayout = forwardRef( - ( - { - children, - titlebarContent - }: { children?: React.ReactNode; titlebarContent?: React.ReactNode }, - ref - ) => { - const scrollAreaRef = useRef(null) +const PrimaryPageLayout = forwardRef(({ children }: { children?: React.ReactNode }, ref) => { + const scrollAreaRef = useRef(null) - useImperativeHandle( - ref, - () => ({ - scrollToTop: () => { - scrollAreaRef.current?.scrollTo({ top: 0 }) - } - }), - [] - ) + useImperativeHandle( + ref, + () => ({ + scrollToTop: () => { + scrollAreaRef.current?.scrollTo({ top: 0 }) + } + }), + [] + ) - return ( - - -
{children}
- -
- ) - } -) + return ( + + +
{children}
+ +
+ ) +}) PrimaryPageLayout.displayName = 'PrimaryPageLayout' export default PrimaryPageLayout @@ -44,13 +37,13 @@ export type TPrimaryPageLayoutRef = { scrollToTop: () => void } -export function PrimaryPageTitlebar({ content }: { content?: React.ReactNode }) { +export function PrimaryPageTitlebar() { return (
- {content} +
diff --git a/src/renderer/src/lib/link.ts b/src/renderer/src/lib/link.ts index caf93494..84b8a918 100644 --- a/src/renderer/src/lib/link.ts +++ b/src/renderer/src/lib/link.ts @@ -1,7 +1,19 @@ export const toHome = () => '/' -export const toProfile = (pubkey: string) => `/user/${pubkey}` export const toNote = (eventId: string) => `/note/${eventId}` -export const toHashtag = (hashtag: string) => `/hashtag/${hashtag}` +export const toNoteList = ({ hashtag, search }: { hashtag?: string; search?: string }) => { + const path = '/note' + const query = new URLSearchParams() + if (hashtag) query.set('t', hashtag.toLowerCase()) + if (search) query.set('s', search) + return `${path}?${query.toString()}` +} +export const toProfile = (pubkey: string) => `/user/${pubkey}` +export const toProfileList = ({ search }: { search?: string }) => { + const path = '/user' + const query = new URLSearchParams() + if (search) query.set('s', search) + return `${path}?${query.toString()}` +} export const toFollowingList = (pubkey: string) => `/user/${pubkey}/following` export const toNoStrudelProfile = (id: string) => `https://nostrudel.ninja/#/u/${id}` diff --git a/src/renderer/src/pages/secondary/FollowingListPage/index.tsx b/src/renderer/src/pages/secondary/FollowingListPage/index.tsx index 08ab0c5b..30773d00 100644 --- a/src/renderer/src/pages/secondary/FollowingListPage/index.tsx +++ b/src/renderer/src/pages/secondary/FollowingListPage/index.tsx @@ -1,7 +1,4 @@ -import FollowButton from '@renderer/components/FollowButton' -import Nip05 from '@renderer/components/Nip05' -import UserAvatar from '@renderer/components/UserAvatar' -import Username from '@renderer/components/Username' +import UserItem from '@renderer/components/UserItem' import { useFetchFollowings, useFetchProfile } from '@renderer/hooks' import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout' import { useEffect, useRef, useState } from 'react' @@ -56,27 +53,10 @@ export default function FollowingListPage({ id }: { id?: string }) { >
{visibleFollowings.map((pubkey, index) => ( - + ))} {followings.length > visibleFollowings.length &&
}
) } - -function FollowingItem({ pubkey }: { pubkey: string }) { - const { profile } = useFetchProfile(pubkey) - const { nip05, about } = profile || {} - - return ( -
- -
- - -
{about}
-
- -
- ) -} diff --git a/src/renderer/src/pages/secondary/HashtagPage/index.tsx b/src/renderer/src/pages/secondary/HashtagPage/index.tsx deleted file mode 100644 index f7cfc9f8..00000000 --- a/src/renderer/src/pages/secondary/HashtagPage/index.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import NoteList from '@renderer/components/NoteList' -import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout' -import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider' -import NotFoundPage from '../NotFoundPage' - -export default function HashtagPage({ id }: { id?: string }) { - const { relayUrls } = useRelaySettings() - if (!id) { - return - } - const hashtag = id.toLowerCase() - - return ( - - - - ) -} diff --git a/src/renderer/src/pages/secondary/NotFoundPage/index.tsx b/src/renderer/src/pages/secondary/NotFoundPage/index.tsx index 3114e0a6..0338b8a2 100644 --- a/src/renderer/src/pages/secondary/NotFoundPage/index.tsx +++ b/src/renderer/src/pages/secondary/NotFoundPage/index.tsx @@ -1,21 +1,14 @@ -import { Button } from '@renderer/components/ui/button' import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout' -import { toHome } from '@renderer/lib/link' -import { useSecondaryPage } from '@renderer/PageManager' import { useTranslation } from 'react-i18next' export default function NotFoundPage() { const { t } = useTranslation() - const { push } = useSecondaryPage() return (
{t('Lost in the void')} 🌌
(404)
-
) diff --git a/src/renderer/src/pages/secondary/NoteListPage/index.tsx b/src/renderer/src/pages/secondary/NoteListPage/index.tsx new file mode 100644 index 00000000..6c73f08b --- /dev/null +++ b/src/renderer/src/pages/secondary/NoteListPage/index.tsx @@ -0,0 +1,40 @@ +import NoteList from '@renderer/components/NoteList' +import { useSearchParams } from '@renderer/hooks' +import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout' +import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider' +import { Filter } from 'nostr-tools' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' + +export default function NoteListPage() { + const { t } = useTranslation() + const { relayUrls, searchableRelayUrls } = useRelaySettings() + const { searchParams } = useSearchParams() + const [title, filter] = useMemo<[string, Filter] | [undefined, undefined]>(() => { + const hashtag = searchParams.get('t') + if (hashtag) { + return [`# ${hashtag}`, { '#t': [hashtag] }] + } + const search = searchParams.get('s') + if (search) { + return [`${t('search')}: ${search}`, { search }] + } + return [undefined, undefined] + }, [searchParams]) + + if (!filter || (filter.search && searchableRelayUrls.length === 0)) { + return ( + +
+ {t('The relays you are connected to do not support search')} +
+
+ ) + } + + return ( + + + + ) +} diff --git a/src/renderer/src/pages/secondary/ProfileListPage/index.tsx b/src/renderer/src/pages/secondary/ProfileListPage/index.tsx new file mode 100644 index 00000000..b116fd64 --- /dev/null +++ b/src/renderer/src/pages/secondary/ProfileListPage/index.tsx @@ -0,0 +1,89 @@ +import UserItem from '@renderer/components/UserItem' +import { useSearchParams } from '@renderer/hooks' +import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout' +import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider' +import client from '@renderer/services/client.service' +import dayjs from 'dayjs' +import { Filter } from 'nostr-tools' +import { useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' + +const LIMIT = 50 + +export default function ProfileListPage() { + const { t } = useTranslation() + const { searchParams } = useSearchParams() + const { relayUrls, searchableRelayUrls } = useRelaySettings() + const [until, setUntil] = useState(() => dayjs().unix()) + const [hasMore, setHasMore] = useState(true) + const [pubkeySet, setPubkeySet] = useState(new Set()) + const observer = useRef(null) + const bottomRef = useRef(null) + const filter = useMemo(() => { + const f: Filter = { until } + const search = searchParams.get('s') + if (search) { + f.search = search + } + return f + }, [searchParams, until]) + const urls = useMemo(() => { + return filter.search ? searchableRelayUrls : relayUrls + }, [relayUrls, searchableRelayUrls, filter]) + const title = useMemo(() => { + return filter.search ? `${t('search')}: ${filter.search}` : t('all users') + }, [filter]) + + useEffect(() => { + if (!hasMore) return + const options = { + root: null, + rootMargin: '10px', + threshold: 1 + } + + observer.current = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && hasMore) { + loadMore() + } + }, options) + + if (bottomRef.current) { + observer.current.observe(bottomRef.current) + } + + return () => { + if (observer.current && bottomRef.current) { + observer.current.unobserve(bottomRef.current) + } + } + }, [filter, hasMore]) + + async function loadMore() { + if (urls.length === 0) { + return setHasMore(false) + } + const profiles = await client.fetchProfiles(urls, { ...filter, limit: LIMIT }) + const newPubkeySet = new Set() + profiles.forEach((profile) => { + if (!pubkeySet.has(profile.pubkey)) { + newPubkeySet.add(profile.pubkey) + } + }) + setPubkeySet((prev) => new Set([...prev, ...newPubkeySet])) + setHasMore(profiles.length >= LIMIT) + const lastProfileCreatedAt = profiles[profiles.length - 1].created_at + setUntil(lastProfileCreatedAt ? lastProfileCreatedAt - 1 : 0) + } + + return ( + +
+ {Array.from(pubkeySet).map((pubkey, index) => ( + + ))} + {hasMore &&
} +
+ + ) +} diff --git a/src/renderer/src/pages/secondary/ProfilePage/index.tsx b/src/renderer/src/pages/secondary/ProfilePage/index.tsx index a6f4d2f9..a68d1465 100644 --- a/src/renderer/src/pages/secondary/ProfilePage/index.tsx +++ b/src/renderer/src/pages/secondary/ProfilePage/index.tsx @@ -19,11 +19,13 @@ import NotFoundPage from '../NotFoundPage' import PubkeyCopy from './PubkeyCopy' import QrCodePopover from './QrCodePopover' import { useTranslation } from 'react-i18next' +import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider' export default function ProfilePage({ id }: { id?: string }) { const { t } = useTranslation() const { profile, isFetching } = useFetchProfile(id) const relayList = useFetchRelayList(profile?.pubkey) + const { relayUrls: currentRelayUrls } = useRelaySettings() const { pubkey: accountPubkey } = useNostr() const { followings: selfFollowings } = useFollowList() const { followings } = useFetchFollowings(profile?.pubkey) @@ -96,7 +98,7 @@ export default function ProfilePage({ id }: { id?: string }) { ) diff --git a/src/renderer/src/providers/RelaySettingsProvider.tsx b/src/renderer/src/providers/RelaySettingsProvider.tsx index b73b4f07..7fc24598 100644 --- a/src/renderer/src/providers/RelaySettingsProvider.tsx +++ b/src/renderer/src/providers/RelaySettingsProvider.tsx @@ -1,5 +1,6 @@ import { TRelayGroup } from '@common/types' import { isWebsocketUrl, normalizeUrl } from '@renderer/lib/url' +import client from '@renderer/services/client.service' import storage from '@renderer/services/storage.service' import { createContext, Dispatch, useContext, useEffect, useState } from 'react' @@ -7,6 +8,7 @@ type TRelaySettingsContext = { relayGroups: TRelayGroup[] temporaryRelayUrls: string[] relayUrls: string[] + searchableRelayUrls: string[] switchRelayGroup: (groupName: string) => void renameRelayGroup: (oldGroupName: string, newGroupName: string) => string | null deleteRelayGroup: (groupName: string) => void @@ -33,6 +35,7 @@ export function RelaySettingsProvider({ children }: { children: React.ReactNode ? temporaryRelayUrls : (relayGroups.find((group) => group.isActive)?.relayUrls ?? []) ) + const [searchableRelayUrls, setSearchableRelayUrls] = useState([]) useEffect(() => { const init = async () => { @@ -67,6 +70,17 @@ export function RelaySettingsProvider({ children }: { children: React.ReactNode ) }, [relayGroups, temporaryRelayUrls]) + useEffect(() => { + const handler = async () => { + setSearchableRelayUrls([]) + const relayInfos = await client.fetchRelayInfos(relayUrls) + setSearchableRelayUrls( + relayUrls.filter((_, index) => relayInfos[index]?.supported_nips?.includes(50)) + ) + } + handler() + }, [relayUrls]) + const updateGroups = async (fn: (pre: TRelayGroup[]) => TRelayGroup[]) => { let newGroups = relayGroups setRelayGroups((pre) => { @@ -147,6 +161,7 @@ export function RelaySettingsProvider({ children }: { children: React.ReactNode relayGroups, temporaryRelayUrls, relayUrls, + searchableRelayUrls, switchRelayGroup, renameRelayGroup, deleteRelayGroup, diff --git a/src/renderer/src/routes.tsx b/src/renderer/src/routes.tsx index 44994bb9..9581735b 100644 --- a/src/renderer/src/routes.tsx +++ b/src/renderer/src/routes.tsx @@ -1,17 +1,19 @@ import { match } from 'path-to-regexp' import { isValidElement } from 'react' import FollowingListPage from './pages/secondary/FollowingListPage' -import HashtagPage from './pages/secondary/HashtagPage' import HomePage from './pages/secondary/HomePage' +import NoteListPage from './pages/secondary/NoteListPage' import NotePage from './pages/secondary/NotePage' +import ProfileListPage from './pages/secondary/ProfileListPage' import ProfilePage from './pages/secondary/ProfilePage' const ROUTES = [ { path: '/', element: }, + { path: '/note', element: }, { path: '/note/:id', element: }, + { path: '/user', element: }, { path: '/user/:id', element: }, - { path: '/user/:id/following', element: }, - { path: '/hashtag/:id', element: } + { path: '/user/:id/following', element: } ] export const routes = ROUTES.map(({ path, element }) => ({ diff --git a/src/renderer/src/services/client.service.ts b/src/renderer/src/services/client.service.ts index ac574470..eab6be2a 100644 --- a/src/renderer/src/services/client.service.ts +++ b/src/renderer/src/services/client.service.ts @@ -3,7 +3,7 @@ import { isReplyNoteEvent } from '@renderer/lib/event' import { formatPubkey } from '@renderer/lib/pubkey' import { tagNameEquals } from '@renderer/lib/tag' import { isWebsocketUrl, normalizeUrl } from '@renderer/lib/url' -import { TProfile, TRelayList } from '@renderer/types' +import { TProfile, TRelayInfo, TRelayList } from '@renderer/types' import DataLoader from 'dataloader' import { LRUCache } from 'lru-cache' import { @@ -55,6 +55,19 @@ class ClientService { cacheMap: new LRUCache>({ max: 10000 }) } ) + private relayInfoDataLoader = new DataLoader(async (urls) => { + return await Promise.all( + urls.map(async (url) => { + return (await ( + await fetch(url.replace('ws://', 'http://').replace('wss://', 'https://'), { + headers: { Accept: 'application/nostr+json' } + }) + ) + .json() + .catch(() => undefined)) as TRelayInfo | undefined + }) + ) + }) private followListCache = new LRUCache>({ max: 10000, fetchMethod: this._fetchFollowListEvent.bind(this) @@ -329,6 +342,19 @@ class ClientService { return this.profileDataloader.load(id) } + async fetchProfiles(relayUrls: string[], filter: Filter): Promise { + const events = await this.pool.querySync(relayUrls, { + ...filter, + kinds: [kinds.Metadata] + }) + + const profiles = events + .sort((a, b) => b.created_at - a.created_at) + .map((event) => this.parseProfileFromEvent(event)) + profiles.forEach((profile) => this.profileDataloader.prime(profile.pubkey, profile)) + return profiles + } + async fetchRelayList(pubkey: string): Promise { return this.relayListDataLoader.load(pubkey) } @@ -341,6 +367,11 @@ class ClientService { this.followListCache.set(pubkey, Promise.resolve(event)) } + async fetchRelayInfos(urls: string[]) { + const infos = await this.relayInfoDataLoader.loadMany(urls) + return infos.map((info) => (info ? (info instanceof Error ? undefined : info) : undefined)) + } + private async fetchEventById(relayUrls: string[], id: string): Promise { const event = await this.fetchEventFromBigRelaysDataloader.load(id) if (event) { @@ -561,7 +592,8 @@ class ClientService { profileObj.nip05?.split('@')[0]?.trim() || formatPubkey(event.pubkey), nip05: profileObj.nip05, - about: profileObj.about + about: profileObj.about, + created_at: event.created_at } } catch (err) { console.error(err) diff --git a/src/renderer/src/types.ts b/src/renderer/src/types.ts index 22c0967f..a1a4bbc0 100644 --- a/src/renderer/src/types.ts +++ b/src/renderer/src/types.ts @@ -5,9 +5,14 @@ export type TProfile = { avatar?: string nip05?: string about?: string + created_at?: number } export type TRelayList = { write: string[] read: string[] } + +export type TRelayInfo = { + supported_nips?: number[] +}