feat: search
This commit is contained in:
361
package-lock.json
generated
361
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -56,9 +56,8 @@ export function PageManager({
|
||||
const [secondaryStack, setSecondaryStack] = useState<TStackItem[]>([])
|
||||
|
||||
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 <NotFoundPage />
|
||||
|
||||
@@ -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 (
|
||||
<SecondaryPageLink
|
||||
className="text-highlight hover:underline"
|
||||
to={toHashtag(hashtag)}
|
||||
to={toNoteList({ hashtag })}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
#{hashtag}
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-2 items-center">
|
||||
@@ -122,6 +126,11 @@ function RelayUrl({
|
||||
<div className="text-red-500 text-xs">●</div>
|
||||
)}
|
||||
<div className="text-muted-foreground text-sm">{url}</div>
|
||||
{relayInfo?.supported_nips?.includes(50) && (
|
||||
<div title={t('supports search')} className="text-highlight">
|
||||
<SearchCheck size={14} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<CircleX
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import { useFetchRelayInfos } from '@renderer/hooks'
|
||||
import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider'
|
||||
import client from '@renderer/services/client.service'
|
||||
import { Save } from 'lucide-react'
|
||||
import { Save, SearchCheck } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button } from '../ui/button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function TemporaryRelayGroup() {
|
||||
const { t } = useTranslation()
|
||||
const { temporaryRelayUrls, relayGroups, addRelayGroup, switchRelayGroup } = useRelaySettings()
|
||||
const [relays, setRelays] = useState<
|
||||
{
|
||||
@@ -12,6 +15,7 @@ export default function TemporaryRelayGroup() {
|
||||
isConnected: boolean
|
||||
}[]
|
||||
>(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() {
|
||||
<div className="text-red-500 text-xs">●</div>
|
||||
)}
|
||||
<div className="text-muted-foreground text-sm">{relay.url}</div>
|
||||
{relayInfos[index]?.supported_nips?.includes(50) && (
|
||||
<div title={t('supports search')} className="text-highlight">
|
||||
<SearchCheck size={14} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
24
src/renderer/src/components/SearchButton/index.tsx
Normal file
24
src/renderer/src/components/SearchButton/index.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<Button variant={variant} size={variant} onClick={() => setOpen(true)} title={t('Search')}>
|
||||
<Search />
|
||||
{variant === 'sidebar' && <div>{t('Search')}</div>}
|
||||
</Button>
|
||||
<SearchDialog open={open} setOpen={setOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
160
src/renderer/src/components/SearchDialog/index.tsx
Normal file
160
src/renderer/src/components/SearchDialog/index.tsx
Normal file
@@ -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<boolean> }) {
|
||||
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 (
|
||||
<>
|
||||
<NoteItem id={search} onClick={() => setOpen(false)} />
|
||||
<ProfileIdItem id={search} onClick={() => 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 <ProfileIdItem id={id} onClick={() => setOpen(false)} />
|
||||
}
|
||||
if (['nevent', 'naddr', 'note'].includes(type)) {
|
||||
return <NoteItem id={id} onClick={() => setOpen(false)} />
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<NormalItem search={search} onClick={() => setOpen(false)} />
|
||||
<HashtagItem search={search} onClick={() => setOpen(false)} />
|
||||
{profiles.map((profile) => (
|
||||
<ProfileItem key={profile.pubkey} profile={profile} onClick={() => setOpen(false)} />
|
||||
))}
|
||||
{profiles.length >= 10 && (
|
||||
<SecondaryPageLink to={toProfileList({ search })} onClick={() => setOpen(false)}>
|
||||
<CommandItem onClick={() => setOpen(false)} className="text-center">
|
||||
<div className="font-semibold">{t('Show more...')}</div>
|
||||
</CommandItem>
|
||||
</SecondaryPageLink>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}, [input, profiles])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedInput(input)
|
||||
}, 500)
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler)
|
||||
}
|
||||
}, [input])
|
||||
|
||||
return (
|
||||
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||
<CommandInput value={input} onValueChange={setInput} />
|
||||
<CommandList>{list}</CommandList>
|
||||
</CommandDialog>
|
||||
)
|
||||
}
|
||||
|
||||
function NormalItem({ search, onClick }: { search: string; onClick?: () => void }) {
|
||||
const { searchableRelayUrls } = useRelaySettings()
|
||||
|
||||
if (searchableRelayUrls.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<SecondaryPageLink to={toNoteList({ search })} onClick={onClick}>
|
||||
<CommandItem>
|
||||
<Notebook className="text-muted-foreground" />
|
||||
<div className="font-semibold">{search}</div>
|
||||
</CommandItem>
|
||||
</SecondaryPageLink>
|
||||
)
|
||||
}
|
||||
|
||||
function HashtagItem({ search, onClick }: { search: string; onClick?: () => void }) {
|
||||
const hashtag = search.match(/[\p{L}\p{N}\p{M}]+/u)?.[0].toLowerCase()
|
||||
return (
|
||||
<SecondaryPageLink to={toNoteList({ hashtag })} onClick={onClick}>
|
||||
<CommandItem value={`hashtag-${hashtag}`}>
|
||||
<Hash className="text-muted-foreground" />
|
||||
<div className="font-semibold">{hashtag}</div>
|
||||
</CommandItem>
|
||||
</SecondaryPageLink>
|
||||
)
|
||||
}
|
||||
|
||||
function NoteItem({ id, onClick }: { id: string; onClick?: () => void }) {
|
||||
return (
|
||||
<SecondaryPageLink to={toNote(id)} onClick={onClick}>
|
||||
<CommandItem>
|
||||
<Notebook className="text-muted-foreground" />
|
||||
<div className="font-semibold truncate">{id}</div>
|
||||
</CommandItem>
|
||||
</SecondaryPageLink>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileIdItem({ id, onClick }: { id: string; onClick?: () => void }) {
|
||||
return (
|
||||
<SecondaryPageLink to={toProfile(id)} onClick={onClick}>
|
||||
<CommandItem>
|
||||
<UserRound className="text-muted-foreground" />
|
||||
<div className="font-semibold truncate">{id}</div>
|
||||
</CommandItem>
|
||||
</SecondaryPageLink>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileItem({ profile, onClick }: { profile: TProfile; onClick?: () => void }) {
|
||||
return (
|
||||
<SecondaryPageLink to={toProfile(profile.pubkey)} onClick={onClick}>
|
||||
<CommandItem>
|
||||
<div className="flex gap-2">
|
||||
<Avatar>
|
||||
<AvatarImage src={profile.avatar} alt={profile.username} />
|
||||
<AvatarFallback>
|
||||
<img src={generateImageByPubkey(profile.pubkey)} alt={profile.username} />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="font-semibold">{profile.username}</div>
|
||||
<div className="line-clamp-1 text-muted-foreground">{profile.about}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</SecondaryPageLink>
|
||||
)
|
||||
}
|
||||
@@ -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() {
|
||||
</div>
|
||||
<PostButton variant="sidebar" />
|
||||
<RelaySettingsPopover variant="sidebar" />
|
||||
<SearchButton variant="sidebar" />
|
||||
<RefreshButton variant="sidebar" />
|
||||
{!IS_ELECTRON && (
|
||||
<AboutInfoDialog>
|
||||
|
||||
22
src/renderer/src/components/UserItem/index.tsx
Normal file
22
src/renderer/src/components/UserItem/index.tsx
Normal file
@@ -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 (
|
||||
<div className="flex gap-2 items-start">
|
||||
<UserAvatar userId={pubkey} className="shrink-0" />
|
||||
<div className="w-full overflow-hidden">
|
||||
<Username userId={pubkey} className="font-semibold truncate" skeletonClassName="h-4" />
|
||||
<Nip05 nip05={nip05} pubkey={pubkey} />
|
||||
<div className="truncate text-muted-foreground text-sm">{about}</div>
|
||||
</div>
|
||||
<FollowButton pubkey={pubkey} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
143
src/renderer/src/components/ui/command.tsx
Normal file
143
src/renderer/src/components/ui/command.tsx
Normal file
@@ -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<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Command.displayName = CommandPrimitive.displayName
|
||||
|
||||
const CommandDialog = ({ children, ...props }: DialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0 shadow-lg top-4 translate-y-0 data-[state=closed]:slide-out-to-top-0 data-[state=open]:slide-in-from-top-0">
|
||||
<Command
|
||||
shouldFilter={false}
|
||||
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"
|
||||
>
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 pr-6',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ScrollArea className="max-h-[80vh]">
|
||||
<CommandPrimitive.List ref={ref} className={cn('overflow-x-hidden', className)} {...props} />
|
||||
</ScrollArea>
|
||||
))
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
|
||||
))
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 h-px bg-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||
|
||||
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
CommandShortcut.displayName = 'CommandShortcut'
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -9,6 +9,7 @@ export function useFetchProfile(id?: string) {
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProfile = async () => {
|
||||
setIsFetching(true)
|
||||
try {
|
||||
if (!id) {
|
||||
setIsFetching(false)
|
||||
|
||||
23
src/renderer/src/hooks/useFetchRelayInfos.tsx
Normal file
23
src/renderer/src/hooks/useFetchRelayInfos.tsx
Normal file
@@ -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
|
||||
}
|
||||
24
src/renderer/src/hooks/useSearchParams.tsx
Normal file
24
src/renderer/src/hooks/useSearchParams.tsx
Normal file
@@ -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()}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
39
src/renderer/src/hooks/useSearchProfiles.tsx
Normal file
39
src/renderer/src/hooks/useSearchProfiles.tsx
Normal file
@@ -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<Error | null>(null)
|
||||
const [profiles, setProfiles] = useState<TProfile[]>([])
|
||||
|
||||
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 }
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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': '所有用户'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,19 +3,13 @@ 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 PrimaryPageLayout = forwardRef(({ children }: { children?: React.ReactNode }, ref) => {
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useImperativeHandle(
|
||||
@@ -30,13 +24,12 @@ const PrimaryPageLayout = forwardRef(
|
||||
|
||||
return (
|
||||
<ScrollArea ref={scrollAreaRef} className="h-full w-full" scrollBarClassName="pt-9 xl:pt-0">
|
||||
<PrimaryPageTitlebar content={titlebarContent} />
|
||||
<PrimaryPageTitlebar />
|
||||
<div className="px-4 pb-4 pt-11 xl:pt-4">{children}</div>
|
||||
<ScrollToTopButton scrollAreaRef={scrollAreaRef} />
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
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 (
|
||||
<Titlebar className={`justify-between xl:hidden ${isMacOS() ? 'pl-20' : ''}`}>
|
||||
<div className="flex gap-2 items-center">
|
||||
<AccountButton />
|
||||
<PostButton />
|
||||
{content}
|
||||
<SearchButton />
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<RefreshButton />
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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 }) {
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{visibleFollowings.map((pubkey, index) => (
|
||||
<FollowingItem key={`${index}-${pubkey}`} pubkey={pubkey} />
|
||||
<UserItem key={`${index}-${pubkey}`} pubkey={pubkey} />
|
||||
))}
|
||||
{followings.length > visibleFollowings.length && <div ref={bottomRef} />}
|
||||
</div>
|
||||
</SecondaryPageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
function FollowingItem({ pubkey }: { pubkey: string }) {
|
||||
const { profile } = useFetchProfile(pubkey)
|
||||
const { nip05, about } = profile || {}
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-start">
|
||||
<UserAvatar userId={pubkey} className="shrink-0" />
|
||||
<div className="w-full overflow-hidden">
|
||||
<Username userId={pubkey} className="font-semibold truncate" skeletonClassName="h-4" />
|
||||
<Nip05 nip05={nip05} pubkey={pubkey} />
|
||||
<div className="truncate text-muted-foreground text-sm">{about}</div>
|
||||
</div>
|
||||
<FollowButton pubkey={pubkey} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 <NotFoundPage />
|
||||
}
|
||||
const hashtag = id.toLowerCase()
|
||||
|
||||
return (
|
||||
<SecondaryPageLayout titlebarContent={`# ${hashtag}`}>
|
||||
<NoteList key={hashtag} filter={{ '#t': [hashtag] }} relayUrls={relayUrls} />
|
||||
</SecondaryPageLayout>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<SecondaryPageLayout hideBackButton>
|
||||
<div className="text-muted-foreground w-full h-full flex flex-col items-center justify-center gap-2">
|
||||
<div>{t('Lost in the void')} 🌌</div>
|
||||
<div>(404)</div>
|
||||
<Button variant="secondary" onClick={() => push(toHome())}>
|
||||
{t('Carry me home')}
|
||||
</Button>
|
||||
</div>
|
||||
</SecondaryPageLayout>
|
||||
)
|
||||
|
||||
40
src/renderer/src/pages/secondary/NoteListPage/index.tsx
Normal file
40
src/renderer/src/pages/secondary/NoteListPage/index.tsx
Normal file
@@ -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 (
|
||||
<SecondaryPageLayout titlebarContent={title}>
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
{t('The relays you are connected to do not support search')}
|
||||
</div>
|
||||
</SecondaryPageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SecondaryPageLayout titlebarContent={title}>
|
||||
<NoteList key={title} filter={filter} relayUrls={relayUrls} />
|
||||
</SecondaryPageLayout>
|
||||
)
|
||||
}
|
||||
89
src/renderer/src/pages/secondary/ProfileListPage/index.tsx
Normal file
89
src/renderer/src/pages/secondary/ProfileListPage/index.tsx
Normal file
@@ -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<number>(() => dayjs().unix())
|
||||
const [hasMore, setHasMore] = useState<boolean>(true)
|
||||
const [pubkeySet, setPubkeySet] = useState(new Set<string>())
|
||||
const observer = useRef<IntersectionObserver | null>(null)
|
||||
const bottomRef = useRef<HTMLDivElement>(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<string>()
|
||||
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 (
|
||||
<SecondaryPageLayout titlebarContent={title}>
|
||||
<div className="space-y-2">
|
||||
{Array.from(pubkeySet).map((pubkey, index) => (
|
||||
<UserItem key={`${index}-${pubkey}`} pubkey={pubkey} />
|
||||
))}
|
||||
{hasMore && <div ref={bottomRef} />}
|
||||
</div>
|
||||
</SecondaryPageLayout>
|
||||
)
|
||||
}
|
||||
@@ -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 }) {
|
||||
<NoteList
|
||||
key={pubkey}
|
||||
filter={{ authors: [pubkey] }}
|
||||
relayUrls={relayList.write.slice(0, 5)}
|
||||
relayUrls={relayList.write.slice(0, 5).concat(currentRelayUrls)}
|
||||
/>
|
||||
</SecondaryPageLayout>
|
||||
)
|
||||
|
||||
@@ -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<string[]>([])
|
||||
|
||||
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,
|
||||
|
||||
@@ -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: <HomePage /> },
|
||||
{ path: '/note', element: <NoteListPage /> },
|
||||
{ path: '/note/:id', element: <NotePage /> },
|
||||
{ path: '/user', element: <ProfileListPage /> },
|
||||
{ path: '/user/:id', element: <ProfilePage /> },
|
||||
{ path: '/user/:id/following', element: <FollowingListPage /> },
|
||||
{ path: '/hashtag/:id', element: <HashtagPage /> }
|
||||
{ path: '/user/:id/following', element: <FollowingListPage /> }
|
||||
]
|
||||
|
||||
export const routes = ROUTES.map(({ path, element }) => ({
|
||||
|
||||
@@ -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<string, Promise<TRelayList>>({ max: 10000 })
|
||||
}
|
||||
)
|
||||
private relayInfoDataLoader = new DataLoader<string, TRelayInfo | undefined>(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<string, Promise<NEvent | undefined>>({
|
||||
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<TProfile[]> {
|
||||
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<TRelayList> {
|
||||
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<NEvent | undefined> {
|
||||
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)
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user