diff --git a/package-lock.json b/package-lock.json index 9599216..2ea450f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "0.1.0", "license": "MIT", "dependencies": { - "@nextui-org/image": "^2.2.3", "@noble/hashes": "^1.6.1", "@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-avatar": "^1.1.2", @@ -29,7 +28,7 @@ "cmdk": "^1.0.0", "dataloader": "^2.2.3", "dayjs": "^1.11.13", - "framer-motion": "^11.15.0", + "embla-carousel-react": "^8.5.1", "i18next": "^24.2.0", "lru-cache": "^11.0.2", "lucide-react": "^0.469.0", @@ -2105,57 +2104,6 @@ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz", "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==" }, - "node_modules/@formatjs/ecma402-abstract": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.1.tgz", - "integrity": "sha512-Ip9uV+/MpLXWRk03U/GzeJMuPeOXpJBSB5V1tjA6kJhvqssye5J5LoYLc7Z5IAHb7nR62sRoguzrFiVCP/hnzw==", - "peer": true, - "dependencies": { - "@formatjs/fast-memoize": "2.2.5", - "@formatjs/intl-localematcher": "0.5.9", - "decimal.js": "10", - "tslib": "2" - } - }, - "node_modules/@formatjs/fast-memoize": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.5.tgz", - "integrity": "sha512-6PoewUMrrcqxSoBXAOJDiW1m+AmkrAj0RiXnOMD59GRaswjXhm3MDhgepXPBgonc09oSirAJTsAggzAGQf6A6g==", - "peer": true, - "dependencies": { - "tslib": "2" - } - }, - "node_modules/@formatjs/icu-messageformat-parser": { - "version": "2.9.7", - "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.9.7.tgz", - "integrity": "sha512-cuEHyRM5VqLQobANOjtjlgU7+qmk9Q3fDQuBiRRJ3+Wp3ZoZhpUPtUfuimZXsir6SaI2TaAJ+SLo9vLnV5QcbA==", - "peer": true, - "dependencies": { - "@formatjs/ecma402-abstract": "2.3.1", - "@formatjs/icu-skeleton-parser": "1.8.11", - "tslib": "2" - } - }, - "node_modules/@formatjs/icu-skeleton-parser": { - "version": "1.8.11", - "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.11.tgz", - "integrity": "sha512-8LlHHE/yL/zVJZHAX3pbKaCjZKmBIO6aJY1mkVh4RMSEu/2WRZ4Ysvv3kKXJ9M8RJLBHdnk1/dUQFdod1Dt7Dw==", - "peer": true, - "dependencies": { - "@formatjs/ecma402-abstract": "2.3.1", - "tslib": "2" - } - }, - "node_modules/@formatjs/intl-localematcher": { - "version": "0.5.9", - "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.5.9.tgz", - "integrity": "sha512-8zkGu/sv5euxbjfZ/xmklqLyDGQSxsLqg8XOq88JW3cmJtzhCP8EtSJXlaKZnVO4beEaoiT9wj4eIoCQ9smwxA==", - "peer": true, - "dependencies": { - "tslib": "2" - } - }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2217,43 +2165,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@internationalized/date": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.6.0.tgz", - "integrity": "sha512-+z6ti+CcJnRlLHok/emGEsWQhe7kfSmEW+/6qCzvKY67YPh7YOBfvc7+/+NXq+zJlbArg30tYpqLjNgcAYv2YQ==", - "peer": true, - "dependencies": { - "@swc/helpers": "^0.5.0" - } - }, - "node_modules/@internationalized/message": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/@internationalized/message/-/message-3.1.6.tgz", - "integrity": "sha512-JxbK3iAcTIeNr1p0WIFg/wQJjIzJt9l/2KNY/48vXV7GRGZSv3zMxJsce008fZclk2cDC8y0Ig3odceHO7EfNQ==", - "peer": true, - "dependencies": { - "@swc/helpers": "^0.5.0", - "intl-messageformat": "^10.1.0" - } - }, - "node_modules/@internationalized/number": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.0.tgz", - "integrity": "sha512-PtrRcJVy7nw++wn4W2OuePQQfTqDzfusSuY1QTtui4wa7r+rGVtR75pO8CyKvHvzyQYi3Q1uO5sY0AsB4e65Bw==", - "peer": true, - "dependencies": { - "@swc/helpers": "^0.5.0" - } - }, - "node_modules/@internationalized/string": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/@internationalized/string/-/string-3.2.5.tgz", - "integrity": "sha512-rKs71Zvl2OKOHM+mzAFMIyqR5hI1d1O6BBkMK2/lkfg3fkmVh9Eeg0awcA8W2WqYqDOv6a86DIOlFpggwLtbuw==", - "peer": true, - "dependencies": { - "@swc/helpers": "^0.5.0" - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -2323,138 +2234,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@nextui-org/image": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/@nextui-org/image/-/image-2.2.3.tgz", - "integrity": "sha512-erd+c7uA4FRoOOdwS6di97VVJSBbs8mXv9cOY2kZHU830e2//TKlNaE4nC7xR9ApFEAtfXjoRzSyUCIlyXmD9Q==", - "dependencies": { - "@nextui-org/react-utils": "2.1.1", - "@nextui-org/shared-utils": "2.1.1", - "@nextui-org/use-image": "2.1.1" - }, - "peerDependencies": { - "@nextui-org/system": ">=2.4.0", - "@nextui-org/theme": ">=2.4.0", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@nextui-org/react-rsc-utils": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@nextui-org/react-rsc-utils/-/react-rsc-utils-2.1.1.tgz", - "integrity": "sha512-9uKH1XkeomTGaswqlGKt0V0ooUev8mPXtKJolR+6MnpvBUrkqngw1gUGF0bq/EcCCkks2+VOHXZqFT6x9hGkQQ==", - "peerDependencies": { - "react": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@nextui-org/react-utils": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@nextui-org/react-utils/-/react-utils-2.1.1.tgz", - "integrity": "sha512-cN3Z0b2bV6Nf0CYD4imsGdXbHMQqad8KivltpBv1ItbI1/FSTAv9AHTKSzDE15hd/UwOGYt3Qm7I6tWzqov55w==", - "dependencies": { - "@nextui-org/react-rsc-utils": "2.1.1", - "@nextui-org/shared-utils": "2.1.1" - }, - "peerDependencies": { - "react": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@nextui-org/shared-utils": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@nextui-org/shared-utils/-/shared-utils-2.1.1.tgz", - "integrity": "sha512-qE8gZO63GqUX1ljOi/4PlwGzE84dhUS3zFIq+10/N6ePAaNjM4DwtL4ocucG3abCz4iRUueYKLIxTO2+eYyAfw==" - }, - "node_modules/@nextui-org/system": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/@nextui-org/system/-/system-2.4.4.tgz", - "integrity": "sha512-ldlUYq7VprTEC2s3LaMxQh7S7Xeyy6DYoKkOML9XHJBgSgVXCMr5QyoxvIkO2XRl5nu6KWn2QA1vjtj2xiMjRw==", - "peer": true, - "dependencies": { - "@internationalized/date": "3.6.0", - "@nextui-org/react-utils": "2.1.1", - "@nextui-org/system-rsc": "2.3.4", - "@react-aria/i18n": "3.12.4", - "@react-aria/overlays": "3.24.0", - "@react-aria/utils": "3.26.0", - "@react-stately/utils": "3.10.5", - "@react-types/datepicker": "3.9.0" - }, - "peerDependencies": { - "framer-motion": ">=11.5.6 || >=12.0.0-alpha.1", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@nextui-org/system-rsc": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/@nextui-org/system-rsc/-/system-rsc-2.3.4.tgz", - "integrity": "sha512-Y6OLFO7diYnUMe5ffDPt6sIqCaah7FOqRaJ3ZQ/We8gE8AgHnyNQxWllLtRzBqaCiIheHLo7dTMed1FFmb775A==", - "peer": true, - "dependencies": { - "@react-types/shared": "3.26.0", - "clsx": "^1.2.1" - }, - "peerDependencies": { - "@nextui-org/theme": ">=2.4.0", - "react": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@nextui-org/system-rsc/node_modules/clsx": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", - "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", - "peer": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/@nextui-org/theme": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/@nextui-org/theme/-/theme-2.4.3.tgz", - "integrity": "sha512-QH9ps5NpenWU966INdGbdvZOWWUEGqxrLM2vyqkSRq+A65YON4Jhg/x1xWcSX0SJECNhoNZLh5mt6jp3jH5k8Q==", - "peer": true, - "dependencies": { - "@nextui-org/shared-utils": "2.1.1", - "clsx": "^1.2.1", - "color": "^4.2.3", - "color2k": "^2.0.2", - "deepmerge": "4.3.1", - "flat": "^5.0.2", - "tailwind-merge": "^2.5.2", - "tailwind-variants": "^0.1.20" - }, - "peerDependencies": { - "tailwindcss": ">=3.4.0" - } - }, - "node_modules/@nextui-org/theme/node_modules/clsx": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", - "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", - "peer": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/@nextui-org/use-image": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@nextui-org/use-image/-/use-image-2.1.1.tgz", - "integrity": "sha512-Tsfy9pA4AQBAj7rFIEonB9L/hXGg7M5agaAZNBUVpdp47NjcEwLpcU2XncKh8AhkQku0p4JOyMC9usRGV3z06Q==", - "dependencies": { - "@nextui-org/use-safe-layout-effect": "2.1.1" - }, - "peerDependencies": { - "react": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@nextui-org/use-safe-layout-effect": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@nextui-org/use-safe-layout-effect/-/use-safe-layout-effect-2.1.1.tgz", - "integrity": "sha512-p0vezi2eujC3rxlMQmCLQlc8CNbp+GQgk6YcSm7Rk10isWVlUII5T1L3y+rcFYdgTPObCkCngPPciNQhD7Lf7g==", - "peerDependencies": { - "react": ">=18 || >=19.0.0-rc.0" - } - }, "node_modules/@noble/ciphers": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz", @@ -3478,212 +3257,6 @@ "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz", "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==" }, - "node_modules/@react-aria/focus": { - "version": "3.19.0", - "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.19.0.tgz", - "integrity": "sha512-hPF9EXoUQeQl1Y21/rbV2H4FdUR2v+4/I0/vB+8U3bT1CJ+1AFj1hc/rqx2DqEwDlEwOHN+E4+mRahQmlybq0A==", - "peer": true, - "dependencies": { - "@react-aria/interactions": "^3.22.5", - "@react-aria/utils": "^3.26.0", - "@react-types/shared": "^3.26.0", - "@swc/helpers": "^0.5.0", - "clsx": "^2.0.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/i18n": { - "version": "3.12.4", - "resolved": "https://registry.npmjs.org/@react-aria/i18n/-/i18n-3.12.4.tgz", - "integrity": "sha512-j9+UL3q0Ls8MhXV9gtnKlyozq4aM95YywXqnmJtzT1rYeBx7w28hooqrWkCYLfqr4OIryv1KUnPiCSLwC2OC7w==", - "peer": true, - "dependencies": { - "@internationalized/date": "^3.6.0", - "@internationalized/message": "^3.1.6", - "@internationalized/number": "^3.6.0", - "@internationalized/string": "^3.2.5", - "@react-aria/ssr": "^3.9.7", - "@react-aria/utils": "^3.26.0", - "@react-types/shared": "^3.26.0", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/interactions": { - "version": "3.22.5", - "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.22.5.tgz", - "integrity": "sha512-kMwiAD9E0TQp+XNnOs13yVJghiy8ET8L0cbkeuTgNI96sOAp/63EJ1FSrDf17iD8sdjt41LafwX/dKXW9nCcLQ==", - "peer": true, - "dependencies": { - "@react-aria/ssr": "^3.9.7", - "@react-aria/utils": "^3.26.0", - "@react-types/shared": "^3.26.0", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/overlays": { - "version": "3.24.0", - "resolved": "https://registry.npmjs.org/@react-aria/overlays/-/overlays-3.24.0.tgz", - "integrity": "sha512-0kAXBsMNTc/a3M07tK9Cdt/ea8CxTAEJ223g8YgqImlmoBBYAL7dl5G01IOj67TM64uWPTmZrOklBchHWgEm3A==", - "peer": true, - "dependencies": { - "@react-aria/focus": "^3.19.0", - "@react-aria/i18n": "^3.12.4", - "@react-aria/interactions": "^3.22.5", - "@react-aria/ssr": "^3.9.7", - "@react-aria/utils": "^3.26.0", - "@react-aria/visually-hidden": "^3.8.18", - "@react-stately/overlays": "^3.6.12", - "@react-types/button": "^3.10.1", - "@react-types/overlays": "^3.8.11", - "@react-types/shared": "^3.26.0", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/ssr": { - "version": "3.9.7", - "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.7.tgz", - "integrity": "sha512-GQygZaGlmYjmYM+tiNBA5C6acmiDWF52Nqd40bBp0Znk4M4hP+LTmI0lpI1BuKMw45T8RIhrAsICIfKwZvi2Gg==", - "peer": true, - "dependencies": { - "@swc/helpers": "^0.5.0" - }, - "engines": { - "node": ">= 12" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/utils": { - "version": "3.26.0", - "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.26.0.tgz", - "integrity": "sha512-LkZouGSjjQ0rEqo4XJosS4L3YC/zzQkfRM3KoqK6fUOmUJ9t0jQ09WjiF+uOoG9u+p30AVg3TrZRUWmoTS+koQ==", - "peer": true, - "dependencies": { - "@react-aria/ssr": "^3.9.7", - "@react-stately/utils": "^3.10.5", - "@react-types/shared": "^3.26.0", - "@swc/helpers": "^0.5.0", - "clsx": "^2.0.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/visually-hidden": { - "version": "3.8.18", - "resolved": "https://registry.npmjs.org/@react-aria/visually-hidden/-/visually-hidden-3.8.18.tgz", - "integrity": "sha512-l/0igp+uub/salP35SsNWq5mGmg3G5F5QMS1gDZ8p28n7CgjvzyiGhJbbca7Oxvaw1HRFzVl9ev+89I7moNnFQ==", - "peer": true, - "dependencies": { - "@react-aria/interactions": "^3.22.5", - "@react-aria/utils": "^3.26.0", - "@react-types/shared": "^3.26.0", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/overlays": { - "version": "3.6.12", - "resolved": "https://registry.npmjs.org/@react-stately/overlays/-/overlays-3.6.12.tgz", - "integrity": "sha512-QinvZhwZgj8obUyPIcyURSCjTZlqZYRRCS60TF8jH8ZpT0tEAuDb3wvhhSXuYA3Xo9EHLwvLjEf3tQKKdAQArw==", - "peer": true, - "dependencies": { - "@react-stately/utils": "^3.10.5", - "@react-types/overlays": "^3.8.11", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/utils": { - "version": "3.10.5", - "resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.5.tgz", - "integrity": "sha512-iMQSGcpaecghDIh3mZEpZfoFH3ExBwTtuBEcvZ2XnGzCgQjeYXcMdIUwAfVQLXFTdHUHGF6Gu6/dFrYsCzySBQ==", - "peer": true, - "dependencies": { - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/button": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@react-types/button/-/button-3.10.1.tgz", - "integrity": "sha512-XTtap8o04+4QjPNAshFWOOAusUTxQlBjU2ai0BTVLShQEjHhRVDBIWsI2B2FKJ4KXT6AZ25llaxhNrreWGonmA==", - "peer": true, - "dependencies": { - "@react-types/shared": "^3.26.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/calendar": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@react-types/calendar/-/calendar-3.5.0.tgz", - "integrity": "sha512-O3IRE7AGwAWYnvJIJ80cOy7WwoJ0m8GtX/qSmvXQAjC4qx00n+b5aFNBYAQtcyc3RM5QpW6obs9BfwGetFiI8w==", - "peer": true, - "dependencies": { - "@internationalized/date": "^3.6.0", - "@react-types/shared": "^3.26.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/datepicker": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/@react-types/datepicker/-/datepicker-3.9.0.tgz", - "integrity": "sha512-dbKL5Qsm2MQwOTtVQdOcKrrphcXAqDD80WLlSQrBLg+waDuuQ7H+TrvOT0thLKloNBlFUGnZZfXGRHINpih/0g==", - "peer": true, - "dependencies": { - "@internationalized/date": "^3.6.0", - "@react-types/calendar": "^3.5.0", - "@react-types/overlays": "^3.8.11", - "@react-types/shared": "^3.26.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/overlays": { - "version": "3.8.11", - "resolved": "https://registry.npmjs.org/@react-types/overlays/-/overlays-3.8.11.tgz", - "integrity": "sha512-aw7T0rwVI3EuyG5AOaEIk8j7dZJQ9m34XAztXJVZ/W2+4pDDkLDbJ/EAPnuo2xGYRGhowuNDn4tDju01eHYi+w==", - "peer": true, - "dependencies": { - "@react-types/shared": "^3.26.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/shared": { - "version": "3.26.0", - "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.26.0.tgz", - "integrity": "sha512-6FuPqvhmjjlpEDLTiYx29IJCbCNWPlsyO+ZUmCUXzhUv2ttShOXfw8CmeHWHftT/b2KweAWuzqSlfeXPR76jpw==", - "peer": true, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@rollup/plugin-node-resolve": { "version": "15.3.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz", @@ -4103,15 +3676,6 @@ "string.prototype.matchall": "^4.0.6" } }, - "node_modules/@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", - "peer": true, - "dependencies": { - "tslib": "^2.8.0" - } - }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -5321,19 +4885,6 @@ } } }, - "node_modules/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "peer": true, - "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - }, - "engines": { - "node": ">=12.5.0" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -5350,22 +4901,6 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, - "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "peer": true, - "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } - }, - "node_modules/color2k": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/color2k/-/color2k-2.0.3.tgz", - "integrity": "sha512-zW190nQTIoXcGCaU08DvVNFTmQhUpnJfVuAKfWqUQkflXKpaDdpaYoM0iluLS9lgJNHyBF58KKA2FBEwkD7wog==", - "peer": true - }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -5525,12 +5060,6 @@ } } }, - "node_modules/decimal.js": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", - "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", - "peer": true - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -5541,6 +5070,7 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -5634,6 +5164,31 @@ "integrity": "sha512-Lf3++DumRE/QmweGjU+ZcKqQ+3bKkU/qjaKYhIJKEOhgIO9Xs6IiAQFkfFoj+RhgDk4LUeNsLo6plExHqSyu6Q==", "dev": true }, + "node_modules/embla-carousel": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.5.1.tgz", + "integrity": "sha512-JUb5+FOHobSiWQ2EJNaueCNT/cQU9L6XWBbWmorWPQT9bkbk+fhsuLr8wWrzXKagO3oWszBO7MSx+GfaRk4E6A==" + }, + "node_modules/embla-carousel-react": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.5.1.tgz", + "integrity": "sha512-z9Y0K84BJvhChXgqn2CFYbfEi6AwEr+FFVVKm/MqbTQ2zIzO1VQri6w67LcfpVF0AjbhwVMywDZqY4alYkjW5w==", + "dependencies": { + "embla-carousel": "8.5.1", + "embla-carousel-reactive-utils": "8.5.1" + }, + "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.5.1", + "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.5.1.tgz", + "integrity": "sha512-n7VSoGIiiDIc4MfXF3ZRTO59KDp820QDuyBDGlt5/65+lumPHxX2JLz0EZ23hZ4eg4vZGUXwMkYv02fw2JVo/A==", + "peerDependencies": { + "embla-carousel": "8.5.1" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -6121,15 +5676,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/flat": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", - "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", - "peer": true, - "bin": { - "flat": "cli.js" - } - }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -6186,32 +5732,6 @@ "url": "https://github.com/sponsors/rawify" } }, - "node_modules/framer-motion": { - "version": "11.15.0", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.15.0.tgz", - "integrity": "sha512-MLk8IvZntxOMg7lDBLw2qgTHHv664bYoYmnFTmE0Gm/FW67aOJk0WM3ctMcG+Xhcv+vh5uyyXwxvxhSeJzSe+w==", - "dependencies": { - "motion-dom": "^11.14.3", - "motion-utils": "^11.14.3", - "tslib": "^2.4.0" - }, - "peerDependencies": { - "@emotion/is-prop-valid": "*", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/is-prop-valid": { - "optional": true - }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } - } - }, "node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -6646,18 +6166,6 @@ "node": ">= 0.4" } }, - "node_modules/intl-messageformat": { - "version": "10.7.10", - "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.10.tgz", - "integrity": "sha512-hp7iejCBiJdW3zmOe18FdlJu8U/JsADSDiBPQhfdSeI8B9POtvPRvPh3nMlvhYayGMKLv6maldhR7y3Pf1vkpw==", - "peer": true, - "dependencies": { - "@formatjs/ecma402-abstract": "2.3.1", - "@formatjs/fast-memoize": "2.2.5", - "@formatjs/icu-messageformat-parser": "2.9.7", - "tslib": "2" - } - }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -6675,12 +6183,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-arrayish": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", - "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", - "peer": true - }, "node_modules/is-async-function": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.0.0.tgz", @@ -7370,16 +6872,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/motion-dom": { - "version": "11.14.3", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.14.3.tgz", - "integrity": "sha512-lW+D2wBy5vxLJi6aCP0xyxTxlTfiu+b+zcpVbGVFUxotwThqhdpPRSmX8xztAgtZMPMeU0WGVn/k1w4I+TbPqA==" - }, - "node_modules/motion-utils": { - "version": "11.14.3", - "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.14.3.tgz", - "integrity": "sha512-Xg+8xnqIJTpr0L/cidfTTBFkvRw26ZtGGuIhA94J9PQ2p4mEa06Xx7QVYZH0BP+EpMSaDlu+q0I0mmvwADPsaQ==" - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -8535,15 +8027,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/simple-swizzle": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", - "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", - "peer": true, - "dependencies": { - "is-arrayish": "^0.3.1" - } - }, "node_modules/smob": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/smob/-/smob-1.5.0.tgz", @@ -8855,32 +8338,6 @@ "url": "https://github.com/sponsors/dcastil" } }, - "node_modules/tailwind-variants": { - "version": "0.1.20", - "resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-0.1.20.tgz", - "integrity": "sha512-AMh7x313t/V+eTySKB0Dal08RHY7ggYK0MSn/ad8wKWOrDUIzyiWNayRUm2PIJ4VRkvRnfNuyRuKbLV3EN+ewQ==", - "peer": true, - "dependencies": { - "tailwind-merge": "^1.14.0" - }, - "engines": { - "node": ">=16.x", - "pnpm": ">=7.x" - }, - "peerDependencies": { - "tailwindcss": "*" - } - }, - "node_modules/tailwind-variants/node_modules/tailwind-merge": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-1.14.0.tgz", - "integrity": "sha512-3mFKyCo/MBcgyOTlrY8T7odzZFx+w+qKSMAmdFzRvqBfLlSigU6TZnlFHK0lkMwj9Bj8OYU+9yW9lmGuS0QEnQ==", - "peer": true, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/dcastil" - } - }, "node_modules/tailwindcss": { "version": "3.4.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", diff --git a/package.json b/package.json index 3576aeb..00cb718 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ "preview": "vite preview" }, "dependencies": { - "@nextui-org/image": "^2.2.3", "@noble/hashes": "^1.6.1", "@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-avatar": "^1.1.2", @@ -39,7 +38,7 @@ "cmdk": "^1.0.0", "dataloader": "^2.2.3", "dayjs": "^1.11.13", - "framer-motion": "^11.15.0", + "embla-carousel-react": "^8.5.1", "i18next": "^24.2.0", "lru-cache": "^11.0.2", "lucide-react": "^0.469.0", diff --git a/src/components/Content/index.tsx b/src/components/Content/index.tsx index f3d91bf..c8ac54a 100644 --- a/src/components/Content/index.tsx +++ b/src/components/Content/index.tsx @@ -1,4 +1,6 @@ -import { isNsfwEvent } from '@/lib/event' +import { isNsfwEvent, isPictureEvent } from '@/lib/event' +import { extractImetaUrlFromTag } from '@/lib/tag' +import { isImage, isVideo } from '@/lib/url' import { cn } from '@/lib/utils' import { Event } from 'nostr-tools' import { memo } from 'react' @@ -14,6 +16,7 @@ import { import ImageGallery from '../ImageGallery' import VideoPlayer from '../VideoPlayer' import WebPreview from '../WebPreview' +import { URL_REGEX } from '@/constants' const Content = memo( ({ @@ -25,7 +28,7 @@ const Content = memo( className?: string size?: 'normal' | 'small' }) => { - const { content, images, videos, embeddedNotes, lastNonMediaUrl } = preprocess(event.content) + const { content, images, videos, embeddedNotes, lastNonMediaUrl } = preprocess(event) const isNsfw = isNsfwEvent(event) const nodes = embedded(content, [ embeddedNormalUrlRenderer, @@ -39,7 +42,7 @@ const Content = memo( if (images.length) { nodes.push( { + const imageUrl = extractImetaUrlFromTag(tag) + if (imageUrl) { + images.push(imageUrl) + } + }) + } + const embeddedNotes: string[] = [] const embeddedNoteRegex = /nostr:(note1[a-z0-9]{58}|nevent1[a-z0-9]+|naddr1[a-z0-9]+)/g ;(c.match(embeddedNoteRegex) || []).forEach((note) => { @@ -123,23 +135,7 @@ function preprocess(content: string) { embeddedNotes.push(note) }) - return { content: c, images, videos, embeddedNotes, lastNonMediaUrl } -} + c = c.replace(/\n{3,}/g, '\n\n').trim() -function isImage(url: string) { - try { - const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.heic', '.svg'] - return imageExtensions.some((ext) => new URL(url).pathname.toLowerCase().endsWith(ext)) - } catch { - return false - } -} - -function isVideo(url: string) { - try { - const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov'] - return videoExtensions.some((ext) => new URL(url).pathname.toLowerCase().endsWith(ext)) - } catch { - return false - } + return { content: c, images, videos, embeddedNotes, lastNonMediaUrl } } diff --git a/src/components/Image/index.tsx b/src/components/Image/index.tsx new file mode 100644 index 0000000..d5f2f39 --- /dev/null +++ b/src/components/Image/index.tsx @@ -0,0 +1,35 @@ +import { Skeleton } from '@/components/ui/skeleton' +import { cn } from '@/lib/utils' +import { HTMLAttributes, useState } from 'react' + +export default function Image({ + src, + alt, + className = '', + classNames = {}, + ...props +}: HTMLAttributes & { + src: string + alt?: string + classNames?: { + wrapper?: string + } +}) { + const [isLoading, setIsLoading] = useState(true) + + return ( +
+ {isLoading && } + {alt} setIsLoading(false)} + /> +
+ ) +} diff --git a/src/components/ImageCarousel/index.tsx b/src/components/ImageCarousel/index.tsx new file mode 100644 index 0000000..edf9706 --- /dev/null +++ b/src/components/ImageCarousel/index.tsx @@ -0,0 +1,43 @@ +import { Carousel, CarouselContent, CarouselItem } from '@/components/ui/carousel' +import { useState } from 'react' +import Lightbox from 'yet-another-react-lightbox' +import Zoom from 'yet-another-react-lightbox/plugins/zoom' +import Image from '../Image' +import NsfwOverlay from '../NsfwOverlay' + +export function ImageCarousel({ images, isNsfw = false }: { images: string[]; isNsfw?: boolean }) { + const [index, setIndex] = useState(-1) + + const handlePhotoClick = (event: React.MouseEvent, current: number) => { + event.preventDefault() + setIndex(current) + } + + return ( + <> + + + {images.map((url, index) => ( + + handlePhotoClick(e, index)} /> + + ))} + + + ({ src }))} + plugins={[Zoom]} + open={index >= 0} + close={() => setIndex(-1)} + controller={{ + closeOnBackdropClick: true, + closeOnPullUp: true, + closeOnPullDown: true + }} + styles={{ toolbar: { paddingTop: '2.25rem' } }} + /> + {isNsfw && } + + ) +} diff --git a/src/components/ImageGallery/index.tsx b/src/components/ImageGallery/index.tsx index 42d5293..0d74b14 100644 --- a/src/components/ImageGallery/index.tsx +++ b/src/components/ImageGallery/index.tsx @@ -1,9 +1,9 @@ -import { Image } from '@nextui-org/image' -import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area' import { cn } from '@/lib/utils' -import { useState } from 'react' +import { useScreenSize } from '@/providers/ScreenSizeProvider' +import { ReactNode, useState } from 'react' import Lightbox from 'yet-another-react-lightbox' import Zoom from 'yet-another-react-lightbox/plugins/zoom' +import Image from '../Image' import NsfwOverlay from '../NsfwOverlay' export default function ImageGallery({ @@ -17,45 +17,84 @@ export default function ImageGallery({ isNsfw?: boolean size?: 'normal' | 'small' }) { + const { isSmallScreen } = useScreenSize() const [index, setIndex] = useState(-1) const handlePhotoClick = (event: React.MouseEvent, current: number) => { + event.stopPropagation() event.preventDefault() setIndex(current) } - return ( -
e.stopPropagation()}> - -
- {images.map((src, index) => ( - handlePhotoClick(e, index)} - removeWrapper - /> - ))} -
- -
- ({ src }))} - plugins={[Zoom]} - open={index >= 0} - close={() => setIndex(-1)} - controller={{ - closeOnBackdropClick: true, - closeOnPullUp: true, - closeOnPullDown: true - }} - styles={{ toolbar: { paddingTop: '2.25rem' } }} + let imageContent: ReactNode | null = null + if (images.length === 1) { + imageContent = ( + handlePhotoClick(e, 0)} /> + ) + } else if (size === 'small') { + imageContent = ( +
+ {images.map((src, i) => ( + handlePhotoClick(e, i)} + /> + ))} +
+ ) + } else if (isSmallScreen && (images.length === 2 || images.length === 4)) { + imageContent = ( +
+ {images.map((src, i) => ( + handlePhotoClick(e, i)} + /> + ))} +
+ ) + } else { + imageContent = ( +
+ {images.map((src, i) => ( + handlePhotoClick(e, i)} + /> + ))} +
+ ) + } + + return ( +
+ {imageContent} +
e.stopPropagation()}> + ({ src }))} + plugins={[Zoom]} + open={index >= 0} + close={() => setIndex(-1)} + controller={{ + closeOnBackdropClick: true, + closeOnPullUp: true, + closeOnPullDown: true + }} + styles={{ toolbar: { paddingTop: '2.25rem' } }} + /> +
{isNsfw && }
) diff --git a/src/components/Nip22ReplyNoteList/index.tsx b/src/components/Nip22ReplyNoteList/index.tsx new file mode 100644 index 0000000..384a0c5 --- /dev/null +++ b/src/components/Nip22ReplyNoteList/index.tsx @@ -0,0 +1,190 @@ +import { Separator } from '@/components/ui/separator' +import { COMMENT_EVENT_KIND } from '@/constants' +import { tagNameEquals } from '@/lib/tag' +import { cn } from '@/lib/utils' +import { useNostr } from '@/providers/NostrProvider' +import { useNoteStats } from '@/providers/NoteStatsProvider' +import client from '@/services/client.service' +import dayjs from 'dayjs' +import { Event as NEvent } from 'nostr-tools' +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import ReplyNote from '../ReplyNote' +import { isCommentEvent } from '@/lib/event' + +const LIMIT = 100 + +export default function Nip22ReplyNoteList({ + event, + className +}: { + event: NEvent + className?: string +}) { + const { t } = useTranslation() + const { pubkey } = useNostr() + const [timelineKey, setTimelineKey] = useState(undefined) + const [until, setUntil] = useState(() => dayjs().unix()) + const [replies, setReplies] = useState([]) + const [replyMap, setReplyMap] = useState< + Record + >({}) + const [loading, setLoading] = useState(false) + const [highlightReplyId, setHighlightReplyId] = useState(undefined) + const { updateNoteReplyCount } = useNoteStats() + const replyRefs = useRef>({}) + const bottomRef = useRef(null) + + useEffect(() => { + const handleEventPublished = (data: Event) => { + const customEvent = data as CustomEvent + const evt = customEvent.detail + if ( + isCommentEvent(evt) && + evt.tags.some(([tagName, tagValue]) => tagName === 'E' && tagValue === event.id) + ) { + onNewReply(evt) + } + } + + client.addEventListener('eventPublished', handleEventPublished) + return () => { + client.removeEventListener('eventPublished', handleEventPublished) + } + }, [event]) + + useEffect(() => { + if (loading) return + + const init = async () => { + setLoading(true) + setReplies([]) + + try { + const relayList = await client.fetchRelayList(event.pubkey) + const { closer, timelineKey } = await client.subscribeTimeline( + relayList.read.slice(0, 5), + { + '#E': [event.id], + kinds: [COMMENT_EVENT_KIND], + limit: LIMIT + }, + { + onEvents: (evts, eosed) => { + setReplies(evts.reverse()) + if (eosed) { + setLoading(false) + setUntil(evts.length >= LIMIT ? evts[evts.length - 1].created_at - 1 : undefined) + } + }, + onNew: (evt) => { + onNewReply(evt) + } + } + ) + setTimelineKey(timelineKey) + return closer + } catch { + setLoading(false) + } + return + } + + const promise = init() + return () => { + promise.then((closer) => closer?.()) + } + }, [event]) + + useEffect(() => { + updateNoteReplyCount(event.id, replies.length) + + const replyMap: Record = + {} + for (const reply of replies) { + const parentEventId = reply.tags.find(tagNameEquals('e'))?.[1] + if (parentEventId && parentEventId !== event.id) { + const parentReplyInfo = replyMap[parentEventId] + const level = parentReplyInfo ? parentReplyInfo.level + 1 : 1 + replyMap[reply.id] = { event: reply, level, parent: parentReplyInfo?.event } + continue + } + + replyMap[reply.id] = { event: reply, level: 1 } + continue + } + setReplyMap(replyMap) + }, [replies, event.id, updateNoteReplyCount]) + + const loadMore = async () => { + if (loading || !until || !timelineKey) return + + setLoading(true) + const events = await client.loadMoreTimeline(timelineKey, until, LIMIT) + const olderReplies = events.reverse() + if (olderReplies.length > 0) { + setReplies((pre) => [...olderReplies, ...pre]) + } + setUntil(events.length ? events[events.length - 1].created_at - 1 : undefined) + setLoading(false) + } + + const onNewReply = (evt: NEvent) => { + setReplies((pre) => { + if (pre.some((reply) => reply.id === evt.id)) return pre + return [...pre, evt] + }) + if (evt.pubkey === pubkey) { + setTimeout(() => { + if (bottomRef.current) { + bottomRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) + } + highlightReply(evt.id, false) + }, 100) + } + } + + const highlightReply = (eventId: string, scrollTo = true) => { + if (scrollTo) { + const ref = replyRefs.current[eventId] + if (ref) { + ref.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) + } + } + setHighlightReplyId(eventId) + setTimeout(() => { + setHighlightReplyId((pre) => (pre === eventId ? undefined : pre)) + }, 1500) + } + + return ( + <> +
+ {loading ? t('loading...') : until ? t('load more older replies') : null} +
+ {replies.length > 0 && (loading || until) && } +
+ {replies.map((reply) => { + const info = replyMap[reply.id] + return ( +
(replyRefs.current[reply.id] = el)} key={reply.id}> + +
+ ) + })} +
+ {replies.length === 0 && !loading && !until && ( +
{t('no replies')}
+ )} +
+ + ) +} diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index 18851b3..19d533b 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -62,7 +62,7 @@ export default function Note({ )} {!hideStats && ( - + )}
) diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 54534a0..5aae6bf 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -1,8 +1,10 @@ import { Button } from '@/components/ui/button' +import { PICTURE_EVENT_KIND } from '@/constants' import { useFetchRelayInfos } from '@/hooks' import { isReplyNoteEvent } from '@/lib/event' import { cn } from '@/lib/utils' import { useNostr } from '@/providers/NostrProvider' +import { useScreenSize } from '@/providers/ScreenSizeProvider' import client from '@/services/client.service' import dayjs from 'dayjs' import { Event, Filter, kinds } from 'nostr-tools' @@ -10,10 +12,14 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import PullToRefresh from 'react-simple-pull-to-refresh' import NoteCard from '../NoteCard' +import PictureNoteCard from '../PictureNoteCard' +import SimpleMasonryGrid from '../SimpleMasonryGrid' const NORMAL_RELAY_LIMIT = 100 const ALGO_RELAY_LIMIT = 500 +type TListMode = 'posts' | 'postsAndReplies' | 'pictures' + export default function NoteList({ relayUrls, filter = {}, @@ -24,6 +30,7 @@ export default function NoteList({ className?: string }) { const { t } = useTranslation() + const { isSmallScreen } = useScreenSize() const { signEvent, checkLogin } = useNostr() const { isFetching: isFetchingRelayInfo, areAlgoRelays } = useFetchRelayInfos([...relayUrls]) const [refreshCount, setRefreshCount] = useState(0) @@ -32,15 +39,23 @@ export default function NoteList({ const [newEvents, setNewEvents] = useState([]) const [hasMore, setHasMore] = useState(true) const [refreshing, setRefreshing] = useState(true) - const [displayReplies, setDisplayReplies] = useState(false) + const [listMode, setListMode] = useState('posts') const bottomRef = useRef(null) + const isPictures = useMemo(() => listMode === 'pictures', [listMode]) const noteFilter = useMemo(() => { + if (isPictures) { + return { + kinds: [PICTURE_EVENT_KIND], + limit: areAlgoRelays ? ALGO_RELAY_LIMIT : NORMAL_RELAY_LIMIT, + ...filter + } + } return { - kinds: [kinds.ShortTextNote, kinds.Repost], + kinds: [kinds.ShortTextNote, kinds.Repost, PICTURE_EVENT_KIND], limit: areAlgoRelays ? ALGO_RELAY_LIMIT : NORMAL_RELAY_LIMIT, ...filter } - }, [JSON.stringify(filter), areAlgoRelays]) + }, [JSON.stringify(filter), areAlgoRelays, isPictures]) useEffect(() => { if (isFetchingRelayInfo || relayUrls.length === 0) return @@ -151,55 +166,65 @@ export default function NoteList({ return (
- -
- {newEvents.filter((event) => displayReplies || !isReplyNoteEvent(event)).length > 0 && ( -
- -
- )} - - { - setRefreshCount((count) => count + 1) - await new Promise((resolve) => setTimeout(resolve, 1000)) - }} - pullingContent="" - > -
- {events - .filter((event) => displayReplies || !isReplyNoteEvent(event)) - .map((event) => ( - + + { + setRefreshCount((count) => count + 1) + await new Promise((resolve) => setTimeout(resolve, 1000)) + }} + pullingContent="" + > +
+ {newEvents.filter((event) => listMode !== 'posts' || !isReplyNoteEvent(event)).length > + 0 && ( +
+ +
+ )} + {isPictures ? ( + ( + ))} + /> + ) : ( +
+ {events + .filter((event) => listMode === 'postsAndReplies' || !isReplyNoteEvent(event)) + .map((event) => ( + + ))} +
+ )} +
+ {hasMore || refreshing ? ( +
{t('loading...')}
+ ) : events.length ? ( + t('no more notes') + ) : ( +
+ +
+ )}
- -
-
- {hasMore || refreshing ? ( -
{t('loading...')}
- ) : events.length ? ( - t('no more notes') - ) : ( -
- -
- )} -
+
+
) } -function DisplayRepliesSwitch({ - displayReplies, - setDisplayReplies +function ListModeSwitch({ + listMode, + setListMode }: { - displayReplies: boolean - setDisplayReplies: (value: boolean) => void + listMode: TListMode + setListMode: (listMode: TListMode) => void }) { const { t } = useTranslation() @@ -207,20 +232,26 @@ function DisplayRepliesSwitch({
setDisplayReplies(false)} + className={`w-1/3 text-center py-2 font-semibold clickable cursor-pointer rounded-lg ${listMode === 'posts' ? '' : 'text-muted-foreground'}`} + onClick={() => setListMode('posts')} > {t('Notes')}
setDisplayReplies(true)} + className={`w-1/3 text-center py-2 font-semibold clickable cursor-pointer rounded-lg ${listMode === 'postsAndReplies' ? '' : 'text-muted-foreground'}`} + onClick={() => setListMode('postsAndReplies')} > {t('Notes & Replies')}
+
setListMode('pictures')} + > + {t('Pictures')} +
diff --git a/src/components/NotificationList/index.tsx b/src/components/NotificationList/index.tsx index ea94de9..8003568 100644 --- a/src/components/NotificationList/index.tsx +++ b/src/components/NotificationList/index.tsx @@ -1,3 +1,4 @@ +import { COMMENT_EVENT_KIND, PICTURE_EVENT_KIND } from '@/constants' import { useFetchEvent } from '@/hooks' import { toNote } from '@/lib/link' import { tagNameEquals } from '@/lib/tag' @@ -5,7 +6,7 @@ import { useSecondaryPage } from '@/PageManager' import { useNostr } from '@/providers/NostrProvider' import client from '@/services/client.service' import dayjs from 'dayjs' -import { Heart, MessageCircle, Repeat } from 'lucide-react' +import { Heart, MessageCircle, Repeat, ThumbsUp } from 'lucide-react' import { Event, kinds, nip19, validateEvent } from 'nostr-tools' import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -41,7 +42,7 @@ export default function NotificationList() { : relayList.read.concat(client.getDefaultRelayUrls()).slice(0, 4), { '#p': [pubkey], - kinds: [kinds.ShortTextNote, kinds.Repost, kinds.Reaction], + kinds: [kinds.ShortTextNote, kinds.Repost, kinds.Reaction, COMMENT_EVENT_KIND], limit: LIMIT }, { @@ -147,6 +148,9 @@ function NotificationItem({ notification }: { notification: Event }) { if (notification.kind === kinds.Repost) { return } + if (notification.kind === COMMENT_EVENT_KIND) { + return + } return null } @@ -162,7 +166,9 @@ function ReactionNotification({ notification }: { notification: Event }) { : undefined }, [notification]) const { event } = useFetchEvent(bech32Id) - if (!event || !bech32Id || event.kind !== kinds.ShortTextNote) return null + if (!event || !bech32Id || ![kinds.ShortTextNote, PICTURE_EVENT_KIND].includes(event.kind)) { + return null + } return (
+
{notification.content === '+' ? : notification.content}
@@ -228,8 +235,37 @@ function RepostNotification({ notification }: { notification: Event }) { ) } +function CommentNotification({ notification }: { notification: Event }) { + const { push } = useSecondaryPage() + const rootEventId = notification.tags.find(tagNameEquals('E'))?.[1] + const rootPubkey = notification.tags.find(tagNameEquals('P'))?.[1] + const rootKind = notification.tags.find(tagNameEquals('K'))?.[1] + if ( + !rootEventId || + !rootPubkey || + !rootKind || + ![kinds.ShortTextNote, PICTURE_EVENT_KIND].includes(parseInt(rootKind)) + ) { + return null + } + + return ( +
push(toNote({ id: rootEventId, pubkey: rootPubkey }))} + > + + + +
+ +
+
+ ) +} + function ContentPreview({ event }: { event?: Event }) { - if (!event || event.kind !== kinds.ShortTextNote) return null + if (!event) return null return
{event.content}
} diff --git a/src/components/PictureContent/index.tsx b/src/components/PictureContent/index.tsx new file mode 100644 index 0000000..fef3634 --- /dev/null +++ b/src/components/PictureContent/index.tsx @@ -0,0 +1,48 @@ +import { extractImetaUrlFromTag } from '@/lib/tag' +import { cn } from '@/lib/utils' +import { Event } from 'nostr-tools' +import { memo, ReactNode } from 'react' +import { + embedded, + embeddedHashtagRenderer, + embeddedNormalUrlRenderer, + embeddedNostrNpubRenderer, + embeddedNostrProfileRenderer, + embeddedWebsocketUrlRenderer +} from '../Embedded' +import { ImageCarousel } from '../ImageCarousel' +import { isNsfwEvent } from '@/lib/event' + +const PictureContent = memo(({ event, className }: { event: Event; className?: string }) => { + const images: string[] = [] + event.tags.forEach((tag) => { + const imageUrl = extractImetaUrlFromTag(tag) + if (imageUrl) { + images.push(imageUrl) + } + }) + const isNsfw = isNsfwEvent(event) + + const nodes: ReactNode[] = [ + + ] + nodes.push( +
+ {embedded(event.content, [ + embeddedNormalUrlRenderer, + embeddedWebsocketUrlRenderer, + embeddedHashtagRenderer, + embeddedNostrNpubRenderer, + embeddedNostrProfileRenderer + ])} +
+ ) + + return ( +
+ {nodes} +
+ ) +}) +PictureContent.displayName = 'PictureContent' +export default PictureContent diff --git a/src/components/PictureNote/index.tsx b/src/components/PictureNote/index.tsx new file mode 100644 index 0000000..adf0c83 --- /dev/null +++ b/src/components/PictureNote/index.tsx @@ -0,0 +1,53 @@ +import { getUsingClient } from '@/lib/event' +import { Event } from 'nostr-tools' +import { useMemo } from 'react' +import { FormattedTimestamp } from '../FormattedTimestamp' +import NoteStats from '../NoteStats' +import UserAvatar from '../UserAvatar' +import Username from '../Username' +import PictureContent from '../PictureContent' + +export default function PictureNote({ + event, + className, + hideStats = false, + fetchNoteStats = false +}: { + event: Event + className?: string + hideStats?: boolean + fetchNoteStats?: boolean +}) { + const usingClient = useMemo(() => getUsingClient(event), [event]) + + return ( +
+
+ +
+
+ + {usingClient && ( +
using {usingClient}
+ )} +
+
+ +
+
+
+ + {!hideStats && ( + + )} +
+ ) +} diff --git a/src/components/PictureNoteCard/index.tsx b/src/components/PictureNoteCard/index.tsx new file mode 100644 index 0000000..f000cf2 --- /dev/null +++ b/src/components/PictureNoteCard/index.tsx @@ -0,0 +1,31 @@ +import { extractFirstPictureFromPictureEvent } from '@/lib/event' +import { toNote } from '@/lib/link' +import { cn } from '@/lib/utils' +import { useSecondaryPage } from '@/PageManager' +import { Event } from 'nostr-tools' +import Image from '../Image' +import UserAvatar from '../UserAvatar' +import Username from '../Username' + +export default function PictureNoteCard({ + event, + className +}: { + event: Event + className?: string +}) { + const { push } = useSecondaryPage() + const firstImage = extractFirstPictureFromPictureEvent(event) + if (!firstImage) return null + + return ( +
push(toNote(event))}> + +
{event.content}
+
+ + +
+
+ ) +} diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index 5b46189..2bf3fce 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -4,16 +4,21 @@ import { Switch } from '@/components/ui/switch' import { Textarea } from '@/components/ui/textarea' import { StorageKey } from '@/constants' import { useToast } from '@/hooks/use-toast' -import { createShortTextNoteDraftEvent } from '@/lib/draft-event' +import { + createCommentDraftEvent, + createPictureNoteDraftEvent, + createShortTextNoteDraftEvent +} from '@/lib/draft-event' import { useNostr } from '@/providers/NostrProvider' import client from '@/services/client.service' import { ChevronDown, LoaderCircle } from 'lucide-react' -import { Event } from 'nostr-tools' +import { Event, kinds } from 'nostr-tools' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import Mentions from './Mentions' import Preview from './Preview' import Uploader from './Uploader' +import { extractImagesFromContent } from '@/lib/event' export default function PostContent({ defaultContent = '', @@ -31,12 +36,19 @@ export default function PostContent({ const [posting, setPosting] = useState(false) const [showMoreOptions, setShowMoreOptions] = useState(false) const [addClientTag, setAddClientTag] = useState(false) + const [isPictureNote, setIsPictureNote] = useState(false) + const [hasImages, setHasImages] = useState(false) const canPost = !!content && !posting useEffect(() => { setAddClientTag(window.localStorage.getItem(StorageKey.ADD_CLIENT_TAG) === 'true') }, []) + useEffect(() => { + const { images } = extractImagesFromContent(content) + setHasImages(!!images && images.length > 0) + }, [content]) + const handleTextareaChange = (e: React.ChangeEvent) => { setContent(e.target.value) } @@ -56,10 +68,18 @@ export default function PostContent({ const relayList = await client.fetchRelayList(parentEvent.pubkey) additionalRelayUrls.push(...relayList.read.slice(0, 5)) } - const draftEvent = await createShortTextNoteDraftEvent(content, { - parentEvent, - addClientTag - }) + if (isPictureNote && !hasImages) { + throw new Error(t('Picture note requires images')) + } + const draftEvent = + isPictureNote && !parentEvent && hasImages + ? await createPictureNoteDraftEvent(content, { addClientTag }) + : parentEvent && parentEvent.kind !== kinds.ShortTextNote + ? await createCommentDraftEvent(content, parentEvent, { addClientTag }) + : await createShortTextNoteDraftEvent(content, { + parentEvent, + addClientTag + }) await publish(draftEvent, additionalRelayUrls) setContent('') close() @@ -151,6 +171,21 @@ export default function PostContent({
{t('Show others this was sent via Jumble')}
+ {!parentEvent && ( + <> +
+ + +
+
+ {t('A special note for picture-first clients like Olas')} +
+ + )}
)}
diff --git a/src/components/ProfileBanner/index.tsx b/src/components/ProfileBanner/index.tsx index f2137d6..10663e3 100644 --- a/src/components/ProfileBanner/index.tsx +++ b/src/components/ProfileBanner/index.tsx @@ -1,7 +1,6 @@ -import { Image } from '@nextui-org/image' import { generateImageByPubkey } from '@/lib/pubkey' -import { cn } from '@/lib/utils' import { useEffect, useMemo, useState } from 'react' +import Image from '../Image' export default function ProfileBanner({ pubkey, @@ -27,9 +26,8 @@ export default function ProfileBanner({ {`${pubkey} setBannerUrl(defaultBanner)} - removeWrapper /> ) } diff --git a/src/components/SimpleMasonryGrid/index.tsx b/src/components/SimpleMasonryGrid/index.tsx new file mode 100644 index 0000000..ac1419f --- /dev/null +++ b/src/components/SimpleMasonryGrid/index.tsx @@ -0,0 +1,36 @@ +import { cn } from '@/lib/utils' +import { useMemo, ReactNode } from 'react' + +export default function SimpleMasonryGrid({ + items, + columnCount, + className +}: { + items: ReactNode[] + columnCount: 2 | 3 + className?: string +}) { + const columns = useMemo(() => { + const newColumns: ReactNode[][] = Array.from({ length: columnCount }, () => []) + items.forEach((item, i) => { + newColumns[i % columnCount].push(item) + }) + return newColumns + }, [items]) + + return ( +
+ {columns.map((column, i) => ( +
+ {column} +
+ ))} +
+ ) +} diff --git a/src/components/UserAvatar/index.tsx b/src/components/UserAvatar/index.tsx index d5d56a8..1783e65 100644 --- a/src/components/UserAvatar/index.tsx +++ b/src/components/UserAvatar/index.tsx @@ -14,6 +14,7 @@ const UserAvatarSizeCnMap = { big: 'w-16 h-16', normal: 'w-10 h-10', small: 'w-7 h-7', + xSmall: 'w-5 h-5', tiny: 'w-4 h-4' } @@ -24,7 +25,7 @@ export default function UserAvatar({ }: { userId: string className?: string - size?: 'large' | 'big' | 'normal' | 'small' | 'tiny' + size?: 'large' | 'big' | 'normal' | 'small' | 'xSmall' | 'tiny' }) { const { profile } = useFetchProfile(userId) const defaultAvatar = useMemo( diff --git a/src/components/WebPreview/index.tsx b/src/components/WebPreview/index.tsx index d2ed442..c602e16 100644 --- a/src/components/WebPreview/index.tsx +++ b/src/components/WebPreview/index.tsx @@ -1,8 +1,8 @@ import { useFetchWebMetadata } from '@/hooks/useFetchWebMetadata' import { cn } from '@/lib/utils' import { useScreenSize } from '@/providers/ScreenSizeProvider' -import { Image } from '@nextui-org/image' import { useMemo } from 'react' +import Image from '../Image' export default function WebPreview({ url, @@ -30,7 +30,7 @@ export default function WebPreview({ if (isSmallScreen && image) { return (
- +
{hostname}
{title}
@@ -48,11 +48,7 @@ export default function WebPreview({ }} > {image && ( - + )}
{hostname}
diff --git a/src/components/ui/carousel.tsx b/src/components/ui/carousel.tsx new file mode 100644 index 0000000..9c2b9bf --- /dev/null +++ b/src/components/ui/carousel.tsx @@ -0,0 +1,260 @@ +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< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const { carouselRef, orientation } = useCarousel() + + return ( +
+
+
+ ) +}) +CarouselContent.displayName = "CarouselContent" + +const CarouselItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const { orientation } = useCarousel() + + return ( +
+ ) +}) +CarouselItem.displayName = "CarouselItem" + +const CarouselPrevious = React.forwardRef< + HTMLButtonElement, + React.ComponentProps +>(({ className, variant = "outline", size = "icon", ...props }, ref) => { + const { orientation, scrollPrev, canScrollPrev } = useCarousel() + + return ( + + ) +}) +CarouselPrevious.displayName = "CarouselPrevious" + +const CarouselNext = React.forwardRef< + HTMLButtonElement, + React.ComponentProps +>(({ 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 a886bc9..65c945a 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -14,3 +14,8 @@ export const BIG_RELAY_URLS = [ ] export const SEARCHABLE_RELAY_URLS = ['wss://relay.nostr.band/', 'wss://search.nos.today/'] + +export const PICTURE_EVENT_KIND = 20 +export const COMMENT_EVENT_KIND = 1111 + +export const URL_REGEX = /(https?:\/\/[^\s"']+)/g diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 23e7407..3934516 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -99,6 +99,11 @@ export default { Dark: 'Dark', Temporary: 'Temporary', 'Choose a relay collection': 'Choose a relay collection', - 'Switch account': 'Switch account' + 'Switch account': 'Switch account', + Pictures: 'Pictures', + 'Picture note': 'Picture note', + 'A special note for picture-first clients like Olas': + 'A special note for picture-first clients like Olas', + 'Picture note requires images': 'Picture note requires images' } } diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index cf080b3..c26b50e 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -98,6 +98,11 @@ export default { Dark: '深色', Temporary: '临时', 'Choose a relay collection': '选择一个服务器组', - 'Switch account': '切换账户' + 'Switch account': '切换账户', + Pictures: '图片', + 'Picture note': '图片笔记', + 'A special note for picture-first clients like Olas': + '一种可以在图片优先客户端 (如 Olas) 中显示的特殊笔记', + 'Picture note requires images': '图片笔记需要有图片' } } diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts index 62400e0..fefe220 100644 --- a/src/lib/draft-event.ts +++ b/src/lib/draft-event.ts @@ -1,7 +1,15 @@ +import { COMMENT_EVENT_KIND, PICTURE_EVENT_KIND } from '@/constants' import { TDraftEvent } from '@/types' import dayjs from 'dayjs' import { Event, kinds } from 'nostr-tools' -import { extractHashtags, extractMentions, getEventCoordinate, isReplaceable } from './event' +import { + extractCommentMentions, + extractHashtags, + extractImagesFromContent, + extractMentions, + getEventCoordinate, + isReplaceable +} from './event' // https://github.com/nostr-protocol/nips/blob/master/25.md export function createReactionDraftEvent(event: Event): TDraftEvent { @@ -73,3 +81,79 @@ export async function createShortTextNoteDraftEvent( created_at: dayjs().unix() } } + +export async function createPictureNoteDraftEvent( + content: string, + options: { + addClientTag?: boolean + } = {} +): Promise { + const { pubkeys, quoteEventIds } = await extractMentions(content) + const hashtags = extractHashtags(content) + const { images, contentWithoutImages } = extractImagesFromContent(content) + if (!images || !images.length) { + throw new Error('No images found in content') + } + + const tags = images + .map((image) => ['imeta', `url ${image}`]) + .concat(pubkeys.map((pubkey) => ['p', pubkey])) + .concat(quoteEventIds.map((eventId) => ['q', eventId])) + .concat(hashtags.map((hashtag) => ['t', hashtag])) + + if (options.addClientTag) { + tags.push(['client', 'jumble']) + } + + return { + kind: PICTURE_EVENT_KIND, + content: contentWithoutImages, + tags, + created_at: dayjs().unix() + } +} + +export async function createCommentDraftEvent( + content: string, + parentEvent: Event, + options: { + addClientTag?: boolean + } = {} +): Promise { + const { + pubkeys, + quoteEventIds, + rootEventId, + rootEventKind, + rootEventPubkey, + parentEventId, + parentEventKind, + parentEventPubkey + } = await extractCommentMentions(content, parentEvent) + const hashtags = extractHashtags(content) + + const tags = [ + ['E', rootEventId], + ['K', rootEventKind.toString()], + ['P', rootEventPubkey], + ['e', parentEventId], + ['k', parentEventKind.toString()], + ['p', parentEventPubkey] + ].concat( + pubkeys + .map((pubkey) => ['p', pubkey]) + .concat(quoteEventIds.map((eventId) => ['q', eventId])) + .concat(hashtags.map((hashtag) => ['t', hashtag])) + ) + + if (options.addClientTag) { + tags.push(['client', 'jumble']) + } + + return { + kind: COMMENT_EVENT_KIND, + content, + tags, + created_at: dayjs().unix() + } +} diff --git a/src/lib/event.ts b/src/lib/event.ts index 0314967..523fcf8 100644 --- a/src/lib/event.ts +++ b/src/lib/event.ts @@ -1,6 +1,7 @@ +import { COMMENT_EVENT_KIND, PICTURE_EVENT_KIND } from '@/constants' import client from '@/services/client.service' import { Event, kinds, nip19 } from 'nostr-tools' -import { isReplyETag, isRootETag, tagNameEquals } from './tag' +import { extractImetaUrlFromTag, isReplyETag, isRootETag, tagNameEquals } from './tag' export function isNsfwEvent(event: Event) { return event.tags.some( @@ -26,6 +27,14 @@ export function isReplyNoteEvent(event: Event) { return hasETag && !hasMarker } +export function isCommentEvent(event: Event) { + return event.kind === COMMENT_EVENT_KIND +} + +export function isPictureEvent(event: Event) { + return event.kind === PICTURE_EVENT_KIND +} + export function getParentEventId(event?: Event) { return event?.tags.find(isReplyETag)?.[1] } @@ -116,6 +125,54 @@ export async function extractMentions(content: string, parentEvent?: Event) { } } +export async function extractCommentMentions(content: string, parentEvent: Event) { + const pubkeySet = new Set() + const quoteEventIdSet = new Set() + const rootEventId = parentEvent.tags.find(tagNameEquals('E'))?.[1] ?? parentEvent.id + const rootEventKind = parentEvent.tags.find(tagNameEquals('K'))?.[1] ?? parentEvent.kind + const rootEventPubkey = parentEvent.tags.find(tagNameEquals('P'))?.[1] ?? parentEvent.pubkey + const parentEventId = parentEvent.id + const parentEventKind = parentEvent.kind + const parentEventPubkey = parentEvent.pubkey + + const matches = content.match( + /nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+|note1[a-z0-9]{58}|nevent1[a-z0-9]+)/g + ) + + for (const m of matches || []) { + try { + const id = m.split(':')[1] + const { type, data } = nip19.decode(id) + if (type === 'nprofile') { + pubkeySet.add(data.pubkey) + } else if (type === 'npub') { + pubkeySet.add(data) + } else if (['nevent', 'note', 'naddr'].includes(type)) { + const event = await client.fetchEvent(id) + if (event) { + pubkeySet.add(event.pubkey) + quoteEventIdSet.add(event.id) + } + } + } catch (e) { + console.error(e) + } + } + + pubkeySet.add(parentEvent.pubkey) + + return { + pubkeys: Array.from(pubkeySet), + quoteEventIds: Array.from(quoteEventIdSet), + rootEventId, + rootEventKind, + rootEventPubkey, + parentEventId, + parentEventKind, + parentEventPubkey + } +} + export function extractHashtags(content: string) { const hashtags: string[] = [] const matches = content.match(/#[\p{L}\p{N}\p{M}]+/gu) @@ -127,3 +184,22 @@ export function extractHashtags(content: string) { }) return hashtags } + +export function extractFirstPictureFromPictureEvent(event: Event) { + if (!isPictureEvent(event)) return null + for (const tag of event.tags) { + const url = extractImetaUrlFromTag(tag) + if (url) return url + } + return null +} + +export function extractImagesFromContent(content: string) { + const images = content.match(/https?:\/\/[^\s"']+\.(jpg|jpeg|png|gif|webp|heic)/gi) + let contentWithoutImages = content + images?.forEach((url) => { + contentWithoutImages = contentWithoutImages.replace(url, '').trim() + }) + contentWithoutImages = contentWithoutImages.replace(/\n{3,}/g, '\n\n').trim() + return { images, contentWithoutImages } +} diff --git a/src/lib/link.ts b/src/lib/link.ts index bcf9b35..a941fb3 100644 --- a/src/lib/link.ts +++ b/src/lib/link.ts @@ -1,7 +1,7 @@ import { Event, nip19 } from 'nostr-tools' export const toHome = () => '/' -export const toNote = (eventOrId: Event | string) => { +export const toNote = (eventOrId: Pick | string) => { if (typeof eventOrId === 'string') return `/notes/${eventOrId}` const nevent = nip19.neventEncode({ id: eventOrId.id, author: eventOrId.pubkey }) return `/notes/${nevent}` diff --git a/src/lib/tag.ts b/src/lib/tag.ts index e6499bd..23bf9c3 100644 --- a/src/lib/tag.ts +++ b/src/lib/tag.ts @@ -13,3 +13,10 @@ export function isRootETag([tagName, , , marker]: string[]) { export function isMentionETag([tagName, , , marker]: string[]) { return tagName === 'e' && marker === 'mention' } + +export function extractImetaUrlFromTag(tag: string[]) { + if (tag[0] !== 'imeta') return null + const urlItem = tag.find((item) => item.startsWith('url ')) + const url = urlItem?.slice(4) + return url || null +} diff --git a/src/lib/url.ts b/src/lib/url.ts index c797622..399339b 100644 --- a/src/lib/url.ts +++ b/src/lib/url.ts @@ -18,3 +18,21 @@ export function normalizeUrl(url: string): string { export function simplifyUrl(url: string): string { return url.replace('wss://', '').replace('ws://', '').replace(/\/$/, '') } + +export function isImage(url: string) { + try { + const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.heic', '.svg'] + return imageExtensions.some((ext) => new URL(url).pathname.toLowerCase().endsWith(ext)) + } catch { + return false + } +} + +export function isVideo(url: string) { + try { + const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov'] + return videoExtensions.some((ext) => new URL(url).pathname.toLowerCase().endsWith(ext)) + } catch { + return false + } +} diff --git a/src/pages/secondary/NotePage/index.tsx b/src/pages/secondary/NotePage/index.tsx index 8efb285..f68c0c9 100644 --- a/src/pages/secondary/NotePage/index.tsx +++ b/src/pages/secondary/NotePage/index.tsx @@ -1,5 +1,7 @@ import { useSecondaryPage } from '@/PageManager' +import Nip22ReplyNoteList from '@/components/Nip22ReplyNoteList' import Note from '@/components/Note' +import PictureNote from '@/components/PictureNote' import ReplyNoteList from '@/components/ReplyNoteList' import UserAvatar from '@/components/UserAvatar' import Username from '@/components/Username' @@ -8,14 +10,16 @@ import { Separator } from '@/components/ui/separator' import { Skeleton } from '@/components/ui/skeleton' import { useFetchEvent } from '@/hooks' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' -import { getParentEventId, getRootEventId } from '@/lib/event' +import { getParentEventId, getRootEventId, isPictureEvent } from '@/lib/event' import { toNote } from '@/lib/link' +import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import NotFoundPage from '../NotFoundPage' export default function NotePage({ id, index }: { id?: string; index?: number }) { const { t } = useTranslation() + const { isSmallScreen } = useScreenSize() const { event, isFetching } = useFetchEvent(id) const parentEventId = useMemo(() => getParentEventId(event), [event]) const rootEventId = useMemo(() => getRootEventId(event), [event]) @@ -31,6 +35,20 @@ export default function NotePage({ id, index }: { id?: string; index?: number }) } if (!event) return + if (isPictureEvent(event) && isSmallScreen) { + return ( + + + + + + ) + } + return (
@@ -39,7 +57,15 @@ export default function NotePage({ id, index }: { id?: string; index?: number })
- + {isPictureEvent(event) ? ( + + ) : ( + + )}
) } diff --git a/src/pages/secondary/ProfilePage/index.tsx b/src/pages/secondary/ProfilePage/index.tsx index 8104c63..a3e732d 100644 --- a/src/pages/secondary/ProfilePage/index.tsx +++ b/src/pages/secondary/ProfilePage/index.tsx @@ -67,7 +67,7 @@ export default function ProfilePage({ id, index }: { id?: string; index?: number diff --git a/tailwind.config.js b/tailwind.config.js index 3dc0aae..4a3e2fe 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -4,7 +4,6 @@ export default { content: [ './index.html', './src/**/*.{ts,tsx}', - './node_modules/@nextui-org/theme/dist/components/image.js' ], theme: { extend: {