From 2439150c6e21d56000dbdd1c44450e5813bb25d7 Mon Sep 17 00:00:00 2001 From: codytseng Date: Sat, 20 Sep 2025 22:00:28 +0800 Subject: [PATCH] feat: relay reviews --- package-lock.json | 259 ++++++++++++++---- package.json | 4 +- src/components/ContentPreview/index.tsx | 3 +- src/components/Note/RelayReview.tsx | 16 ++ src/components/Note/index.tsx | 3 + src/components/NoteList/index.tsx | 2 +- src/components/OthersRelayList/index.tsx | 2 +- src/components/RelayBadges/index.tsx | 28 -- src/components/RelayInfo/RelayReviewCard.tsx | 57 ++++ .../RelayInfo/RelayReviewsPreview.tsx | 200 ++++++++++++++ src/components/RelayInfo/ReviewEditor.tsx | 90 ++++++ src/components/RelayInfo/index.tsx | 166 +++++------ src/components/RelaySimpleInfo/index.tsx | 4 - src/components/Stars/index.tsx | 19 ++ src/components/TranslateButton/index.tsx | 3 +- src/components/UserAvatar/index.tsx | 2 +- src/components/ui/carousel.tsx | 235 ++++++++++++++++ src/constants.ts | 4 +- src/i18n/locales/ar.ts | 10 +- src/i18n/locales/de.ts | 12 +- src/i18n/locales/en.ts | 10 +- src/i18n/locales/es.ts | 12 +- src/i18n/locales/fa.ts | 11 +- src/i18n/locales/fr.ts | 11 +- src/i18n/locales/hi.ts | 10 +- src/i18n/locales/it.ts | 12 +- src/i18n/locales/ja.ts | 11 +- src/i18n/locales/ko.ts | 11 +- src/i18n/locales/pl.ts | 11 +- src/i18n/locales/pt-BR.ts | 12 +- src/i18n/locales/pt-PT.ts | 12 +- src/i18n/locales/ru.ts | 11 +- src/i18n/locales/th.ts | 10 +- src/i18n/locales/zh.ts | 10 +- src/lib/draft-event.ts | 16 ++ src/lib/event-metadata.ts | 11 + src/lib/link.ts | 1 + src/pages/primary/NoteListPage/index.tsx | 74 ++++- .../secondary/RelayReviewsPage/index.tsx | 36 +++ src/routes.tsx | 2 + 40 files changed, 1206 insertions(+), 207 deletions(-) create mode 100644 src/components/Note/RelayReview.tsx delete mode 100644 src/components/RelayBadges/index.tsx create mode 100644 src/components/RelayInfo/RelayReviewCard.tsx create mode 100644 src/components/RelayInfo/RelayReviewsPreview.tsx create mode 100644 src/components/RelayInfo/ReviewEditor.tsx create mode 100644 src/components/Stars/index.tsx create mode 100644 src/components/ui/carousel.tsx create mode 100644 src/pages/secondary/RelayReviewsPage/index.tsx diff --git a/package-lock.json b/package-lock.json index a48c429c..3ecb4ae9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,7 @@ "@radix-ui/react-select": "^2.1.4", "@radix-ui/react-separator": "^1.1.1", "@radix-ui/react-slider": "^1.3.5", - "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-toast": "^1.2.4", @@ -49,6 +49,8 @@ "cmdk": "^1.0.0", "dataloader": "^2.2.3", "dayjs": "^1.11.13", + "embla-carousel-react": "^8.6.0", + "embla-carousel-wheel-gestures": "^8.1.0", "emoji-picker-react": "^4.12.2", "flexsearch": "^0.7.43", "franc-min": "^6.2.0", @@ -2607,6 +2609,24 @@ } } }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", + "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.1.tgz", @@ -2767,24 +2787,6 @@ } } }, - "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-use-controllable-state": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", @@ -2877,6 +2879,24 @@ } } }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", + "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", @@ -2940,6 +2960,24 @@ } } }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", + "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", @@ -3154,6 +3192,24 @@ } } }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", + "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popover": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.4.tgz", @@ -3190,6 +3246,24 @@ } } }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", + "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.1.tgz", @@ -3289,6 +3363,24 @@ } } }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", + "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-radio-group": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", @@ -3494,24 +3586,6 @@ } } }, - "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", @@ -3777,6 +3851,24 @@ } } }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", + "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-separator": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.1.tgz", @@ -3930,23 +4022,6 @@ } } }, - "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-use-controllable-state": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", @@ -4011,11 +4086,12 @@ } }, "node_modules/@radix-ui/react-slot": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", - "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.1" + "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -4027,6 +4103,21 @@ } } }, + "node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-switch": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.2.tgz", @@ -6898,6 +6989,49 @@ "integrity": "sha512-Lf3++DumRE/QmweGjU+ZcKqQ+3bKkU/qjaKYhIJKEOhgIO9Xs6IiAQFkfFoj+RhgDk4LUeNsLo6plExHqSyu6Q==", "dev": true }, + "node_modules/embla-carousel": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", + "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", + "license": "MIT" + }, + "node_modules/embla-carousel-react": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz", + "integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==", + "license": "MIT", + "dependencies": { + "embla-carousel": "8.6.0", + "embla-carousel-reactive-utils": "8.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/embla-carousel-reactive-utils": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz", + "integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.6.0" + } + }, + "node_modules/embla-carousel-wheel-gestures": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/embla-carousel-wheel-gestures/-/embla-carousel-wheel-gestures-8.1.0.tgz", + "integrity": "sha512-J68jkYrxbWDmXOm2n2YHl+uMEXzkGSKjWmjaEgL9xVvPb3HqVmg6rJSKfI3sqIDVvm7mkeTy87wtG/5263XqHQ==", + "license": "MIT", + "dependencies": { + "wheel-gestures": "^2.2.5" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "embla-carousel": "^8.0.0 || ~8.0.0-rc03" + } + }, "node_modules/emoji-picker-react": { "version": "4.12.2", "resolved": "https://registry.npmjs.org/emoji-picker-react/-/emoji-picker-react-4.12.2.tgz", @@ -12312,6 +12446,15 @@ "webidl-conversions": "^4.0.2" } }, + "node_modules/wheel-gestures": { + "version": "2.2.48", + "resolved": "https://registry.npmjs.org/wheel-gestures/-/wheel-gestures-2.2.48.tgz", + "integrity": "sha512-f+Gy33Oa5Z14XY9679Zze+7VFhbsQfBFXodnU2x589l4kxGM9L5Y8zETTmcMR5pWOPQyRv4Z0lNax6xCO0NSlA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index da15a3e9..0c0f48d3 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "@radix-ui/react-select": "^2.1.4", "@radix-ui/react-separator": "^1.1.1", "@radix-ui/react-slider": "^1.3.5", - "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-toast": "^1.2.4", @@ -59,6 +59,8 @@ "cmdk": "^1.0.0", "dataloader": "^2.2.3", "dayjs": "^1.11.13", + "embla-carousel-react": "^8.6.0", + "embla-carousel-wheel-gestures": "^8.1.0", "emoji-picker-react": "^4.12.2", "flexsearch": "^0.7.43", "franc-min": "^6.2.0", diff --git a/src/components/ContentPreview/index.tsx b/src/components/ContentPreview/index.tsx index 662cbdbc..e3e597d4 100644 --- a/src/components/ContentPreview/index.tsx +++ b/src/components/ContentPreview/index.tsx @@ -61,7 +61,8 @@ export default function ContentPreview({ kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE, - ExtendedKind.VOICE_COMMENT + ExtendedKind.VOICE_COMMENT, + ExtendedKind.RELAY_REVIEW ].includes(event.kind) ) { return diff --git a/src/components/Note/RelayReview.tsx b/src/components/Note/RelayReview.tsx new file mode 100644 index 00000000..4919311d --- /dev/null +++ b/src/components/Note/RelayReview.tsx @@ -0,0 +1,16 @@ +import { getStarsFromRelayReviewEvent } from '@/lib/event-metadata' +import { Event } from 'nostr-tools' +import { useMemo } from 'react' +import Content from '../Content' +import Stars from '../Stars' + +export default function RelayReview({ event, className }: { event: Event; className?: string }) { + const stars = useMemo(() => getStarsFromRelayReviewEvent(event), [event]) + + return ( +
+ + +
+ ) +} diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index 6552f753..b094ae50 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -30,6 +30,7 @@ import PictureNote from './PictureNote' import Poll from './Poll' import UnknownNote from './UnknownNote' import VideoNote from './VideoNote' +import RelayReview from './RelayReview' export default function Note({ event, @@ -98,6 +99,8 @@ export default function Note({ content = } else if (event.kind === ExtendedKind.VIDEO || event.kind === ExtendedKind.SHORT_VIDEO) { content = + } else if (event.kind === ExtendedKind.RELAY_REVIEW) { + content = } else { content = } diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index f2941a11..a470b321 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -6,6 +6,7 @@ import { isReplaceableEvent, isReplyNoteEvent } from '@/lib/event' +import { isTouchDevice } from '@/lib/utils' import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useMuteList } from '@/providers/MuteListProvider' @@ -27,7 +28,6 @@ import { import { useTranslation } from 'react-i18next' import PullToRefresh from 'react-simple-pull-to-refresh' import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard' -import { isTouchDevice } from '@/lib/utils' const LIMIT = 200 const ALGO_LIMIT = 500 diff --git a/src/components/OthersRelayList/index.tsx b/src/components/OthersRelayList/index.tsx index 9dccc743..55d15193 100644 --- a/src/components/OthersRelayList/index.tsx +++ b/src/components/OthersRelayList/index.tsx @@ -34,7 +34,7 @@ function RelayItem({ relay }: { relay: TMailboxRelay }) { return (
push(toRelay(url))}> - +
{['both', 'read'].includes(scope) && ( {t('Read')} diff --git a/src/components/RelayBadges/index.tsx b/src/components/RelayBadges/index.tsx deleted file mode 100644 index f277acf3..00000000 --- a/src/components/RelayBadges/index.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { Badge } from '@/components/ui/badge' -import { TRelayInfo } from '@/types' -import { useMemo } from 'react' -import { useTranslation } from 'react-i18next' - -export default function RelayBadges({ relayInfo }: { relayInfo: TRelayInfo }) { - const { t } = useTranslation() - - const badges = useMemo(() => { - const b: string[] = [] - if (relayInfo.limitation?.payment_required) { - b.push('Payment') - } - return b - }, [relayInfo]) - - if (!badges.length) { - return null - } - - return ( -
- {badges.includes('Payment') && ( - {t('relayInfoBadgePayment')} - )} -
- ) -} diff --git a/src/components/RelayInfo/RelayReviewCard.tsx b/src/components/RelayInfo/RelayReviewCard.tsx new file mode 100644 index 00000000..12695ba1 --- /dev/null +++ b/src/components/RelayInfo/RelayReviewCard.tsx @@ -0,0 +1,57 @@ +import { useSecondaryPage } from '@/PageManager' +import { getStarsFromRelayReviewEvent } from '@/lib/event-metadata' +import { toNote } from '@/lib/link' +import { cn } from '@/lib/utils' +import { NostrEvent } from 'nostr-tools' +import { useMemo } from 'react' +import ClientTag from '../ClientTag' +import ContentPreview from '../ContentPreview' +import { FormattedTimestamp } from '../FormattedTimestamp' +import Nip05 from '../Nip05' +import Stars from '../Stars' +import TranslateButton from '../TranslateButton' +import { SimpleUserAvatar } from '../UserAvatar' +import { SimpleUsername } from '../Username' + +export default function RelayReviewCard({ + event, + className +}: { + event: NostrEvent + className?: string +}) { + const { push } = useSecondaryPage() + const stars = useMemo(() => getStarsFromRelayReviewEvent(event), [event]) + + return ( +
push(toNote(event))} + > +
+
+ +
+
+ + +
+
+ + +
+
+
+
+ +
+
+ + +
+ ) +} diff --git a/src/components/RelayInfo/RelayReviewsPreview.tsx b/src/components/RelayInfo/RelayReviewsPreview.tsx new file mode 100644 index 00000000..5de87637 --- /dev/null +++ b/src/components/RelayInfo/RelayReviewsPreview.tsx @@ -0,0 +1,200 @@ +import { useSecondaryPage } from '@/PageManager' +import { Button } from '@/components/ui/button' +import { + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious +} from '@/components/ui/carousel' +import { BIG_RELAY_URLS, ExtendedKind } from '@/constants' +import { compareEvents } from '@/lib/event' +import { getStarsFromRelayReviewEvent } from '@/lib/event-metadata' +import { toRelayReviews } from '@/lib/link' +import { cn, isTouchDevice } from '@/lib/utils' +import { useMuteList } from '@/providers/MuteListProvider' +import { useNostr } from '@/providers/NostrProvider' +import { useUserTrust } from '@/providers/UserTrustProvider' +import client from '@/services/client.service' +import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures' +import { Filter, NostrEvent } from 'nostr-tools' +import { useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import Stars from '../Stars' +import RelayReviewCard from './RelayReviewCard' +import ReviewEditor from './ReviewEditor' + +export default function RelayReviewsPreview({ relayUrl }: { relayUrl: string }) { + const { t } = useTranslation() + const { push } = useSecondaryPage() + const { pubkey, checkLogin } = useNostr() + const { hideUntrustedNotes, isUserTrusted } = useUserTrust() + const { mutePubkeySet } = useMuteList() + const [showEditor, setShowEditor] = useState(false) + const [myReview, setMyReview] = useState(null) + const [reviews, setReviews] = useState([]) + const [initialized, setInitialized] = useState(false) + const { stars, count } = useMemo(() => { + let totalStars = 0 + let totalCount = 0 + ;[myReview, ...reviews].forEach((evt) => { + if (!evt) return + const stars = getStarsFromRelayReviewEvent(evt) + if (stars) { + totalStars += stars + totalCount += 1 + } + }) + return { + stars: totalCount > 0 ? +(totalStars / totalCount).toFixed(1) : 0, + count: totalCount + } + }, [myReview, reviews]) + + useEffect(() => { + const init = async () => { + const filters: Filter[] = [ + { kinds: [ExtendedKind.RELAY_REVIEW], '#d': [relayUrl], limit: 100 } + ] + if (pubkey) { + filters.push({ kinds: [ExtendedKind.RELAY_REVIEW], authors: [pubkey], '#d': [relayUrl] }) + } + const events = await client.fetchEvents([relayUrl, ...BIG_RELAY_URLS], filters, { + cache: true + }) + + const pubkeySet = new Set() + const reviews: NostrEvent[] = [] + let myReview: NostrEvent | null = null + + events.sort((a, b) => compareEvents(b, a)) + for (const evt of events) { + if ( + mutePubkeySet.has(evt.pubkey) || + pubkeySet.has(evt.pubkey) || + (hideUntrustedNotes && !isUserTrusted(evt.pubkey)) + ) { + continue + } + const stars = getStarsFromRelayReviewEvent(evt) + if (!stars) { + continue + } + + pubkeySet.add(evt.pubkey) + if (evt.pubkey === pubkey) { + myReview = evt + } else { + reviews.push(evt) + } + } + + setMyReview(myReview) + setReviews(reviews) + setInitialized(true) + } + init() + }, [relayUrl, pubkey, mutePubkeySet, hideUntrustedNotes, isUserTrusted]) + + const handleReviewed = (evt: NostrEvent) => { + setMyReview(evt) + setShowEditor(false) + } + + return ( +
+
+
+
+
{stars}
+ +
+
0 && 'underline cursor-pointer hover:text-foreground' + )} + onClick={() => { + if (count > 0) { + push(toRelayReviews(relayUrl)) + } + }} + > + {t('{{count}} reviews', { count })} +
+
+ {!showEditor && !myReview && ( + + )} +
+ + {showEditor && } + + {myReview || reviews.length > 0 ? ( + + ) : !showEditor ? ( +
+ {initialized ? t('No reviews yet. Be the first to write one!') : t('Loading...')} +
+ ) : null} +
+ ) +} + +function ReviewCarousel({ + relayUrl, + myReview, + reviews +}: { + relayUrl: string + myReview: NostrEvent | null + reviews: NostrEvent[] +}) { + const { t } = useTranslation() + const { push } = useSecondaryPage() + const showPreviousAndNext = useMemo(() => !isTouchDevice(), []) + + return ( + + + {myReview && ( + + + + )} + {reviews.slice(0, 10).map((evt) => ( + + + + ))} + {reviews.length > 10 && ( + +
push(toRelayReviews(relayUrl))} + > +
{t('View more reviews')}
+
+
+ )} +
+ {showPreviousAndNext && } + {showPreviousAndNext && } +
+ ) +} + +function Item({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/src/components/RelayInfo/ReviewEditor.tsx b/src/components/RelayInfo/ReviewEditor.tsx new file mode 100644 index 00000000..caa2b88d --- /dev/null +++ b/src/components/RelayInfo/ReviewEditor.tsx @@ -0,0 +1,90 @@ +import { Button } from '@/components/ui/button' +import { Textarea } from '@/components/ui/textarea' +import { BIG_RELAY_URLS } from '@/constants' +import { createRelayReviewDraftEvent } from '@/lib/draft-event' +import { useNostr } from '@/providers/NostrProvider' +import { Loader2, Star } from 'lucide-react' +import { NostrEvent } from 'nostr-tools' +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' + +export default function ReviewEditor({ + relayUrl, + onReviewed +}: { + relayUrl: string + onReviewed: (evt: NostrEvent) => void +}) { + const { t } = useTranslation() + const { publish } = useNostr() + const [stars, setStars] = useState(0) + const [hoverStars, setHoverStars] = useState(0) + const [review, setReview] = useState('') + const [submitting, setSubmitting] = useState(false) + const canSubmit = useMemo(() => stars > 0 && !!review.trim(), [stars, review]) + + const submit = async () => { + if (!canSubmit) return + + setSubmitting(true) + try { + const draftEvent = createRelayReviewDraftEvent(relayUrl, review, stars) + const evt = await publish(draftEvent, { specifiedRelayUrls: [relayUrl, ...BIG_RELAY_URLS] }) + onReviewed(evt) + } catch (error) { + if (error instanceof AggregateError) { + error.errors.forEach((e) => toast.error(`${t('Failed to review')}: ${e.message}`)) + } else if (error instanceof Error) { + toast.error(`${t('Failed to review')}: ${error.message}`) + } + console.error(error) + return + } finally { + setSubmitting(false) + } + } + + return ( +
+