From c7623d6b2d75fe49b5dc758fbb67c6a380f538de Mon Sep 17 00:00:00 2001 From: Henrique Dias Date: Sun, 15 Feb 2026 08:09:28 +0100 Subject: [PATCH 1/9] docs: update instructions to use bun --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 1c7eb29..a2f285b 100644 --- a/README.md +++ b/README.md @@ -58,8 +58,8 @@ API server runs on http://localhost:8081 ```bash cd web -npm install -npm run dev +bun install +bun run dev ``` Dev server runs on http://localhost:4321 and proxies API requests to the backend. @@ -70,9 +70,9 @@ Built with [WXT](https://wxt.dev): ```bash cd extension -npm install -npm run dev # Chrome dev mode -npm run dev:firefox # Firefox dev mode +bun install +bun run dev # Chrome dev mode +bun run dev:firefox # Firefox dev mode ``` ## Architecture From 20850ebf13d62a09af6d1db9c6a7061ab02fa4a5 Mon Sep 17 00:00:00 2001 From: Henrique Dias Date: Sun, 15 Feb 2026 08:36:17 +0100 Subject: [PATCH 2/9] fix: ingestion of preferences record --- backend/internal/firehose/ingester.go | 2 + web/bun.lock | 72 +++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/backend/internal/firehose/ingester.go b/backend/internal/firehose/ingester.go index 6e83fa8..79329fc 100644 --- a/backend/internal/firehose/ingester.go +++ b/backend/internal/firehose/ingester.go @@ -794,6 +794,7 @@ func (i *Ingester) handlePreferences(event *FirehoseEvent) { ExternalLinkSkippedHostnames []string `json:"externalLinkSkippedHostnames"` SubscribedLabelers json.RawMessage `json:"subscribedLabelers"` LabelPreferences json.RawMessage `json:"labelPreferences"` + DisableExternalLinkWarning *bool `json:"disableExternalLinkWarning,omitempty"` CreatedAt string `json:"createdAt"` } @@ -837,6 +838,7 @@ func (i *Ingester) handlePreferences(event *FirehoseEvent) { AuthorDID: event.Repo, ExternalLinkSkippedHostnames: skippedHostnamesPtr, SubscribedLabelers: subscribedLabelersPtr, + DisableExternalLinkWarning: record.DisableExternalLinkWarning, LabelPreferences: labelPrefsPtr, CreatedAt: createdAt, IndexedAt: time.Now(), diff --git a/web/bun.lock b/web/bun.lock index 37b3e65..d828f08 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "web", @@ -8,6 +9,7 @@ "@astrojs/react": "^4.4.2", "@astrojs/tailwind": "^6.0.2", "@nanostores/react": "^1.0.0", + "@resvg/resvg-js": "^2.6.2", "@tailwindcss/vite": "^4.1.18", "astro": "^5.17.1", "autoprefixer": "^10.4.24", @@ -20,11 +22,13 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "react-router-dom": "^7.13.0", + "satori": "^0.19.2", "tailwind-merge": "^3.4.0", "tailwindcss": "^3.4.19", }, "devDependencies": { "@eslint/js": "^10.0.1", + "@types/node": "^25.2.3", "@types/react": "^19.2.11", "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^8.54.0", @@ -256,6 +260,32 @@ "@pkgr/core": ["@pkgr/core@0.2.9", "", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="], + "@resvg/resvg-js": ["@resvg/resvg-js@2.6.2", "", { "optionalDependencies": { "@resvg/resvg-js-android-arm-eabi": "2.6.2", "@resvg/resvg-js-android-arm64": "2.6.2", "@resvg/resvg-js-darwin-arm64": "2.6.2", "@resvg/resvg-js-darwin-x64": "2.6.2", "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2", "@resvg/resvg-js-linux-arm64-gnu": "2.6.2", "@resvg/resvg-js-linux-arm64-musl": "2.6.2", "@resvg/resvg-js-linux-x64-gnu": "2.6.2", "@resvg/resvg-js-linux-x64-musl": "2.6.2", "@resvg/resvg-js-win32-arm64-msvc": "2.6.2", "@resvg/resvg-js-win32-ia32-msvc": "2.6.2", "@resvg/resvg-js-win32-x64-msvc": "2.6.2" } }, "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q=="], + + "@resvg/resvg-js-android-arm-eabi": ["@resvg/resvg-js-android-arm-eabi@2.6.2", "", { "os": "android", "cpu": "arm" }, "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA=="], + + "@resvg/resvg-js-android-arm64": ["@resvg/resvg-js-android-arm64@2.6.2", "", { "os": "android", "cpu": "arm64" }, "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ=="], + + "@resvg/resvg-js-darwin-arm64": ["@resvg/resvg-js-darwin-arm64@2.6.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A=="], + + "@resvg/resvg-js-darwin-x64": ["@resvg/resvg-js-darwin-x64@2.6.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw=="], + + "@resvg/resvg-js-linux-arm-gnueabihf": ["@resvg/resvg-js-linux-arm-gnueabihf@2.6.2", "", { "os": "linux", "cpu": "arm" }, "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw=="], + + "@resvg/resvg-js-linux-arm64-gnu": ["@resvg/resvg-js-linux-arm64-gnu@2.6.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg=="], + + "@resvg/resvg-js-linux-arm64-musl": ["@resvg/resvg-js-linux-arm64-musl@2.6.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg=="], + + "@resvg/resvg-js-linux-x64-gnu": ["@resvg/resvg-js-linux-x64-gnu@2.6.2", "", { "os": "linux", "cpu": "x64" }, "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw=="], + + "@resvg/resvg-js-linux-x64-musl": ["@resvg/resvg-js-linux-x64-musl@2.6.2", "", { "os": "linux", "cpu": "x64" }, "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ=="], + + "@resvg/resvg-js-win32-arm64-msvc": ["@resvg/resvg-js-win32-arm64-msvc@2.6.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ=="], + + "@resvg/resvg-js-win32-ia32-msvc": ["@resvg/resvg-js-win32-ia32-msvc@2.6.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w=="], + + "@resvg/resvg-js-win32-x64-msvc": ["@resvg/resvg-js-win32-x64-msvc@2.6.2", "", { "os": "win32", "cpu": "x64" }, "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], "@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="], @@ -324,6 +354,8 @@ "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], + "@shuding/opentype.js": ["@shuding/opentype.js@1.4.0-beta.0", "", { "dependencies": { "fflate": "^0.7.3", "string.prototype.codepointat": "^0.2.1" }, "bin": { "ot": "bin/ot" } }, "sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA=="], + "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="], @@ -378,6 +410,8 @@ "@types/nlcst": ["@types/nlcst@2.0.3", "", { "dependencies": { "@types/unist": "*" } }, "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA=="], + "@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="], + "@types/react": ["@types/react@19.2.11", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-tORuanb01iEzWvMGVGv2ZDhYZVeRMrw453DCSAIn/5yvcSVnMoUMTyf33nQJLahYEnv9xqrTNbgz4qY5EfSh0g=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -462,6 +496,8 @@ "base-64": ["base-64@1.0.0", "", {}, "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg=="], + "base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], @@ -486,6 +522,8 @@ "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], + "camelize": ["camelize@1.0.1", "", {}, "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ=="], + "caniuse-lite": ["caniuse-lite@1.0.30001768", "", {}, "sha512-qY3aDRZC5nWPgHUgIB84WL+nySuo19wk0VJpp/XI9T34lrvkyhRvNVOFJOp2kxClQhiFBu+TaUSudf6oa3vkSA=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], @@ -506,6 +544,8 @@ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], "commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], @@ -524,8 +564,18 @@ "crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="], + "css-background-parser": ["css-background-parser@0.1.0", "", {}, "sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA=="], + + "css-box-shadow": ["css-box-shadow@1.0.0-3", "", {}, "sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg=="], + + "css-color-keywords": ["css-color-keywords@1.0.0", "", {}, "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg=="], + + "css-gradient-parser": ["css-gradient-parser@0.0.17", "", {}, "sha512-w2Xy9UMMwlKtou0vlRnXvWglPAceXCTtcmVSo8ZBUvqCV5aXEFP/PC6d+I464810I9FT++UACwTD5511bmGPUg=="], + "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], + "css-to-react-native": ["css-to-react-native@3.2.0", "", { "dependencies": { "camelize": "^1.0.0", "css-color-keywords": "^1.0.0", "postcss-value-parser": "^4.0.2" } }, "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ=="], + "css-tree": ["css-tree@3.1.0", "", { "dependencies": { "mdn-data": "2.12.2", "source-map-js": "^1.0.1" } }, "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w=="], "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], @@ -598,6 +648,8 @@ "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "emoji-regex-xs": ["emoji-regex-xs@2.0.1", "", {}, "sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g=="], + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], "enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="], @@ -678,6 +730,8 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fflate": ["fflate@0.7.4", "", {}, "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw=="], + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], @@ -772,6 +826,8 @@ "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + "hex-rgb": ["hex-rgb@4.3.0", "", {}, "sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw=="], + "html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="], "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], @@ -910,6 +966,8 @@ "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], + "linebreak": ["linebreak@1.1.0", "", { "dependencies": { "base64-js": "0.0.8", "unicode-trie": "^2.0.0" } }, "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ=="], + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], @@ -1090,6 +1148,10 @@ "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], + "pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="], + + "parse-css-color": ["parse-css-color@0.2.1", "", { "dependencies": { "color-name": "^1.1.4", "hex-rgb": "^4.1.0" } }, "sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg=="], + "parse-latin": ["parse-latin@7.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "@types/unist": "^3.0.0", "nlcst-to-string": "^4.0.0", "unist-util-modify-children": "^4.0.0", "unist-util-visit-children": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ=="], "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], @@ -1216,6 +1278,8 @@ "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], + "satori": ["satori@0.19.2", "", { "dependencies": { "@shuding/opentype.js": "1.4.0-beta.0", "css-background-parser": "^0.1.0", "css-box-shadow": "1.0.0-3", "css-gradient-parser": "^0.0.17", "css-to-react-native": "^3.0.0", "emoji-regex-xs": "^2.0.1", "escape-html": "^1.0.3", "linebreak": "^1.1.0", "parse-css-color": "^0.2.1", "postcss-value-parser": "^4.2.0", "yoga-layout": "^3.2.1" } }, "sha512-71plFHWcq6WJBM5sf/n0eHOmTBiKLUB/G8du7SmLTTLHKEKrV3TPHGKcEVIoyjnbhnjvu9HhLyF9MATB/zzL7g=="], + "sax": ["sax@1.4.4", "", {}, "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], @@ -1266,6 +1330,8 @@ "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "string.prototype.codepointat": ["string.prototype.codepointat@0.2.1", "", {}, "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg=="], + "string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="], "string.prototype.repeat": ["string.prototype.repeat@1.0.0", "", { "dependencies": { "define-properties": "^1.1.3", "es-abstract": "^1.17.5" } }, "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w=="], @@ -1344,6 +1410,10 @@ "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="], + "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], "unifont": ["unifont@0.7.3", "", { "dependencies": { "css-tree": "^3.1.0", "ofetch": "^1.5.1", "ohash": "^2.0.11" } }, "sha512-b0GtQzKCyuSHGsfj5vyN8st7muZ6VCI4XD4vFlr7Uy1rlWVYxC3npnfk8MyreHxJYrz1ooLDqDzFe9XqQTlAhA=="], @@ -1418,6 +1488,8 @@ "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], + "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], From 164042d38ae6e4cb478b63d5ffcf694e5738898f Mon Sep 17 00:00:00 2001 From: Henrique Dias Date: Sun, 15 Feb 2026 08:38:14 +0100 Subject: [PATCH 3/9] fix: add annotations to mobile nav --- web/src/components/navigation/MobileNav.tsx | 34 +++++++++++++-------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/web/src/components/navigation/MobileNav.tsx b/web/src/components/navigation/MobileNav.tsx index d1df022..66acb49 100644 --- a/web/src/components/navigation/MobileNav.tsx +++ b/web/src/components/navigation/MobileNav.tsx @@ -1,22 +1,23 @@ -import React, { useState, useEffect } from "react"; -import { Link, useLocation } from "react-router-dom"; import { useStore } from "@nanostores/react"; -import { $user, logout } from "../../store/auth"; -import { getUnreadNotificationCount } from "../../api/client"; import { - Home, - Search, + Bell, + Bookmark, Folder, - User, + Highlighter, + Home, + LogOut, + MessageSquareText, + MoreHorizontal, PenSquare, - Bookmark, + Search, Settings, - MoreHorizontal, - LogOut, - Bell, - Highlighter, + User, X, } from "lucide-react"; +import React, { useEffect, useState } from "react"; +import { Link, useLocation } from "react-router-dom"; +import { getUnreadNotificationCount } from "../../api/client"; +import { $user, logout } from "../../store/auth"; import { AppleIcon } from "../common/Icons"; export default function MobileNav() { @@ -84,6 +85,15 @@ export default function MobileNav() {
+ + + Annotations + + Date: Sun, 15 Feb 2026 09:17:21 +0100 Subject: [PATCH 4/9] refactor: reuse some logic when fetching feed --- web/src/api/client.ts | 21 +-- web/src/components/feed/MasonryFeed.tsx | 13 +- web/src/types.ts | 5 +- web/src/views/core/Feed.tsx | 34 ++-- web/src/views/profile/Profile.tsx | 203 ++++++++++-------------- 5 files changed, 118 insertions(+), 158 deletions(-) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 538ddeb..d898edc 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,14 +1,15 @@ import { atom } from "nanostores"; import type { - UserProfile, - FeedResponse, AnnotationItem, Collection, + FeedResponse, + HydratedLabel, NotificationItem, - Target, Selector, - HydratedLabel, + Target, + UserProfile, } from "../types"; + export type { Collection } from "../types"; export const sessionAtom = atom(null); @@ -264,7 +265,9 @@ export async function getFeed({ }); if (!res.ok) throw new Error("Failed to fetch feed"); const data = await res.json(); - const normalizedItems = (data.items || []).map(normalizeItem); + const normalizedItems: AnnotationItem[] = (data.items || []).map( + normalizeItem, + ); const groupedItems: AnnotationItem[] = []; if (normalizedItems.length > 0) { @@ -292,7 +295,6 @@ export async function getFeed({ } return { - cursor: data.cursor, items: groupedItems, hasMore: normalizedItems.length >= limit, fetchedCount: normalizedItems.length, @@ -1046,10 +1048,11 @@ export async function getUserTargetItems( return { annotations: [], highlights: [] }; } } + import type { + LabelerInfo, LabelerSubscription, LabelPreference, - LabelerInfo, } from "../types"; export interface PreferencesResponse { @@ -1104,10 +1107,10 @@ export async function getLabelerInfo(): Promise { } import type { - ModerationRelationship, BlockedUser, - MutedUser, + ModerationRelationship, ModerationReport, + MutedUser, ReportReasonType, } from "../types"; diff --git a/web/src/components/feed/MasonryFeed.tsx b/web/src/components/feed/MasonryFeed.tsx index d01d2f1..18ea0a1 100644 --- a/web/src/components/feed/MasonryFeed.tsx +++ b/web/src/components/feed/MasonryFeed.tsx @@ -1,14 +1,13 @@ +import { useStore as useNanoStore, useStore } from "@nanostores/react"; +import { Loader2 } from "lucide-react"; import React, { useEffect, useState } from "react"; import { getFeed } from "../../api/client"; -import Card from "../common/Card"; -import { Loader2 } from "lucide-react"; -import { useStore } from "@nanostores/react"; import { $user } from "../../store/auth"; +import { $feedLayout } from "../../store/feedLayout"; import type { AnnotationItem } from "../../types"; -import { Tabs, EmptyState } from "../ui"; +import Card from "../common/Card"; +import { EmptyState, Tabs } from "../ui"; import LayoutToggle from "../ui/LayoutToggle"; -import { useStore as useNanoStore } from "@nanostores/react"; -import { $feedLayout } from "../../store/feedLayout"; interface MasonryFeedProps { motivation?: string; @@ -50,7 +49,7 @@ function MasonryContent({ getFeed(params) .then((data) => { if (cancelled) return; - setItems(data?.items || []); + setItems(data.items); setLoading(false); }) .catch((e) => { diff --git a/web/src/types.ts b/web/src/types.ts index 4f7dd5d..ec45a4c 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -93,10 +93,9 @@ export interface AnnotationItem { export type ActorSearchItem = UserProfile; export interface FeedResponse { - cursor?: string; items: AnnotationItem[]; - hasMore?: boolean; - fetchedCount?: number; + hasMore: boolean; + fetchedCount: number; } export interface NotificationItem { diff --git a/web/src/views/core/Feed.tsx b/web/src/views/core/Feed.tsx index dbb8e58..790114f 100644 --- a/web/src/views/core/Feed.tsx +++ b/web/src/views/core/Feed.tsx @@ -1,21 +1,21 @@ -import React, { useEffect, useState, useCallback } from "react"; -import { useSearchParams } from "react-router-dom"; -import { getFeed } from "../../api/client"; -import Card from "../../components/common/Card"; +import { useStore } from "@nanostores/react"; +import { clsx } from "clsx"; import { - Loader2, - Clock, Bookmark, + Clock, Highlighter, + Loader2, MessageSquareText, } from "lucide-react"; -import { useStore } from "@nanostores/react"; -import { $user } from "../../store/auth"; -import type { AnnotationItem } from "../../types"; -import { Tabs, EmptyState, Button } from "../../components/ui"; +import React, { useCallback, useEffect, useState } from "react"; +import { useSearchParams } from "react-router-dom"; +import { getFeed } from "../../api/client"; +import Card from "../../components/common/Card"; +import { Button, EmptyState, Tabs } from "../../components/ui"; import LayoutToggle from "../../components/ui/LayoutToggle"; +import { $user } from "../../store/auth"; import { $feedLayout } from "../../store/feedLayout"; -import { clsx } from "clsx"; +import type { AnnotationItem } from "../../types"; interface FeedProps { initialType?: string; @@ -51,14 +51,10 @@ function FeedContent({ getFeed({ type, motivation, tag, limit: LIMIT, offset: 0 }) .then((data) => { if (cancelled) return; - const fetched = data?.items || []; + const fetched = data.items; setItems(fetched); - if (data?.hasMore !== undefined) { - setHasMore(data.hasMore); - } else { - setHasMore(fetched.length >= LIMIT); - } - setOffset(data?.fetchedCount ?? fetched.length); + setHasMore(data.hasMore); + setOffset(data.fetchedCount); setLoading(false); }) .catch((e) => { @@ -91,7 +87,7 @@ function FeedContent({ } else { setHasMore(fetched.length >= LIMIT); } - setOffset((prev) => prev + (data?.fetchedCount ?? fetched.length)); + setOffset((prev) => prev + data.fetchedCount); } catch (e) { console.error(e); } finally { diff --git a/web/src/views/profile/Profile.tsx b/web/src/views/profile/Profile.tsx index 126e7f9..d8d2f0d 100644 --- a/web/src/views/profile/Profile.tsx +++ b/web/src/views/profile/Profile.tsx @@ -1,61 +1,61 @@ -import React, { useCallback, useEffect, useRef, useState } from "react"; -import { - getProfile, - getFeed, - getCollections, - blockUser, - unblockUser, - muteUser, - unmuteUser, - getModerationRelationship, -} from "../../api/client"; -import Card from "../../components/common/Card"; -import RichText from "../../components/common/RichText"; -import MoreMenu from "../../components/common/MoreMenu"; -import { BlueskyIcon } from "../../components/common/Icons"; -import type { MoreMenuItem } from "../../components/common/MoreMenu"; -import ReportModal from "../../components/modals/ReportModal"; +import { useStore } from "@nanostores/react"; +import { clsx } from "clsx"; import { + Bookmark, Edit2, + Eye, + EyeOff, + Flag, + Folder, Github, + Link2, Linkedin, Loader2, - Folder, MessageSquare, PenTool, - Bookmark, - Link2, ShieldBan, - VolumeX, - Flag, ShieldOff, Volume2, - EyeOff, - Eye, + VolumeX, } from "lucide-react"; -import { TangledIcon } from "../../components/common/Icons"; -import type { - UserProfile, - AnnotationItem, - Collection, - ModerationRelationship, - ContentLabel, -} from "../../types"; -import { useStore } from "@nanostores/react"; -import { $user } from "../../store/auth"; +import type React from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Link } from "react-router-dom"; +import { + blockUser, + getCollections, + getFeed, + getModerationRelationship, + getProfile, + muteUser, + unblockUser, + unmuteUser, +} from "../../api/client"; +import Card from "../../components/common/Card"; +import CollectionIcon from "../../components/common/CollectionIcon"; +import { BlueskyIcon, TangledIcon } from "../../components/common/Icons"; +import type { MoreMenuItem } from "../../components/common/MoreMenu"; +import MoreMenu from "../../components/common/MoreMenu"; +import RichText from "../../components/common/RichText"; import EditProfileModal from "../../components/modals/EditProfileModal"; import ExternalLinkModal from "../../components/modals/ExternalLinkModal"; -import CollectionIcon from "../../components/common/CollectionIcon"; -import { $preferences, loadPreferences } from "../../store/preferences"; -import { Link } from "react-router-dom"; -import { clsx } from "clsx"; +import ReportModal from "../../components/modals/ReportModal"; import { Avatar, - Tabs, - EmptyState, Button, + EmptyState, Skeleton, + Tabs, } from "../../components/ui"; +import { $user } from "../../store/auth"; +import { $preferences, loadPreferences } from "../../store/preferences"; +import type { + AnnotationItem, + Collection, + ContentLabel, + ModerationRelationship, + UserProfile, +} from "../../types"; interface ProfileProps { did: string; @@ -63,6 +63,14 @@ interface ProfileProps { type Tab = "all" | "annotations" | "highlights" | "bookmarks" | "collections"; +const motivationMap: Record = { + all: undefined, + annotations: "commenting", + highlights: "highlighting", + bookmarks: "bookmarking", + collections: undefined, +}; + const LIMIT = 50; export default function Profile({ did }: ProfileProps) { @@ -70,13 +78,21 @@ export default function Profile({ did }: ProfileProps) { const [loading, setLoading] = useState(true); const [activeTab, setActiveTab] = useState("all"); - const [all, setAll] = useState([]); - const [annotations, setAnnotations] = useState([]); - const [highlights, setHighlights] = useState([]); - const [bookmarks, setBookmarks] = useState([]); const [collections, setCollections] = useState([]); const [dataLoading, setDataLoading] = useState(false); + const [items, setItems] = useState<{ + all: AnnotationItem[]; + annotations: AnnotationItem[]; + highlights: AnnotationItem[]; + bookmarks: AnnotationItem[]; + }>({ + all: [], + annotations: [], + highlights: [], + bookmarks: [], + }); + const user = useStore($user); const isOwner = user?.did === did; const [showEdit, setShowEdit] = useState(false); @@ -130,10 +146,12 @@ export default function Profile({ did }: ProfileProps) { useEffect(() => { setProfile(null); - setAll([]); - setAnnotations([]); - setHighlights([]); - setBookmarks([]); + setItems({ + all: [], + annotations: [], + highlights: [], + bookmarks: [], + }); setCollections([]); setActiveTab("all"); setLoading(true); @@ -213,63 +231,23 @@ export default function Profile({ did }: ProfileProps) { [activeTab]: { hasMore: false, offset: 0 }, })); try { - if (activeTab === "all") { + if ( + ["all", "annotations", "highlights", "bookmarks"].includes(activeTab) + ) { const res = await getFeed({ creator: resolvedDid, limit: LIMIT, + motivation: motivationMap[activeTab], }); - const items = res.items || []; - setAll(items); - setPagination((prev) => ({ + setItems((prev) => ({ ...prev, - all: { - hasMore: res.hasMore ?? items.length >= LIMIT, - offset: res.fetchedCount ?? items.length, - }, + [activeTab]: res.items, })); - } else if (activeTab === "annotations") { - const res = await getFeed({ - creator: resolvedDid, - motivation: "commenting", - limit: LIMIT, - }); - const items = res.items || []; - setAnnotations(items); - setPagination((prev) => ({ - ...prev, - annotations: { - hasMore: res.hasMore ?? items.length >= LIMIT, - offset: res.fetchedCount ?? items.length, - }, - })); - } else if (activeTab === "highlights") { - const res = await getFeed({ - creator: resolvedDid, - motivation: "highlighting", - limit: LIMIT, - }); - const items = res.items || []; - setHighlights(items); setPagination((prev) => ({ ...prev, - highlights: { - hasMore: res.hasMore ?? items.length >= LIMIT, - offset: res.fetchedCount ?? items.length, - }, - })); - } else if (activeTab === "bookmarks") { - const res = await getFeed({ - creator: resolvedDid, - motivation: "bookmarking", - limit: LIMIT, - }); - const items = res.items || []; - setBookmarks(items); - setPagination((prev) => ({ - ...prev, - bookmarks: { - hasMore: res.hasMore ?? items.length >= LIMIT, - offset: res.fetchedCount ?? items.length, + [activeTab]: { + hasMore: res.hasMore, + offset: res.fetchedCount, }, })); } else if (activeTab === "collections") { @@ -296,13 +274,10 @@ export default function Profile({ did }: ProfileProps) { const capturedTab = activeTab; setLoadingMore(true); setLoadMoreError(null); + + if (capturedTab === "collections") return; + try { - const motivationMap: Record = { - all: undefined, - annotations: "commenting", - highlights: "highlighting", - bookmarks: "bookmarking", - }; const res = await getFeed({ creator: resolvedDid, motivation: motivationMap[capturedTab], @@ -310,16 +285,10 @@ export default function Profile({ did }: ProfileProps) { offset: tabPagination.offset, }); const fetched = res.items || []; - const setters: Record< - string, - React.Dispatch> - > = { - annotations: setAnnotations, - highlights: setHighlights, - bookmarks: setBookmarks, - }; - const setter = setters[capturedTab] ?? setAll; - setter((prev) => [...prev, ...fetched]); + setItems((prev) => ({ + ...prev, + [capturedTab]: [...prev[capturedTab], ...(res.items || [])], + })); setPagination((prev) => ({ ...prev, [capturedTab]: { @@ -379,13 +348,7 @@ export default function Profile({ did }: ProfileProps) { ]; const currentItems = - activeTab === "annotations" - ? annotations - : activeTab === "highlights" - ? highlights - : activeTab === "all" - ? all - : bookmarks; + activeTab !== "collections" ? items[activeTab] : collections; const LABEL_DESCRIPTIONS: Record = { sexual: "Sexual Content", From 9e318ddf4f03c136610a48d517096863c170441a Mon Sep 17 00:00:00 2001 From: Henrique Dias Date: Sun, 15 Feb 2026 09:21:45 +0100 Subject: [PATCH 5/9] fix: small fixes --- web/src/views/profile/Profile.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/web/src/views/profile/Profile.tsx b/web/src/views/profile/Profile.tsx index d8d2f0d..33122d2 100644 --- a/web/src/views/profile/Profile.tsx +++ b/web/src/views/profile/Profile.tsx @@ -284,17 +284,15 @@ export default function Profile({ did }: ProfileProps) { limit: LIMIT, offset: tabPagination.offset, }); - const fetched = res.items || []; setItems((prev) => ({ ...prev, - [capturedTab]: [...prev[capturedTab], ...(res.items || [])], + [capturedTab]: [...prev[capturedTab], ...res.items], })); setPagination((prev) => ({ ...prev, [capturedTab]: { - hasMore: res.hasMore ?? fetched.length >= LIMIT, - offset: - prev[capturedTab].offset + (res.fetchedCount ?? fetched.length), + hasMore: res.hasMore, + offset: prev[capturedTab].offset + res.fetchedCount, }, })); } catch (e) { @@ -347,8 +345,7 @@ export default function Profile({ did }: ProfileProps) { { id: "collections", label: "Collections" }, ]; - const currentItems = - activeTab !== "collections" ? items[activeTab] : collections; + const currentItems = activeTab !== "collections" ? items[activeTab] : []; const LABEL_DESCRIPTIONS: Record = { sexual: "Sexual Content", From d87f6ccc3b5b84d9aa8e051a2d6acb1714660dfa Mon Sep 17 00:00:00 2001 From: Henrique Dias Date: Sun, 15 Feb 2026 09:27:05 +0100 Subject: [PATCH 6/9] fix: check for colls first --- web/src/views/profile/Profile.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/views/profile/Profile.tsx b/web/src/views/profile/Profile.tsx index 33122d2..c82e019 100644 --- a/web/src/views/profile/Profile.tsx +++ b/web/src/views/profile/Profile.tsx @@ -264,6 +264,8 @@ export default function Profile({ did }: ProfileProps) { }, [profile?.did, did, activeTab]); const loadMore = useCallback(async () => { + if (activeTab === "collections") return; + const isHandle = !did.startsWith("did:"); const resolvedDid = isHandle ? profile?.did : did; if (!resolvedDid) return; @@ -275,8 +277,6 @@ export default function Profile({ did }: ProfileProps) { setLoadingMore(true); setLoadMoreError(null); - if (capturedTab === "collections") return; - try { const res = await getFeed({ creator: resolvedDid, From 6184c544f224b694caf5a242d6209c5d0db016f5 Mon Sep 17 00:00:00 2001 From: Henrique Dias Date: Sun, 15 Feb 2026 09:28:19 +0100 Subject: [PATCH 7/9] fix: address chaining issue, lint --- web/src/lib/og.ts | 3 ++- web/src/pages/og-image.ts | 3 ++- web/src/views/core/Feed.tsx | 6 +----- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/web/src/lib/og.ts b/web/src/lib/og.ts index 2a6d204..7088515 100644 --- a/web/src/lib/og.ts +++ b/web/src/lib/og.ts @@ -195,7 +195,8 @@ export async function fetchBookmarkOG(uri: string): Promise { const domain = extractDomain(source); const title = item.title || item.target?.title || "Bookmark on Margin"; - let description = item.description || extractBody(item.body) || item.bodyValue || ""; + let description = + item.description || extractBody(item.body) || item.bodyValue || ""; if (!description) description = "A saved bookmark on Margin"; if (domain) description += ` from ${domain}`; description = truncate(description, 200); diff --git a/web/src/pages/og-image.ts b/web/src/pages/og-image.ts index 9abbd3e..8b342cb 100644 --- a/web/src/pages/og-image.ts +++ b/web/src/pages/og-image.ts @@ -79,7 +79,8 @@ async function fetchRecordData(uri: string): Promise { : ""; const selectorText = item.target?.selector?.exact || item.selector?.exact || ""; - const bodyText = extractBody(item.body) || item.bodyValue || item.text || ""; + const bodyText = + extractBody(item.body) || item.bodyValue || item.text || ""; const motivation = item.motivation || ""; const targetTitle = item.target?.title || item.title || ""; diff --git a/web/src/views/core/Feed.tsx b/web/src/views/core/Feed.tsx index 790114f..8e51a04 100644 --- a/web/src/views/core/Feed.tsx +++ b/web/src/views/core/Feed.tsx @@ -82,11 +82,7 @@ function FeedContent({ }); const fetched = data?.items || []; setItems((prev) => [...prev, ...fetched]); - if (data?.hasMore !== undefined) { - setHasMore(data.hasMore); - } else { - setHasMore(fetched.length >= LIMIT); - } + setHasMore(data.hasMore); setOffset((prev) => prev + data.fetchedCount); } catch (e) { console.error(e); From df3d263bfbc9eac1cf8257892c808b7892ea18cf Mon Sep 17 00:00:00 2001 From: Henrique Dias Date: Sun, 15 Feb 2026 11:13:40 +0100 Subject: [PATCH 8/9] fix: do not overflow tabs on mobile --- web/src/components/ui/Tabs.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/components/ui/Tabs.tsx b/web/src/components/ui/Tabs.tsx index 570fd23..fa04d05 100644 --- a/web/src/components/ui/Tabs.tsx +++ b/web/src/components/ui/Tabs.tsx @@ -1,5 +1,5 @@ -import React from "react"; import { clsx } from "clsx"; +import React from "react"; interface Tab { id: string; @@ -23,7 +23,7 @@ export default function Tabs({ return (
From 9e6cc7a3642552a44ff30c5cd45ad16ba7127964 Mon Sep 17 00:00:00 2001 From: Henrique Dias Date: Sun, 15 Feb 2026 18:23:35 +0100 Subject: [PATCH 9/9] feat: add 'load more' on remaining pages --- web/src/api/client.ts | 2 +- web/src/components/feed/FeedItems.tsx | 158 +++++++++++++++++++++++ web/src/components/feed/MasonryFeed.tsx | 124 ++---------------- web/src/views/core/Feed.tsx | 165 +----------------------- web/src/views/profile/Profile.tsx | 157 ++-------------------- 5 files changed, 192 insertions(+), 414 deletions(-) create mode 100644 web/src/components/feed/FeedItems.tsx diff --git a/web/src/api/client.ts b/web/src/api/client.ts index d898edc..69918ae 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -114,7 +114,7 @@ async function apiRequest( return response; } -interface GetFeedParams { +export interface GetFeedParams { source?: string; type?: string; limit?: number; diff --git a/web/src/components/feed/FeedItems.tsx b/web/src/components/feed/FeedItems.tsx new file mode 100644 index 0000000..b7e6c13 --- /dev/null +++ b/web/src/components/feed/FeedItems.tsx @@ -0,0 +1,158 @@ +import { Clock, Loader2 } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { type GetFeedParams, getFeed } from "../../api/client"; +import Card from "../../components/common/Card"; +import { EmptyState } from "../../components/ui"; +import type { AnnotationItem } from "../../types"; + +const LIMIT = 50; + +export interface FeedItemsProps extends Omit< + GetFeedParams, + "limit" | "offset" +> { + layout: "list" | "mosaic"; + emptyMessage: string; +} + +export default function FeedItems({ + creator, + source, + tag, + type, + motivation, + emptyMessage, + layout, +}: FeedItemsProps) { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(false); + const [offset, setOffset] = useState(0); + + useEffect(() => { + let cancelled = false; + + getFeed({ type, motivation, tag, creator, source, limit: LIMIT, offset: 0 }) + .then((data) => { + if (cancelled) return; + const fetched = data.items; + setItems(fetched); + setHasMore(data.hasMore); + setOffset(data.fetchedCount); + setLoading(false); + }) + .catch((e) => { + if (cancelled) return; + console.error(e); + setItems([]); + setHasMore(false); + setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [type, motivation, tag, creator, source]); + + const loadMore = useCallback(async () => { + setLoadingMore(true); + try { + const data = await getFeed({ + type, + motivation, + tag, + creator, + source, + limit: LIMIT, + offset, + }); + const fetched = data?.items || []; + setItems((prev) => [...prev, ...fetched]); + setHasMore(data.hasMore); + setOffset((prev) => prev + data.fetchedCount); + } catch (e) { + console.error(e); + } finally { + setLoadingMore(false); + } + }, [type, motivation, tag, creator, source, offset]); + + const handleDelete = (uri: string) => { + setItems((prev) => prev.filter((i) => i.uri !== uri)); + }; + + if (loading) { + return ( +
+ +

+ Loading... +

+
+ ); + } + + if (items.length === 0) { + return ( + } + title="Nothing here yet" + message={emptyMessage} + /> + ); + } + + const loadMoreButton = hasMore && ( +
+ +
+ ); + + if (layout === "mosaic") { + return ( + <> +
+ {items.map((item) => ( +
+ +
+ ))} +
+ {loadMoreButton} + + ); + } + + return ( + <> +
+ {items.map((item) => ( + + ))} +
+ {loadMoreButton} + + ); +} diff --git a/web/src/components/feed/MasonryFeed.tsx b/web/src/components/feed/MasonryFeed.tsx index 18ea0a1..2640f17 100644 --- a/web/src/components/feed/MasonryFeed.tsx +++ b/web/src/components/feed/MasonryFeed.tsx @@ -1,13 +1,10 @@ import { useStore as useNanoStore, useStore } from "@nanostores/react"; -import { Loader2 } from "lucide-react"; -import React, { useEffect, useState } from "react"; -import { getFeed } from "../../api/client"; +import { useState } from "react"; import { $user } from "../../store/auth"; import { $feedLayout } from "../../store/feedLayout"; -import type { AnnotationItem } from "../../types"; -import Card from "../common/Card"; -import { EmptyState, Tabs } from "../ui"; +import { Tabs } from "../ui"; import LayoutToggle from "../ui/LayoutToggle"; +import FeedItems from "./FeedItems"; interface MasonryFeedProps { motivation?: string; @@ -16,106 +13,6 @@ interface MasonryFeedProps { title?: string; } -function MasonryContent({ - tab, - motivation, - emptyMessage, - userDid, - layout, -}: { - tab: string; - motivation?: string; - emptyMessage: string; - userDid?: string; - layout: "list" | "mosaic"; -}) { - const [items, setItems] = useState([]); - const [loading, setLoading] = useState(true); - - useEffect(() => { - let cancelled = false; - - const params: { type?: string; motivation?: string; creator?: string } = { - motivation, - }; - - if (tab === "my" && userDid) { - params.creator = userDid; - params.type = "my-feed"; - } else { - params.type = "all"; - } - - getFeed(params) - .then((data) => { - if (cancelled) return; - setItems(data.items); - setLoading(false); - }) - .catch((e) => { - if (cancelled) return; - console.error(e); - setItems([]); - setLoading(false); - }); - - return () => { - cancelled = true; - }; - }, [tab, motivation, userDid]); - - const handleDelete = (uri: string) => { - setItems((prev) => prev.filter((i) => i.uri !== uri)); - }; - - if (loading) { - return ( -
- -
- ); - } - - if (items.length === 0) { - return ( - - ); - } - - if (layout === "list") { - return ( -
- {items.map((item) => ( - - ))} -
- ); - } - - return ( -
- {items.map((item) => ( -
- -
- ))} -
- ); -} - export default function MasonryFeed({ motivation, emptyMessage = "No items found.", @@ -139,6 +36,9 @@ export default function MasonryFeed({ ] : [{ id: "global", label: "Global" }]; + const creator = activeTab === "my" ? user?.did : undefined; + const type = activeTab === "my" ? "my-feed" : "all"; + return (
{title && ( @@ -168,12 +68,16 @@ export default function MasonryFeed({
)} -
diff --git a/web/src/views/core/Feed.tsx b/web/src/views/core/Feed.tsx index 8e51a04..87bc2ce 100644 --- a/web/src/views/core/Feed.tsx +++ b/web/src/views/core/Feed.tsx @@ -1,21 +1,13 @@ import { useStore } from "@nanostores/react"; import { clsx } from "clsx"; -import { - Bookmark, - Clock, - Highlighter, - Loader2, - MessageSquareText, -} from "lucide-react"; -import React, { useCallback, useEffect, useState } from "react"; +import { Bookmark, Highlighter, MessageSquareText } from "lucide-react"; +import { useState } from "react"; import { useSearchParams } from "react-router-dom"; -import { getFeed } from "../../api/client"; -import Card from "../../components/common/Card"; -import { Button, EmptyState, Tabs } from "../../components/ui"; +import FeedItems from "../../components/feed/FeedItems"; +import { Button, Tabs } from "../../components/ui"; import LayoutToggle from "../../components/ui/LayoutToggle"; import { $user } from "../../store/auth"; import { $feedLayout } from "../../store/feedLayout"; -import type { AnnotationItem } from "../../types"; interface FeedProps { initialType?: string; @@ -24,151 +16,6 @@ interface FeedProps { emptyMessage?: string; } -function FeedContent({ - type, - motivation, - emptyMessage, - layout, - tag, -}: { - type: string; - motivation?: string; - emptyMessage: string; - layout: "list" | "mosaic"; - tag?: string; -}) { - const [items, setItems] = useState([]); - const [loading, setLoading] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); - const [hasMore, setHasMore] = useState(false); - const [offset, setOffset] = useState(0); - - const LIMIT = 50; - - useEffect(() => { - let cancelled = false; - - getFeed({ type, motivation, tag, limit: LIMIT, offset: 0 }) - .then((data) => { - if (cancelled) return; - const fetched = data.items; - setItems(fetched); - setHasMore(data.hasMore); - setOffset(data.fetchedCount); - setLoading(false); - }) - .catch((e) => { - if (cancelled) return; - console.error(e); - setItems([]); - setHasMore(false); - setLoading(false); - }); - - return () => { - cancelled = true; - }; - }, [type, motivation, tag]); - - const loadMore = useCallback(async () => { - setLoadingMore(true); - try { - const data = await getFeed({ - type, - motivation, - tag, - limit: LIMIT, - offset, - }); - const fetched = data?.items || []; - setItems((prev) => [...prev, ...fetched]); - setHasMore(data.hasMore); - setOffset((prev) => prev + data.fetchedCount); - } catch (e) { - console.error(e); - } finally { - setLoadingMore(false); - } - }, [type, motivation, tag, offset]); - - const handleDelete = (uri: string) => { - setItems((prev) => prev.filter((i) => i.uri !== uri)); - }; - - if (loading) { - return ( -
- -

- Loading feed... -

-
- ); - } - - if (items.length === 0) { - return ( - } - title="Nothing here yet" - message={emptyMessage} - /> - ); - } - - const loadMoreButton = hasMore && ( -
- -
- ); - - if (layout === "mosaic") { - return ( - <> -
- {items.map((item) => ( -
- -
- ))} -
- {loadMoreButton} - - ); - } - - return ( - <> -
- {items.map((item) => ( - - ))} -
- {loadMoreButton} - - ); -} - export default function Feed({ initialType = "all", motivation, @@ -306,13 +153,13 @@ export default function Feed({
)} - ); diff --git a/web/src/views/profile/Profile.tsx b/web/src/views/profile/Profile.tsx index c82e019..2a924a6 100644 --- a/web/src/views/profile/Profile.tsx +++ b/web/src/views/profile/Profile.tsx @@ -1,7 +1,6 @@ import { useStore } from "@nanostores/react"; import { clsx } from "clsx"; import { - Bookmark, Edit2, Eye, EyeOff, @@ -11,32 +10,28 @@ import { Link2, Linkedin, Loader2, - MessageSquare, - PenTool, ShieldBan, ShieldOff, Volume2, VolumeX, } from "lucide-react"; -import type React from "react"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Link } from "react-router-dom"; import { blockUser, getCollections, - getFeed, getModerationRelationship, getProfile, muteUser, unblockUser, unmuteUser, } from "../../api/client"; -import Card from "../../components/common/Card"; import CollectionIcon from "../../components/common/CollectionIcon"; import { BlueskyIcon, TangledIcon } from "../../components/common/Icons"; import type { MoreMenuItem } from "../../components/common/MoreMenu"; import MoreMenu from "../../components/common/MoreMenu"; import RichText from "../../components/common/RichText"; +import FeedItems from "../../components/feed/FeedItems"; import EditProfileModal from "../../components/modals/EditProfileModal"; import ExternalLinkModal from "../../components/modals/ExternalLinkModal"; import ReportModal from "../../components/modals/ReportModal"; @@ -50,7 +45,6 @@ import { import { $user } from "../../store/auth"; import { $preferences, loadPreferences } from "../../store/preferences"; import type { - AnnotationItem, Collection, ContentLabel, ModerationRelationship, @@ -71,8 +65,6 @@ const motivationMap: Record = { collections: undefined, }; -const LIMIT = 50; - export default function Profile({ did }: ProfileProps) { const [profile, setProfile] = useState(null); const [loading, setLoading] = useState(true); @@ -81,33 +73,11 @@ export default function Profile({ did }: ProfileProps) { const [collections, setCollections] = useState([]); const [dataLoading, setDataLoading] = useState(false); - const [items, setItems] = useState<{ - all: AnnotationItem[]; - annotations: AnnotationItem[]; - highlights: AnnotationItem[]; - bookmarks: AnnotationItem[]; - }>({ - all: [], - annotations: [], - highlights: [], - bookmarks: [], - }); - const user = useStore($user); const isOwner = user?.did === did; const [showEdit, setShowEdit] = useState(false); const [externalLink, setExternalLink] = useState(null); const [showReportModal, setShowReportModal] = useState(false); - const [loadingMore, setLoadingMore] = useState(false); - const [loadMoreError, setLoadMoreError] = useState(null); - const [pagination, setPagination] = useState< - Record - >({ - all: { hasMore: false, offset: 0 }, - annotations: { hasMore: false, offset: 0 }, - highlights: { hasMore: false, offset: 0 }, - bookmarks: { hasMore: false, offset: 0 }, - }); const loadMoreTimerRef = useRef | null>(null); const [modRelation, setModRelation] = useState({ blocking: false, @@ -146,12 +116,6 @@ export default function Profile({ did }: ProfileProps) { useEffect(() => { setProfile(null); - setItems({ - all: [], - annotations: [], - highlights: [], - bookmarks: [], - }); setCollections([]); setActiveTab("all"); setLoading(true); @@ -218,6 +182,9 @@ export default function Profile({ did }: ProfileProps) { }; }, []); + const isHandle = !did.startsWith("did:"); + const resolvedDid = isHandle ? profile?.did : did; + useEffect(() => { const loadTabContent = async () => { const isHandle = !did.startsWith("did:"); @@ -226,31 +193,8 @@ export default function Profile({ did }: ProfileProps) { if (!resolvedDid) return; setDataLoading(true); - setPagination((prev) => ({ - ...prev, - [activeTab]: { hasMore: false, offset: 0 }, - })); try { - if ( - ["all", "annotations", "highlights", "bookmarks"].includes(activeTab) - ) { - const res = await getFeed({ - creator: resolvedDid, - limit: LIMIT, - motivation: motivationMap[activeTab], - }); - setItems((prev) => ({ - ...prev, - [activeTab]: res.items, - })); - setPagination((prev) => ({ - ...prev, - [activeTab]: { - hasMore: res.hasMore, - offset: res.fetchedCount, - }, - })); - } else if (activeTab === "collections") { + if (activeTab === "collections") { const res = await getCollections(resolvedDid); setCollections(res); } @@ -263,49 +207,6 @@ export default function Profile({ did }: ProfileProps) { loadTabContent(); }, [profile?.did, did, activeTab]); - const loadMore = useCallback(async () => { - if (activeTab === "collections") return; - - const isHandle = !did.startsWith("did:"); - const resolvedDid = isHandle ? profile?.did : did; - if (!resolvedDid) return; - - const tabPagination = pagination[activeTab]; - if (!tabPagination) return; - - const capturedTab = activeTab; - setLoadingMore(true); - setLoadMoreError(null); - - try { - const res = await getFeed({ - creator: resolvedDid, - motivation: motivationMap[capturedTab], - limit: LIMIT, - offset: tabPagination.offset, - }); - setItems((prev) => ({ - ...prev, - [capturedTab]: [...prev[capturedTab], ...res.items], - })); - setPagination((prev) => ({ - ...prev, - [capturedTab]: { - hasMore: res.hasMore, - offset: prev[capturedTab].offset + res.fetchedCount, - }, - })); - } catch (e) { - console.error(e); - const msg = e instanceof Error ? e.message : "Something went wrong"; - setLoadMoreError(msg); - if (loadMoreTimerRef.current) clearTimeout(loadMoreTimerRef.current); - loadMoreTimerRef.current = setTimeout(() => setLoadMoreError(null), 5000); - } finally { - setLoadingMore(false); - } - }, [did, profile?.did, activeTab, pagination]); - if (loading) { return (
@@ -345,8 +246,6 @@ export default function Profile({ did }: ProfileProps) { { id: "collections", label: "Collections" }, ]; - const currentItems = activeTab !== "collections" ? items[activeTab] : []; - const LABEL_DESCRIPTIONS: Record = { sexual: "Sexual Content", nudity: "Nudity", @@ -728,50 +627,20 @@ export default function Profile({ did }: ProfileProps) { ))}
) - ) : currentItems.length > 0 ? ( -
- {currentItems.map((item) => ( - - ))} -
) : ( - - ) : activeTab === "highlights" ? ( - - ) : ( - - ) - } - message={ + )} - - {activeTab !== "collections" && pagination[activeTab]?.hasMore && ( -
- {loadMoreError && ( -

- Failed to load more: {loadMoreError} -

- )} - -
- )} {showEdit && profile && (