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 (
+
+
+
+
+
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 (
+
+ )
+}
diff --git a/src/components/RelayInfo/index.tsx b/src/components/RelayInfo/index.tsx
index a31a99e0..3eb72c4e 100644
--- a/src/components/RelayInfo/index.tsx
+++ b/src/components/RelayInfo/index.tsx
@@ -1,21 +1,24 @@
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
+import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { useFetchRelayInfo } from '@/hooks'
import { normalizeHttpUrl } from '@/lib/url'
import { cn } from '@/lib/utils'
+import { useNostr } from '@/providers/NostrProvider'
import { Check, Copy, GitBranch, Link, Mail, SquareCode } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import PostEditor from '../PostEditor'
-import RelayBadges from '../RelayBadges'
import RelayIcon from '../RelayIcon'
import SaveRelayDropdownMenu from '../SaveRelayDropdownMenu'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
+import RelayReviewsPreview from './RelayReviewsPreview'
export default function RelayInfo({ url, className }: { url: string; className?: string }) {
const { t } = useTranslation()
+ const { checkLogin } = useNostr()
const { relayInfo, isFetching } = useFetchRelayInfo(url)
const [open, setOpen] = useState(false)
@@ -24,97 +27,94 @@ export default function RelayInfo({ url, className }: { url: string; className?:
}
return (
-
-
-
-
-
-
- {relayInfo.name || relayInfo.shortUrl}
-
-
-
-
-
- {!!relayInfo.tags?.length && (
-
- {relayInfo.tags.map((tag) => (
- {tag}
- ))}
-
- )}
- {relayInfo.description && (
-
- {relayInfo.description}
-
- )}
-
-
-
-
- {relayInfo.payments_url && (
+
+
-
{t('Payment page')}:
+
+
+
+
+ {relayInfo.name || relayInfo.shortUrl}
+
+
+
+
+ {!!relayInfo.tags?.length && (
+
+ {relayInfo.tags.map((tag) => (
+ {tag}
+ ))}
+
+ )}
+ {relayInfo.description && (
+
+ {relayInfo.description}
+
+ )}
+
+
+
- )}
-
- {relayInfo.pubkey && (
-
-
{t('Operator')}
-
-
-
-
+
+
+
+ {relayInfo.pubkey && (
+
+
{t('Operator')}
+
+
+
+
+
+ )}
+ {relayInfo.contact && (
+
+
{t('Contact')}
+
+
+ {relayInfo.contact}
+
+
+ )}
+ {relayInfo.software && (
+
+
{t('Software')}
+
+
+ {formatSoftware(relayInfo.software)}
+
+
+ )}
+ {relayInfo.version && (
+
+
{t('Version')}
+
+
+ {relayInfo.version}
+
+
+ )}
- )}
- {relayInfo.contact && (
-
-
{t('Contact')}
-
-
- {relayInfo.contact}
-
-
- )}
- {relayInfo.software && (
-
-
{t('Software')}
-
-
- {formatSoftware(relayInfo.software)}
-
-
- )}
- {relayInfo.version && (
-
-
{t('Version')}
-
-
- {relayInfo.version}
-
-
- )}
+
+
+
+
-
-
+
)
}
diff --git a/src/components/RelaySimpleInfo/index.tsx b/src/components/RelaySimpleInfo/index.tsx
index 60041b22..40d3dd87 100644
--- a/src/components/RelaySimpleInfo/index.tsx
+++ b/src/components/RelaySimpleInfo/index.tsx
@@ -3,7 +3,6 @@ import { cn } from '@/lib/utils'
import { TRelayInfo } from '@/types'
import { HTMLProps } from 'react'
import { useTranslation } from 'react-i18next'
-import RelayBadges from '../RelayBadges'
import RelayIcon from '../RelayIcon'
import SaveRelayDropdownMenu from '../SaveRelayDropdownMenu'
import { SimpleUserAvatar } from '../UserAvatar'
@@ -11,13 +10,11 @@ import { SimpleUserAvatar } from '../UserAvatar'
export default function RelaySimpleInfo({
relayInfo,
users,
- hideBadge = false,
className,
...props
}: HTMLProps
& {
relayInfo?: TRelayInfo
users?: string[]
- hideBadge?: boolean
}) {
const { t } = useTranslation()
@@ -35,7 +32,6 @@ export default function RelaySimpleInfo({
{relayInfo &&
}
- {!hideBadge && relayInfo &&
}
{!!relayInfo?.description &&
{relayInfo.description}
}
{!!users?.length && (
diff --git a/src/components/Stars/index.tsx b/src/components/Stars/index.tsx
new file mode 100644
index 00000000..533917da
--- /dev/null
+++ b/src/components/Stars/index.tsx
@@ -0,0 +1,19 @@
+import { cn } from '@/lib/utils'
+import { Star } from 'lucide-react'
+import { useMemo } from 'react'
+
+export default function Stars({ stars, className }: { stars: number; className?: string }) {
+ const roundedStars = useMemo(() => Math.round(stars), [stars])
+
+ return (
+
+ {Array.from({ length: 5 }).map((_, index) =>
+ index < roundedStars ? (
+
+ ) : (
+
+ )
+ )}
+
+ )
+}
diff --git a/src/components/TranslateButton/index.tsx b/src/components/TranslateButton/index.tsx
index 850e3367..ae3f015c 100644
--- a/src/components/TranslateButton/index.tsx
+++ b/src/components/TranslateButton/index.tsx
@@ -29,7 +29,8 @@ export default function TranslateButton({
kinds.Highlights,
ExtendedKind.COMMENT,
ExtendedKind.PICTURE,
- ExtendedKind.POLL
+ ExtendedKind.POLL,
+ ExtendedKind.RELAY_REVIEW
].includes(event.kind),
[event]
)
diff --git a/src/components/UserAvatar/index.tsx b/src/components/UserAvatar/index.tsx
index 0200b1c0..187beb6c 100644
--- a/src/components/UserAvatar/index.tsx
+++ b/src/components/UserAvatar/index.tsx
@@ -68,7 +68,7 @@ export function SimpleUserAvatar({
onClick
}: {
userId: string
- size?: 'large' | 'big' | 'normal' | 'small' | 'xSmall' | 'tiny'
+ size?: 'large' | 'big' | 'normal' | 'medium' | 'small' | 'xSmall' | 'tiny'
className?: string
onClick?: (e: React.MouseEvent
) => void
}) {
diff --git a/src/components/ui/carousel.tsx b/src/components/ui/carousel.tsx
new file mode 100644
index 00000000..2251e197
--- /dev/null
+++ b/src/components/ui/carousel.tsx
@@ -0,0 +1,235 @@
+import * as React from 'react'
+import useEmblaCarousel, { type UseEmblaCarouselType } from 'embla-carousel-react'
+import { ArrowLeft, ArrowRight } from 'lucide-react'
+
+import { cn } from '@/lib/utils'
+import { Button } from '@/components/ui/button'
+
+type CarouselApi = UseEmblaCarouselType[1]
+type UseCarouselParameters = Parameters
+type CarouselOptions = UseCarouselParameters[0]
+type CarouselPlugin = UseCarouselParameters[1]
+
+type CarouselProps = {
+ opts?: CarouselOptions
+ plugins?: CarouselPlugin
+ orientation?: 'horizontal' | 'vertical'
+ setApi?: (api: CarouselApi) => void
+}
+
+type CarouselContextProps = {
+ carouselRef: ReturnType[0]
+ api: ReturnType[1]
+ scrollPrev: () => void
+ scrollNext: () => void
+ canScrollPrev: boolean
+ canScrollNext: boolean
+} & CarouselProps
+
+const CarouselContext = React.createContext(null)
+
+function useCarousel() {
+ const context = React.useContext(CarouselContext)
+
+ if (!context) {
+ throw new Error('useCarousel must be used within a ')
+ }
+
+ return context
+}
+
+const Carousel = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes & CarouselProps
+>(({ orientation = 'horizontal', opts, setApi, plugins, className, children, ...props }, ref) => {
+ const [carouselRef, api] = useEmblaCarousel(
+ {
+ ...opts,
+ axis: orientation === 'horizontal' ? 'x' : 'y'
+ },
+ plugins
+ )
+ const [canScrollPrev, setCanScrollPrev] = React.useState(false)
+ const [canScrollNext, setCanScrollNext] = React.useState(false)
+
+ const onSelect = React.useCallback((api: CarouselApi) => {
+ if (!api) {
+ return
+ }
+
+ setCanScrollPrev(api.canScrollPrev())
+ setCanScrollNext(api.canScrollNext())
+ }, [])
+
+ const scrollPrev = React.useCallback(() => {
+ api?.scrollPrev()
+ }, [api])
+
+ const scrollNext = React.useCallback(() => {
+ api?.scrollNext()
+ }, [api])
+
+ const handleKeyDown = React.useCallback(
+ (event: React.KeyboardEvent) => {
+ if (event.key === 'ArrowLeft') {
+ event.preventDefault()
+ scrollPrev()
+ } else if (event.key === 'ArrowRight') {
+ event.preventDefault()
+ scrollNext()
+ }
+ },
+ [scrollPrev, scrollNext]
+ )
+
+ React.useEffect(() => {
+ if (!api || !setApi) {
+ return
+ }
+
+ setApi(api)
+ }, [api, setApi])
+
+ React.useEffect(() => {
+ if (!api) {
+ return
+ }
+
+ onSelect(api)
+ api.on('reInit', onSelect)
+ api.on('select', onSelect)
+
+ return () => {
+ api?.off('select', onSelect)
+ }
+ }, [api, onSelect])
+
+ return (
+
+
+ {children}
+
+
+ )
+})
+Carousel.displayName = 'Carousel'
+
+const CarouselContent = React.forwardRef>(
+ ({ className, ...props }, ref) => {
+ const { carouselRef, orientation } = useCarousel()
+
+ return (
+
+ )
+ }
+)
+CarouselContent.displayName = 'CarouselContent'
+
+const CarouselItem = React.forwardRef>(
+ ({ className, ...props }, ref) => {
+ const { orientation } = useCarousel()
+
+ return (
+
+ )
+ }
+)
+CarouselItem.displayName = 'CarouselItem'
+
+const CarouselPrevious = React.forwardRef>(
+ ({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
+ const { orientation, scrollPrev, canScrollPrev } = useCarousel()
+
+ return (
+
+ )
+ }
+)
+CarouselPrevious.displayName = 'CarouselPrevious'
+
+const CarouselNext = React.forwardRef>(
+ ({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
+ const { orientation, scrollNext, canScrollNext } = useCarousel()
+
+ return (
+
+ )
+ }
+)
+CarouselNext.displayName = 'CarouselNext'
+
+export { type CarouselApi, Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext }
diff --git a/src/constants.ts b/src/constants.ts
index eda34201..fe90f1f7 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -84,6 +84,7 @@ export const ExtendedKind = {
VOICE_COMMENT: 1244,
FAVORITE_RELAYS: 10012,
BLOSSOM_SERVER_LIST: 10063,
+ RELAY_REVIEW: 31987,
GROUP_METADATA: 39000
}
@@ -98,7 +99,8 @@ export const SUPPORTED_KINDS = [
ExtendedKind.VOICE,
ExtendedKind.VOICE_COMMENT,
kinds.Highlights,
- kinds.LongFormArticle
+ kinds.LongFormArticle,
+ ExtendedKind.RELAY_REVIEW
]
export const URL_REGEX =
diff --git a/src/i18n/locales/ar.ts b/src/i18n/locales/ar.ts
index 66fafae2..f437369c 100644
--- a/src/i18n/locales/ar.ts
+++ b/src/i18n/locales/ar.ts
@@ -407,6 +407,14 @@ export default {
Never: 'أبداً',
'Click to load image': 'انقر لتحميل الصورة',
'Click to load media': 'انقر لتحميل الوسائط',
- 'Click to load YouTube video': 'انقر لتحميل فيديو YouTube'
+ 'Click to load YouTube video': 'انقر لتحميل فيديو YouTube',
+ '{{count}} reviews': '{{count}} مراجعة',
+ 'Write a review': 'كتابة مراجعة',
+ 'No reviews yet. Be the first to write one!': 'لا توجد مراجعات بعد. كن أول من يكتب واحدة!',
+ 'View more reviews': 'عرض المزيد من المراجعات',
+ 'Failed to review': 'فشل في المراجعة',
+ 'Write a review and pick a star rating': 'اكتب مراجعة واختر تقييماً بالنجوم',
+ Submit: 'إرسال',
+ 'Reviews for {{relay}}': 'مراجعات لـ {{relay}}'
}
}
diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts
index 60e7d1cb..c0a401a9 100644
--- a/src/i18n/locales/de.ts
+++ b/src/i18n/locales/de.ts
@@ -417,6 +417,16 @@ export default {
Never: 'Nie',
'Click to load image': 'Klicken, um Bild zu laden',
'Click to load media': 'Klicken, um Medien zu laden',
- 'Click to load YouTube video': 'Klicken, um YouTube-Video zu laden'
+ 'Click to load YouTube video': 'Klicken, um YouTube-Video zu laden',
+ '{{count}} reviews': '{{count}} Bewertungen',
+ 'Write a review': 'Eine Bewertung schreiben',
+ 'No reviews yet. Be the first to write one!':
+ 'Noch keine Bewertungen. Seien Sie der Erste, der eine schreibt!',
+ 'View more reviews': 'Weitere Bewertungen anzeigen',
+ 'Failed to review': 'Bewertung fehlgeschlagen',
+ 'Write a review and pick a star rating':
+ 'Schreiben Sie eine Bewertung und wählen Sie eine Sternebewertung',
+ Submit: 'Absenden',
+ 'Reviews for {{relay}}': 'Bewertungen für {{relay}}'
}
}
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index c740f86c..49eead03 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -406,6 +406,14 @@ export default {
Never: 'Never',
'Click to load image': 'Click to load image',
'Click to load media': 'Click to load media',
- 'Click to load YouTube video': 'Click to load YouTube video'
+ 'Click to load YouTube video': 'Click to load YouTube video',
+ '{{count}} reviews': '{{count}} reviews',
+ 'Write a review': 'Write a review',
+ 'No reviews yet. Be the first to write one!': 'No reviews yet. Be the first to write one!',
+ 'View more reviews': 'View more reviews',
+ 'Failed to review': 'Failed to review',
+ 'Write a review and pick a star rating': 'Write a review and pick a star rating',
+ Submit: 'Submit',
+ 'Reviews for {{relay}}': 'Reviews for {{relay}}'
}
}
diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts
index 0f2a5e97..0c438b71 100644
--- a/src/i18n/locales/es.ts
+++ b/src/i18n/locales/es.ts
@@ -412,6 +412,16 @@ export default {
Never: 'Nunca',
'Click to load image': 'Haz clic para cargar la imagen',
'Click to load media': 'Haz clic para cargar los medios',
- 'Click to load YouTube video': 'Haz clic para cargar el video de YouTube'
+ 'Click to load YouTube video': 'Haz clic para cargar el video de YouTube',
+ '{{count}} reviews': '{{count}} reseñas',
+ 'Write a review': 'Escribir una reseña',
+ 'No reviews yet. Be the first to write one!':
+ '¡Aún no hay reseñas. Sé el primero en escribir una!',
+ 'View more reviews': 'Ver más reseñas',
+ 'Failed to review': 'Error al reseñar',
+ 'Write a review and pick a star rating':
+ 'Escriba una reseña y elija una calificación de estrellas',
+ Submit: 'Enviar',
+ 'Reviews for {{relay}}': 'Reseñas para {{relay}}'
}
}
diff --git a/src/i18n/locales/fa.ts b/src/i18n/locales/fa.ts
index c50cdf36..c0a98210 100644
--- a/src/i18n/locales/fa.ts
+++ b/src/i18n/locales/fa.ts
@@ -408,6 +408,15 @@ export default {
Never: 'هرگز',
'Click to load image': 'برای بارگذاری تصویر کلیک کنید',
'Click to load media': 'برای بارگذاری رسانه کلیک کنید',
- 'Click to load YouTube video': 'برای بارگذاری ویدیو YouTube کلیک کنید'
+ 'Click to load YouTube video': 'برای بارگذاری ویدیو YouTube کلیک کنید',
+ '{{count}} reviews': '{{count}} نقد',
+ 'Write a review': 'نوشتن نقد',
+ 'No reviews yet. Be the first to write one!':
+ 'هنوز نقدی وجود ندارد. اولین نفری باشید که مینویسد!',
+ 'View more reviews': 'مشاهده نقدهای بیشتر',
+ 'Failed to review': 'نقد ناموفق',
+ 'Write a review and pick a star rating': 'نقدی بنویسید و امتیاز ستارهای انتخاب کنید',
+ Submit: 'ارسال',
+ 'Reviews for {{relay}}': 'نقدها برای {{relay}}'
}
}
diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts
index de8f1362..c6804405 100644
--- a/src/i18n/locales/fr.ts
+++ b/src/i18n/locales/fr.ts
@@ -417,6 +417,15 @@ export default {
Never: 'Jamais',
'Click to load image': 'Cliquez pour charger l’image',
'Click to load media': 'Cliquez pour charger les médias',
- 'Click to load YouTube video': 'Cliquez pour charger la vidéo YouTube'
+ 'Click to load YouTube video': 'Cliquez pour charger la vidéo YouTube',
+ '{{count}} reviews': '{{count}} avis',
+ 'Write a review': 'Écrire un avis',
+ 'No reviews yet. Be the first to write one!':
+ 'Pas encore d’avis. Soyez le premier à en écrire un !',
+ 'View more reviews': 'Voir plus d’avis',
+ 'Failed to review': 'Échec de l’avis',
+ 'Write a review and pick a star rating': 'Écrivez un avis et choisissez une note en étoiles',
+ Submit: 'Soumettre',
+ 'Reviews for {{relay}}': 'Avis pour {{relay}}'
}
}
diff --git a/src/i18n/locales/hi.ts b/src/i18n/locales/hi.ts
index 4d1963b3..4fb3f97b 100644
--- a/src/i18n/locales/hi.ts
+++ b/src/i18n/locales/hi.ts
@@ -411,6 +411,14 @@ export default {
Never: 'कभी नहीं',
'Click to load image': 'इमेज लोड करने के लिए क्लिक करें',
'Click to load media': 'मीडिया लोड करने के लिए क्लिक करें',
- 'Click to load YouTube video': 'YouTube वीडियो लोड करने के लिए क्लिक करें'
+ 'Click to load YouTube video': 'YouTube वीडियो लोड करने के लिए क्लिक करें',
+ '{{count}} reviews': '{{count}} समीक्षाएं',
+ 'Write a review': 'समीक्षा लिखें',
+ 'No reviews yet. Be the first to write one!': 'अभी तक कोई समीक्षा नहीं। पहले लिखने वाले बनें!',
+ 'View more reviews': 'और समीक्षाएं देखें',
+ 'Failed to review': 'समीक्षा असफल',
+ 'Write a review and pick a star rating': 'एक समीक्षा लिखें और स्टार रेटिंग चुनें',
+ Submit: 'सबमिट करें',
+ 'Reviews for {{relay}}': '{{relay}} के लिए समीक्षाएं'
}
}
diff --git a/src/i18n/locales/it.ts b/src/i18n/locales/it.ts
index bc5c031f..9cc41460 100644
--- a/src/i18n/locales/it.ts
+++ b/src/i18n/locales/it.ts
@@ -412,6 +412,16 @@ export default {
Never: 'Mai',
'Click to load image': "Clicca per caricare l'immagine",
'Click to load media': 'Clicca per caricare i media',
- 'Click to load YouTube video': 'Clicca per caricare il video di YouTube'
+ 'Click to load YouTube video': 'Clicca per caricare il video di YouTube',
+ '{{count}} reviews': '{{count}} recensioni',
+ 'Write a review': 'Scrivi una recensione',
+ 'No reviews yet. Be the first to write one!':
+ 'Nessuna recensione ancora. Sii il primo a scriverne una!',
+ 'View more reviews': 'Visualizza più recensioni',
+ 'Failed to review': 'Recensione fallita',
+ 'Write a review and pick a star rating':
+ 'Scrivi una recensione e scegli una valutazione a stelle',
+ Submit: 'Invia',
+ 'Reviews for {{relay}}': 'Recensioni per {{relay}}'
}
}
diff --git a/src/i18n/locales/ja.ts b/src/i18n/locales/ja.ts
index 61b7f795..4fde5c52 100644
--- a/src/i18n/locales/ja.ts
+++ b/src/i18n/locales/ja.ts
@@ -409,6 +409,15 @@ export default {
Never: 'しない',
'Click to load image': 'クリックして画像を読み込む',
'Click to load media': 'クリックしてメディアを読み込む',
- 'Click to load YouTube video': 'クリックしてYouTubeビデオを読み込む'
+ 'Click to load YouTube video': 'クリックしてYouTubeビデオを読み込む',
+ '{{count}} reviews': '{{count}}件のレビュー',
+ 'Write a review': 'レビューを書く',
+ 'No reviews yet. Be the first to write one!':
+ 'まだレビューがありません。最初のレビューを書いてみませんか!',
+ 'View more reviews': 'もっとレビューを見る',
+ 'Failed to review': 'レビュー失敗',
+ 'Write a review and pick a star rating': 'レビューを書いて星評価を選択してください',
+ Submit: '送信',
+ 'Reviews for {{relay}}': '{{relay}} のレビュー'
}
}
diff --git a/src/i18n/locales/ko.ts b/src/i18n/locales/ko.ts
index 2ab472af..222d43c5 100644
--- a/src/i18n/locales/ko.ts
+++ b/src/i18n/locales/ko.ts
@@ -409,6 +409,15 @@ export default {
Never: '안함',
'Click to load image': '이미지 로드하려면 클릭',
'Click to load media': '미디어 로드하려면 클릭',
- 'Click to load YouTube video': 'YouTube 비디오 로드하려면 클릭'
+ 'Click to load YouTube video': 'YouTube 비디오 로드하려면 클릭',
+ '{{count}} reviews': '{{count}}개 리뷰',
+ 'Write a review': '리뷰 작성',
+ 'No reviews yet. Be the first to write one!':
+ '아직 리뷰가 없습니다. 첫 번째 리뷰를 작성해보세요!',
+ 'View more reviews': '더 많은 리뷰 보기',
+ 'Failed to review': '리뷰 실패',
+ 'Write a review and pick a star rating': '리뷰를 작성하고 별점을 선택하세요',
+ Submit: '제출',
+ 'Reviews for {{relay}}': '{{relay}}에 대한 리뷰'
}
}
diff --git a/src/i18n/locales/pl.ts b/src/i18n/locales/pl.ts
index 9be6cfb2..286ec7ae 100644
--- a/src/i18n/locales/pl.ts
+++ b/src/i18n/locales/pl.ts
@@ -413,6 +413,15 @@ export default {
Never: 'Nigdy',
'Click to load image': 'Kliknij, aby załadować obraz',
'Click to load media': 'Kliknij, aby załadować media',
- 'Click to load YouTube video': 'Kliknij, aby załadować wideo z YouTube'
+ 'Click to load YouTube video': 'Kliknij, aby załadować wideo z YouTube',
+ '{{count}} reviews': '{{count}} opinii',
+ 'Write a review': 'Napisz opinię',
+ 'No reviews yet. Be the first to write one!':
+ 'Jeszcze brak opinii. Bądź pierwszym, który napisze!',
+ 'View more reviews': 'Zobacz więcej opinii',
+ 'Failed to review': 'Błąd opinii',
+ 'Write a review and pick a star rating': 'Napisz opinię i wybierz ocenę gwiazdkową',
+ Submit: 'Prześlij',
+ 'Reviews for {{relay}}': 'Opinie o {{relay}}'
}
}
diff --git a/src/i18n/locales/pt-BR.ts b/src/i18n/locales/pt-BR.ts
index b0eef6f9..584139fd 100644
--- a/src/i18n/locales/pt-BR.ts
+++ b/src/i18n/locales/pt-BR.ts
@@ -409,6 +409,16 @@ export default {
Never: 'Nunca',
'Click to load image': 'Clique para carregar a imagem',
'Click to load media': 'Clique para carregar a mídia',
- 'Click to load YouTube video': 'Clique para carregar o vídeo do YouTube'
+ 'Click to load YouTube video': 'Clique para carregar o vídeo do YouTube',
+ '{{count}} reviews': '{{count}} avaliações',
+ 'Write a review': 'Escrever uma avaliação',
+ 'No reviews yet. Be the first to write one!':
+ 'Ainda não há avaliações. Seja o primeiro a escrever uma!',
+ 'View more reviews': 'Ver mais avaliações',
+ 'Failed to review': 'Falha ao avaliar',
+ 'Write a review and pick a star rating':
+ 'Escreva uma avaliação e escolha uma classificação por estrelas',
+ Submit: 'Enviar',
+ 'Reviews for {{relay}}': 'Avaliações para {{relay}}'
}
}
diff --git a/src/i18n/locales/pt-PT.ts b/src/i18n/locales/pt-PT.ts
index 50834680..6ebf6888 100644
--- a/src/i18n/locales/pt-PT.ts
+++ b/src/i18n/locales/pt-PT.ts
@@ -412,6 +412,16 @@ export default {
Never: 'Nunca',
'Click to load image': 'Clique para carregar a imagem',
'Click to load media': 'Clique para carregar a mídia',
- 'Click to load YouTube video': 'Clique para carregar o vídeo do YouTube'
+ 'Click to load YouTube video': 'Clique para carregar o vídeo do YouTube',
+ '{{count}} reviews': '{{count}} avaliações',
+ 'Write a review': 'Escrever uma avaliação',
+ 'No reviews yet. Be the first to write one!':
+ 'Ainda não há avaliações. Seja o primeiro a escrever uma!',
+ 'View more reviews': 'Ver mais avaliações',
+ 'Failed to review': 'Falha ao avaliar',
+ 'Write a review and pick a star rating':
+ 'Escreva uma avaliação e escolha uma classificação por estrelas',
+ Submit: 'Enviar',
+ 'Reviews for {{relay}}': 'Avaliações para {{relay}}'
}
}
diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts
index 9073e669..33c260aa 100644
--- a/src/i18n/locales/ru.ts
+++ b/src/i18n/locales/ru.ts
@@ -414,6 +414,15 @@ export default {
Never: 'Никогда',
'Click to load image': 'Нажмите, чтобы загрузить изображение',
'Click to load media': 'Нажмите, чтобы загрузить медиа',
- 'Click to load YouTube video': 'Нажмите, чтобы загрузить видео YouTube'
+ 'Click to load YouTube video': 'Нажмите, чтобы загрузить видео YouTube',
+ '{{count}} reviews': '{{count}} отзывов',
+ 'Write a review': 'Написать отзыв',
+ 'No reviews yet. Be the first to write one!':
+ 'Отзывов пока нет. Станьте первым, кто напишет отзыв!',
+ 'View more reviews': 'Посмотреть больше отзывов',
+ 'Failed to review': 'Ошибка отзыва',
+ 'Write a review and pick a star rating': 'Напишите отзыв и выберите звездный рейтинг',
+ Submit: 'Отправить',
+ 'Reviews for {{relay}}': 'Отзывы для {{relay}}'
}
}
diff --git a/src/i18n/locales/th.ts b/src/i18n/locales/th.ts
index 8ea438f8..385dea52 100644
--- a/src/i18n/locales/th.ts
+++ b/src/i18n/locales/th.ts
@@ -405,6 +405,14 @@ export default {
Never: 'ไม่เลย',
'Click to load image': 'คลิกเพื่อโหลดรูปภาพ',
'Click to load media': 'คลิกเพื่อโหลดสื่อ',
- 'Click to load YouTube video': 'คลิกเพื่อโหลดวิดีโอ YouTube'
+ 'Click to load YouTube video': 'คลิกเพื่อโหลดวิดีโอ YouTube',
+ '{{count}} reviews': '{{count}} รีวิว',
+ 'Write a review': 'เขียนรีวิว',
+ 'No reviews yet. Be the first to write one!': 'ยังไม่มีรีวิว เป็นคนแรกที่เขียนรีวิวสิ!',
+ 'View more reviews': 'ดูรีวิวเพิ่มเติม',
+ 'Failed to review': 'รีวิวล้มเหลว',
+ 'Write a review and pick a star rating': 'เขียนรีวิวและเลือกคะแนนดาว',
+ Submit: 'ส่ง',
+ 'Reviews for {{relay}}': 'รีวิวสำหรับ {{relay}}'
}
}
diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts
index 57085887..e8d1c8c0 100644
--- a/src/i18n/locales/zh.ts
+++ b/src/i18n/locales/zh.ts
@@ -403,6 +403,14 @@ export default {
Never: '从不',
'Click to load image': '点击加载图片',
'Click to load media': '点击加载音视频',
- 'Click to load YouTube video': '点击加载 YouTube 视频'
+ 'Click to load YouTube video': '点击加载 YouTube 视频',
+ '{{count}} reviews': '{{count}} 条评价',
+ 'Write a review': '写评价',
+ 'No reviews yet. Be the first to write one!': '还没有评价,成为第一个评价的人吧!',
+ 'View more reviews': '查看更多评价',
+ 'Failed to review': '评价失败',
+ 'Write a review and pick a star rating': '写下评价并选择星级评分',
+ Submit: '提交',
+ 'Reviews for {{relay}}': '关于 {{relay}} 的评价'
}
}
diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts
index e98984c4..f982cd24 100644
--- a/src/lib/draft-event.ts
+++ b/src/lib/draft-event.ts
@@ -459,6 +459,22 @@ export function createReportDraftEvent(event: Event, reason: string): TDraftEven
}
}
+export function createRelayReviewDraftEvent(
+ relay: string,
+ review: string,
+ stars: number
+): TDraftEvent {
+ return {
+ kind: ExtendedKind.RELAY_REVIEW,
+ content: review,
+ tags: [
+ ['d', relay],
+ ['rating', (stars / 5).toString()]
+ ],
+ created_at: dayjs().unix()
+ }
+}
+
function generateImetaTags(imageUrls: string[]) {
return imageUrls
.map((imageUrl) => {
diff --git a/src/lib/event-metadata.ts b/src/lib/event-metadata.ts
index 5dc09399..f417f7e6 100644
--- a/src/lib/event-metadata.ts
+++ b/src/lib/event-metadata.ts
@@ -369,3 +369,14 @@ export function getEmojisFromEvent(event: Event): TEmoji[] {
return emojis
}
+
+export function getStarsFromRelayReviewEvent(event: Event): number {
+ const ratingTag = event.tags.find((t) => t[0] === 'rating')
+ if (ratingTag) {
+ const stars = parseFloat(ratingTag[1]) * 5
+ if (stars > 0 && stars <= 5) {
+ return stars
+ }
+ }
+ return 0
+}
diff --git a/src/lib/link.ts b/src/lib/link.ts
index 4ecf1d69..ab691794 100644
--- a/src/lib/link.ts
+++ b/src/lib/link.ts
@@ -72,6 +72,7 @@ export const toGeneralSettings = () => '/settings/general'
export const toTranslation = () => '/settings/translation'
export const toProfileEditor = () => '/profile-editor'
export const toRelay = (url: string) => `/relays/${encodeURIComponent(url)}`
+export const toRelayReviews = (url: string) => `/relays/${encodeURIComponent(url)}/reviews`
export const toMuteList = () => '/mutes'
export const toChachiChat = (relay: string, d: string) => {
diff --git a/src/pages/primary/NoteListPage/index.tsx b/src/pages/primary/NoteListPage/index.tsx
index 4857ef59..25d2a844 100644
--- a/src/pages/primary/NoteListPage/index.tsx
+++ b/src/pages/primary/NoteListPage/index.tsx
@@ -1,6 +1,7 @@
import { useSecondaryPage } from '@/PageManager'
import BookmarkList from '@/components/BookmarkList'
import PostEditor from '@/components/PostEditor'
+import RelayInfo from '@/components/RelayInfo'
import { Button } from '@/components/ui/button'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { toSearch } from '@/lib/link'
@@ -8,8 +9,16 @@ import { useFeed } from '@/providers/FeedProvider'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { TPageRef } from '@/types'
-import { PencilLine, Search } from 'lucide-react'
-import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'
+import { Info, PencilLine, Search } from 'lucide-react'
+import {
+ Dispatch,
+ forwardRef,
+ SetStateAction,
+ useEffect,
+ useImperativeHandle,
+ useRef,
+ useState
+} from 'react'
import { useTranslation } from 'react-i18next'
import FeedButton from './FeedButton'
import FollowingFeed from './FollowingFeed'
@@ -20,6 +29,7 @@ const NoteListPage = forwardRef((_, ref) => {
const layoutRef = useRef(null)
const { pubkey, checkLogin } = useNostr()
const { feedInfo, relayUrls, isReady } = useFeed()
+ const [showRelayDetails, setShowRelayDetails] = useState(false)
useImperativeHandle(ref, () => layoutRef.current)
useEffect(() => {
@@ -54,14 +64,29 @@ const NoteListPage = forwardRef((_, ref) => {
} else if (feedInfo.feedType === 'following') {
content =
} else {
- content =
+ content = (
+ <>
+ {showRelayDetails && feedInfo.feedType === 'relay' && !!feedInfo.id && (
+
+ )}
+
+ >
+ )
}
return (
}
+ titlebar={
+
+ }
displayScrollToTopButton
>
{content}
@@ -71,18 +96,45 @@ const NoteListPage = forwardRef((_, ref) => {
NoteListPage.displayName = 'NoteListPage'
export default NoteListPage
-function NoteListPageTitlebar() {
+function NoteListPageTitlebar({
+ layoutRef,
+ showRelayDetails,
+ setShowRelayDetails
+}: {
+ layoutRef?: React.RefObject
+ showRelayDetails?: boolean
+ setShowRelayDetails?: Dispatch>
+}) {
const { isSmallScreen } = useScreenSize()
return (
- {isSmallScreen && (
-
- )}
+
+ {setShowRelayDetails && (
+
+ )}
+ {isSmallScreen && (
+ <>
+
+
+ >
+ )}
+
)
}
diff --git a/src/pages/secondary/RelayReviewsPage/index.tsx b/src/pages/secondary/RelayReviewsPage/index.tsx
new file mode 100644
index 00000000..10810de3
--- /dev/null
+++ b/src/pages/secondary/RelayReviewsPage/index.tsx
@@ -0,0 +1,36 @@
+import NoteList from '@/components/NoteList'
+import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
+import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
+import { normalizeUrl, simplifyUrl } from '@/lib/url'
+import { forwardRef, useMemo } from 'react'
+import { useTranslation } from 'react-i18next'
+import NotFoundPage from '../NotFoundPage'
+
+const RelayReviewsPage = forwardRef(({ url, index }: { url?: string; index?: number }, ref) => {
+ const { t } = useTranslation()
+ const normalizedUrl = useMemo(() => (url ? normalizeUrl(url) : undefined), [url])
+ const title = useMemo(
+ () => (url ? t('Reviews for {{relay}}', { relay: simplifyUrl(url) }) : undefined),
+ [url]
+ )
+
+ if (!normalizedUrl) {
+ return
+ }
+
+ return (
+
+
+
+ )
+})
+RelayReviewsPage.displayName = 'RelayReviewsPage'
+export default RelayReviewsPage
diff --git a/src/routes.tsx b/src/routes.tsx
index 16d863e0..e4b7dc64 100644
--- a/src/routes.tsx
+++ b/src/routes.tsx
@@ -11,6 +11,7 @@ import ProfileEditorPage from './pages/secondary/ProfileEditorPage'
import ProfileListPage from './pages/secondary/ProfileListPage'
import ProfilePage from './pages/secondary/ProfilePage'
import RelayPage from './pages/secondary/RelayPage'
+import RelayReviewsPage from './pages/secondary/RelayReviewsPage'
import RelaySettingsPage from './pages/secondary/RelaySettingsPage'
import SearchPage from './pages/secondary/SearchPage'
import SettingsPage from './pages/secondary/SettingsPage'
@@ -25,6 +26,7 @@ const ROUTES = [
{ path: '/users/:id/following', element: },
{ path: '/users/:id/relays', element: },
{ path: '/relays/:url', element: },
+ { path: '/relays/:url/reviews', element: },
{ path: '/search', element: },
{ path: '/settings', element: },
{ path: '/settings/relays', element: },