diff --git a/package-lock.json b/package-lock.json
index 65a85d2a..08cf16a1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,7 +12,7 @@
"@atproto/api": "^0.16.9",
"@atproto/common": "^0.4.8",
"@atproto/identity": "^0.4.6",
- "@atproto/lexicon": "^0.5.1",
+ "@atproto/lexicon": "^0.6.1",
"@atproto/oauth-client-node": "^0.3.8",
"@atproto/sync": "^0.1.34",
"@atproto/syntax": "^0.3.3",
@@ -236,10 +236,13 @@
}
},
"node_modules/@atproto/api/node_modules/@atproto/syntax": {
- "version": "0.4.1",
- "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.1.tgz",
- "integrity": "sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw==",
- "license": "MIT"
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.3.tgz",
+ "integrity": "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.8.1"
+ }
},
"node_modules/@atproto/api/node_modules/multiformats": {
"version": "9.9.0",
@@ -265,16 +268,54 @@
}
},
"node_modules/@atproto/common-web": {
- "version": "0.4.10",
- "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.10.tgz",
- "integrity": "sha512-TLDZSgSKzT8ZgOrBrTGK87J1CXve9TEuY6NVVUBRkOMzRRtQzpFb9/ih5WVS/hnaWVvE30CfuyaetRoma+WKNw==",
+ "version": "0.4.15",
+ "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.15.tgz",
+ "integrity": "sha512-A4l9gyqUNez8CjZp/Trypz/D3WIQsNj8dN05WR6+RoBbvwc9JhWjKPrm+WoVYc/F16RPdXHLkE3BEJlGIyYIiA==",
"license": "MIT",
"dependencies": {
- "@atproto/lex-data": "0.0.6",
- "@atproto/lex-json": "0.0.6",
+ "@atproto/lex-data": "^0.0.10",
+ "@atproto/lex-json": "^0.0.10",
+ "@atproto/syntax": "^0.4.3",
"zod": "^3.23.8"
}
},
+ "node_modules/@atproto/common-web/node_modules/@atproto/lex-data": {
+ "version": "0.0.10",
+ "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.10.tgz",
+ "integrity": "sha512-FDbcy8VIUVzS9Mi1F8SMxbkL/jOUmRRpqbeM1xB4A0fMxeZJTxf6naAbFt4gYF3quu/+TPJGmio6/7cav05FqQ==",
+ "license": "MIT",
+ "dependencies": {
+ "multiformats": "^9.9.0",
+ "tslib": "^2.8.1",
+ "uint8arrays": "3.0.0",
+ "unicode-segmenter": "^0.14.0"
+ }
+ },
+ "node_modules/@atproto/common-web/node_modules/@atproto/lex-json": {
+ "version": "0.0.10",
+ "resolved": "https://registry.npmjs.org/@atproto/lex-json/-/lex-json-0.0.10.tgz",
+ "integrity": "sha512-L6MyXU17C5ODMeob8myQ2F3xvgCTvJUtM0ew8qSApnN//iDasB/FDGgd7ty4UVNmx4NQ/rtvz8xV94YpG6kneQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@atproto/lex-data": "^0.0.10",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@atproto/common-web/node_modules/@atproto/syntax": {
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.3.tgz",
+ "integrity": "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@atproto/common-web/node_modules/multiformats": {
+ "version": "9.9.0",
+ "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz",
+ "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==",
+ "license": "(Apache-2.0 AND MIT)"
+ },
"node_modules/@atproto/common/node_modules/multiformats": {
"version": "9.9.0",
"resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz",
@@ -464,11 +505,14 @@
}
},
"node_modules/@atproto/lex-cli/node_modules/@atproto/syntax": {
- "version": "0.4.1",
- "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.1.tgz",
- "integrity": "sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw==",
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.3.tgz",
+ "integrity": "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.8.1"
+ }
},
"node_modules/@atproto/lex-client": {
"version": "0.0.7",
@@ -614,23 +658,26 @@
"license": "MIT"
},
"node_modules/@atproto/lexicon": {
- "version": "0.5.1",
- "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.5.1.tgz",
- "integrity": "sha512-y8AEtYmfgVl4fqFxqXAeGvhesiGkxiy3CWoJIfsFDDdTlZUC8DFnZrYhcqkIop3OlCkkljvpSJi1hbeC1tbi8A==",
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.6.1.tgz",
+ "integrity": "sha512-/vI1kVlY50Si+5MXpvOucelnYwb0UJ6Qto5mCp+7Q5C+Jtp+SoSykAPVvjVtTnQUH2vrKOFOwpb3C375vSKzXw==",
"license": "MIT",
"dependencies": {
- "@atproto/common-web": "^0.4.3",
- "@atproto/syntax": "^0.4.1",
+ "@atproto/common-web": "^0.4.13",
+ "@atproto/syntax": "^0.4.3",
"iso-datestring-validator": "^2.2.2",
"multiformats": "^9.9.0",
"zod": "^3.23.8"
}
},
"node_modules/@atproto/lexicon/node_modules/@atproto/syntax": {
- "version": "0.4.1",
- "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.1.tgz",
- "integrity": "sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw==",
- "license": "MIT"
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.3.tgz",
+ "integrity": "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.8.1"
+ }
},
"node_modules/@atproto/lexicon/node_modules/multiformats": {
"version": "9.9.0",
@@ -715,14 +762,14 @@
}
},
"node_modules/@atproto/repo/node_modules/@atproto/common": {
- "version": "0.5.6",
- "resolved": "https://registry.npmjs.org/@atproto/common/-/common-0.5.6.tgz",
- "integrity": "sha512-rbWoZwHQNP8jcwjCREVecchw8aaoM5A1NCONyb9PVDWOJLRLCzojYMeIS8IbFqXo6NyIByOGddupADkkLeVBGQ==",
+ "version": "0.5.10",
+ "resolved": "https://registry.npmjs.org/@atproto/common/-/common-0.5.10.tgz",
+ "integrity": "sha512-A1+4W3JmjZIgmtJFLJBAaoVruZhRL0ANtyjZ91aJR4rjHcZuaQ+v4IFR1UcE6yyTATacLdBk6ADy8OtxXzq14g==",
"license": "MIT",
"dependencies": {
- "@atproto/common-web": "^0.4.10",
- "@atproto/lex-cbor": "0.0.6",
- "@atproto/lex-data": "0.0.6",
+ "@atproto/common-web": "^0.4.15",
+ "@atproto/lex-cbor": "^0.0.10",
+ "@atproto/lex-data": "^0.0.10",
"iso-datestring-validator": "^2.2.2",
"multiformats": "^9.9.0",
"pino": "^8.21.0"
@@ -731,24 +778,27 @@
"node": ">=18.7.0"
}
},
- "node_modules/@atproto/repo/node_modules/@atproto/lexicon": {
- "version": "0.6.0",
- "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.6.0.tgz",
- "integrity": "sha512-5veb8aD+J5M0qszLJ+73KSFsFrJBgAY/nM1TSAJvGY7fNc9ZAT+PSUlmIyrdye9YznAZ07yktalls/TwNV7cHQ==",
+ "node_modules/@atproto/repo/node_modules/@atproto/lex-cbor": {
+ "version": "0.0.10",
+ "resolved": "https://registry.npmjs.org/@atproto/lex-cbor/-/lex-cbor-0.0.10.tgz",
+ "integrity": "sha512-5RtV90iIhRNCXXvvETd3KlraV8XGAAAgOmiszUb+l8GySDU/sGk7AlVvArFfXnj/S/GXJq8DP6IaUxCw/sPASA==",
"license": "MIT",
"dependencies": {
- "@atproto/common-web": "^0.4.7",
- "@atproto/syntax": "^0.4.2",
- "iso-datestring-validator": "^2.2.2",
- "multiformats": "^9.9.0",
- "zod": "^3.23.8"
+ "@atproto/lex-data": "^0.0.10",
+ "tslib": "^2.8.1"
}
},
- "node_modules/@atproto/repo/node_modules/@atproto/syntax": {
- "version": "0.4.2",
- "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.2.tgz",
- "integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==",
- "license": "MIT"
+ "node_modules/@atproto/repo/node_modules/@atproto/lex-data": {
+ "version": "0.0.10",
+ "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.10.tgz",
+ "integrity": "sha512-FDbcy8VIUVzS9Mi1F8SMxbkL/jOUmRRpqbeM1xB4A0fMxeZJTxf6naAbFt4gYF3quu/+TPJGmio6/7cav05FqQ==",
+ "license": "MIT",
+ "dependencies": {
+ "multiformats": "^9.9.0",
+ "tslib": "^2.8.1",
+ "uint8arrays": "3.0.0",
+ "unicode-segmenter": "^0.14.0"
+ }
},
"node_modules/@atproto/repo/node_modules/multiformats": {
"version": "9.9.0",
@@ -777,10 +827,13 @@
}
},
"node_modules/@atproto/sync/node_modules/@atproto/syntax": {
- "version": "0.4.1",
- "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.1.tgz",
- "integrity": "sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw==",
- "license": "MIT"
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.3.tgz",
+ "integrity": "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.8.1"
+ }
},
"node_modules/@atproto/sync/node_modules/multiformats": {
"version": "9.9.0",
@@ -812,14 +865,14 @@
}
},
"node_modules/@atproto/tap/node_modules/@atproto/common": {
- "version": "0.5.6",
- "resolved": "https://registry.npmjs.org/@atproto/common/-/common-0.5.6.tgz",
- "integrity": "sha512-rbWoZwHQNP8jcwjCREVecchw8aaoM5A1NCONyb9PVDWOJLRLCzojYMeIS8IbFqXo6NyIByOGddupADkkLeVBGQ==",
+ "version": "0.5.10",
+ "resolved": "https://registry.npmjs.org/@atproto/common/-/common-0.5.10.tgz",
+ "integrity": "sha512-A1+4W3JmjZIgmtJFLJBAaoVruZhRL0ANtyjZ91aJR4rjHcZuaQ+v4IFR1UcE6yyTATacLdBk6ADy8OtxXzq14g==",
"license": "MIT",
"dependencies": {
- "@atproto/common-web": "^0.4.10",
- "@atproto/lex-cbor": "0.0.6",
- "@atproto/lex-data": "0.0.6",
+ "@atproto/common-web": "^0.4.15",
+ "@atproto/lex-cbor": "^0.0.10",
+ "@atproto/lex-data": "^0.0.10",
"iso-datestring-validator": "^2.2.2",
"multiformats": "^9.9.0",
"pino": "^8.21.0"
@@ -828,11 +881,36 @@
"node": ">=18.7.0"
}
},
+ "node_modules/@atproto/tap/node_modules/@atproto/lex-cbor": {
+ "version": "0.0.10",
+ "resolved": "https://registry.npmjs.org/@atproto/lex-cbor/-/lex-cbor-0.0.10.tgz",
+ "integrity": "sha512-5RtV90iIhRNCXXvvETd3KlraV8XGAAAgOmiszUb+l8GySDU/sGk7AlVvArFfXnj/S/GXJq8DP6IaUxCw/sPASA==",
+ "license": "MIT",
+ "dependencies": {
+ "@atproto/lex-data": "^0.0.10",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@atproto/tap/node_modules/@atproto/lex-data": {
+ "version": "0.0.10",
+ "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.10.tgz",
+ "integrity": "sha512-FDbcy8VIUVzS9Mi1F8SMxbkL/jOUmRRpqbeM1xB4A0fMxeZJTxf6naAbFt4gYF3quu/+TPJGmio6/7cav05FqQ==",
+ "license": "MIT",
+ "dependencies": {
+ "multiformats": "^9.9.0",
+ "tslib": "^2.8.1",
+ "uint8arrays": "3.0.0",
+ "unicode-segmenter": "^0.14.0"
+ }
+ },
"node_modules/@atproto/tap/node_modules/@atproto/syntax": {
- "version": "0.4.2",
- "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.2.tgz",
- "integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==",
- "license": "MIT"
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.3.tgz",
+ "integrity": "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.8.1"
+ }
},
"node_modules/@atproto/tap/node_modules/multiformats": {
"version": "9.9.0",
@@ -854,14 +932,14 @@
}
},
"node_modules/@atproto/ws-client/node_modules/@atproto/common": {
- "version": "0.5.6",
- "resolved": "https://registry.npmjs.org/@atproto/common/-/common-0.5.6.tgz",
- "integrity": "sha512-rbWoZwHQNP8jcwjCREVecchw8aaoM5A1NCONyb9PVDWOJLRLCzojYMeIS8IbFqXo6NyIByOGddupADkkLeVBGQ==",
+ "version": "0.5.10",
+ "resolved": "https://registry.npmjs.org/@atproto/common/-/common-0.5.10.tgz",
+ "integrity": "sha512-A1+4W3JmjZIgmtJFLJBAaoVruZhRL0ANtyjZ91aJR4rjHcZuaQ+v4IFR1UcE6yyTATacLdBk6ADy8OtxXzq14g==",
"license": "MIT",
"dependencies": {
- "@atproto/common-web": "^0.4.10",
- "@atproto/lex-cbor": "0.0.6",
- "@atproto/lex-data": "0.0.6",
+ "@atproto/common-web": "^0.4.15",
+ "@atproto/lex-cbor": "^0.0.10",
+ "@atproto/lex-data": "^0.0.10",
"iso-datestring-validator": "^2.2.2",
"multiformats": "^9.9.0",
"pino": "^8.21.0"
@@ -870,6 +948,28 @@
"node": ">=18.7.0"
}
},
+ "node_modules/@atproto/ws-client/node_modules/@atproto/lex-cbor": {
+ "version": "0.0.10",
+ "resolved": "https://registry.npmjs.org/@atproto/lex-cbor/-/lex-cbor-0.0.10.tgz",
+ "integrity": "sha512-5RtV90iIhRNCXXvvETd3KlraV8XGAAAgOmiszUb+l8GySDU/sGk7AlVvArFfXnj/S/GXJq8DP6IaUxCw/sPASA==",
+ "license": "MIT",
+ "dependencies": {
+ "@atproto/lex-data": "^0.0.10",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@atproto/ws-client/node_modules/@atproto/lex-data": {
+ "version": "0.0.10",
+ "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.10.tgz",
+ "integrity": "sha512-FDbcy8VIUVzS9Mi1F8SMxbkL/jOUmRRpqbeM1xB4A0fMxeZJTxf6naAbFt4gYF3quu/+TPJGmio6/7cav05FqQ==",
+ "license": "MIT",
+ "dependencies": {
+ "multiformats": "^9.9.0",
+ "tslib": "^2.8.1",
+ "uint8arrays": "3.0.0",
+ "unicode-segmenter": "^0.14.0"
+ }
+ },
"node_modules/@atproto/ws-client/node_modules/multiformats": {
"version": "9.9.0",
"resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz",
@@ -1714,13 +1814,14 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
- "version": "0.25.4",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz",
- "integrity": "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"cpu": [
"ppc64"
],
"dev": true,
+ "license": "MIT",
"optional": true,
"os": [
"aix"
@@ -1730,13 +1831,14 @@
}
},
"node_modules/@esbuild/android-arm": {
- "version": "0.25.4",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.4.tgz",
- "integrity": "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"cpu": [
"arm"
],
"dev": true,
+ "license": "MIT",
"optional": true,
"os": [
"android"
@@ -1746,13 +1848,14 @@
}
},
"node_modules/@esbuild/android-arm64": {
- "version": "0.25.4",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz",
- "integrity": "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"cpu": [
"arm64"
],
"dev": true,
+ "license": "MIT",
"optional": true,
"os": [
"android"
@@ -1762,13 +1865,14 @@
}
},
"node_modules/@esbuild/android-x64": {
- "version": "0.25.4",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.4.tgz",
- "integrity": "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"cpu": [
"x64"
],
"dev": true,
+ "license": "MIT",
"optional": true,
"os": [
"android"
@@ -1777,14 +1881,32 @@
"node": ">=18"
}
},
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@esbuild/darwin-x64": {
- "version": "0.25.4",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz",
- "integrity": "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"cpu": [
"x64"
],
"dev": true,
+ "license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -1794,13 +1916,14 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
- "version": "0.25.4",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz",
- "integrity": "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"cpu": [
"arm64"
],
"dev": true,
+ "license": "MIT",
"optional": true,
"os": [
"freebsd"
@@ -1810,13 +1933,14 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
- "version": "0.25.4",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz",
- "integrity": "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"cpu": [
"x64"
],
"dev": true,
+ "license": "MIT",
"optional": true,
"os": [
"freebsd"
@@ -1826,13 +1950,14 @@
}
},
"node_modules/@esbuild/linux-arm": {
- "version": "0.25.4",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz",
- "integrity": "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"cpu": [
"arm"
],
"dev": true,
+ "license": "MIT",
"optional": true,
"os": [
"linux"
@@ -1842,13 +1967,14 @@
}
},
"node_modules/@esbuild/linux-arm64": {
- "version": "0.25.4",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz",
- "integrity": "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"cpu": [
"arm64"
],
"dev": true,
+ "license": "MIT",
"optional": true,
"os": [
"linux"
@@ -1858,13 +1984,14 @@
}
},
"node_modules/@esbuild/linux-ia32": {
- "version": "0.25.4",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz",
- "integrity": "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"cpu": [
"ia32"
],
"dev": true,
+ "license": "MIT",
"optional": true,
"os": [
"linux"
@@ -1874,13 +2001,14 @@
}
},
"node_modules/@esbuild/linux-loong64": {
- "version": "0.25.4",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz",
- "integrity": "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"cpu": [
"loong64"
],
"dev": true,
+ "license": "MIT",
"optional": true,
"os": [
"linux"
@@ -1890,13 +2018,14 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
- "version": "0.25.4",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz",
- "integrity": "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"cpu": [
"mips64el"
],
"dev": true,
+ "license": "MIT",
"optional": true,
"os": [
"linux"
@@ -1906,13 +2035,14 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
- "version": "0.25.4",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz",
- "integrity": "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"cpu": [
"ppc64"
],
"dev": true,
+ "license": "MIT",
"optional": true,
"os": [
"linux"
@@ -1922,13 +2052,14 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
- "version": "0.25.4",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz",
- "integrity": "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"cpu": [
"riscv64"
],
"dev": true,
+ "license": "MIT",
"optional": true,
"os": [
"linux"
@@ -1938,13 +2069,14 @@
}
},
"node_modules/@esbuild/linux-s390x": {
- "version": "0.25.4",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz",
- "integrity": "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"cpu": [
"s390x"
],
"dev": true,
+ "license": "MIT",
"optional": true,
"os": [
"linux"
@@ -1954,9 +2086,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
- "version": "0.25.4",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz",
- "integrity": "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"cpu": [
"x64"
],
@@ -1971,13 +2103,14 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
- "version": "0.25.4",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz",
- "integrity": "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"cpu": [
"arm64"
],
"dev": true,
+ "license": "MIT",
"optional": true,
"os": [
"netbsd"
@@ -1987,13 +2120,14 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
- "version": "0.25.4",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz",
- "integrity": "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"cpu": [
"x64"
],
"dev": true,
+ "license": "MIT",
"optional": true,
"os": [
"netbsd"
@@ -2003,13 +2137,14 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
- "version": "0.25.4",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz",
- "integrity": "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"cpu": [
"arm64"
],
"dev": true,
+ "license": "MIT",
"optional": true,
"os": [
"openbsd"
@@ -2019,13 +2154,14 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
- "version": "0.25.4",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz",
- "integrity": "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"cpu": [
"x64"
],
"dev": true,
+ "license": "MIT",
"optional": true,
"os": [
"openbsd"
@@ -2034,14 +2170,32 @@
"node": ">=18"
}
},
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@esbuild/sunos-x64": {
- "version": "0.25.4",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz",
- "integrity": "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"cpu": [
"x64"
],
"dev": true,
+ "license": "MIT",
"optional": true,
"os": [
"sunos"
@@ -2051,13 +2205,14 @@
}
},
"node_modules/@esbuild/win32-arm64": {
- "version": "0.25.4",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz",
- "integrity": "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"cpu": [
"arm64"
],
"dev": true,
+ "license": "MIT",
"optional": true,
"os": [
"win32"
@@ -2067,13 +2222,14 @@
}
},
"node_modules/@esbuild/win32-ia32": {
- "version": "0.25.4",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz",
- "integrity": "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"cpu": [
"ia32"
],
"dev": true,
+ "license": "MIT",
"optional": true,
"os": [
"win32"
@@ -2083,13 +2239,14 @@
}
},
"node_modules/@esbuild/win32-x64": {
- "version": "0.25.4",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz",
- "integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"cpu": [
"x64"
],
"dev": true,
+ "license": "MIT",
"optional": true,
"os": [
"win32"
@@ -3014,11 +3171,12 @@
}
},
"node_modules/@mdx-js/loader/node_modules/source-map": {
- "version": "0.7.4",
- "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
- "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==",
+ "version": "0.7.6",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz",
+ "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==",
+ "license": "BSD-3-Clause",
"engines": {
- "node": ">= 8"
+ "node": ">= 12"
}
},
"node_modules/@mdx-js/mdx": {
@@ -3065,11 +3223,12 @@
}
},
"node_modules/@mdx-js/mdx/node_modules/source-map": {
- "version": "0.7.4",
- "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
- "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==",
+ "version": "0.7.6",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz",
+ "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==",
+ "license": "BSD-3-Clause",
"engines": {
- "node": ">= 8"
+ "node": ">= 12"
}
},
"node_modules/@mdx-js/react": {
@@ -3160,11 +3319,12 @@
}
},
"node_modules/@next/mdx/node_modules/source-map": {
- "version": "0.7.4",
- "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
- "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==",
+ "version": "0.7.6",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz",
+ "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==",
+ "license": "BSD-3-Clause",
"engines": {
- "node": ">= 8"
+ "node": ">= 12"
}
},
"node_modules/@next/swc-darwin-arm64": {
@@ -7549,9 +7709,9 @@
}
},
"node_modules/@tailwindcss/node/node_modules/magic-string": {
- "version": "0.30.19",
- "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",
- "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==",
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7805,11 +7965,11 @@
}
},
"node_modules/@tailwindcss/oxide/node_modules/tar": {
- "version": "7.5.1",
- "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.1.tgz",
- "integrity": "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==",
+ "version": "7.5.7",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz",
+ "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==",
"dev": true,
- "license": "ISC",
+ "license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
"chownr": "^3.0.0",
@@ -8142,9 +8302,10 @@
}
},
"node_modules/@types/unist": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz",
- "integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ=="
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
+ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
+ "license": "MIT"
},
"node_modules/@types/uuid": {
"version": "10.0.0",
@@ -8563,12 +8724,10 @@
}
},
"node_modules/agent-base": {
- "version": "7.1.1",
- "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz",
- "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==",
- "dependencies": {
- "debug": "^4.3.4"
- },
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "license": "MIT",
"engines": {
"node": ">= 14"
}
@@ -9047,26 +9206,12 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
- "node_modules/body-parser/node_modules/qs": {
- "version": "6.13.0",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
- "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
- "license": "BSD-3-Clause",
- "dependencies": {
- "side-channel": "^1.0.6"
- },
- "engines": {
- "node": ">=0.6"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
"node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -9748,9 +9893,9 @@
"integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug=="
},
"node_modules/debug": {
- "version": "4.4.1",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
- "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -9904,6 +10049,19 @@
"node": "*"
}
},
+ "node_modules/doctrine": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
+ "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "esutils": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/dreamopt": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/dreamopt/-/dreamopt-0.8.0.tgz",
@@ -10800,9 +10958,9 @@
}
},
"node_modules/esbuild": {
- "version": "0.25.4",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz",
- "integrity": "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -10813,31 +10971,32 @@
"node": ">=18"
},
"optionalDependencies": {
- "@esbuild/aix-ppc64": "0.25.4",
- "@esbuild/android-arm": "0.25.4",
- "@esbuild/android-arm64": "0.25.4",
- "@esbuild/android-x64": "0.25.4",
- "@esbuild/darwin-arm64": "0.25.4",
- "@esbuild/darwin-x64": "0.25.4",
- "@esbuild/freebsd-arm64": "0.25.4",
- "@esbuild/freebsd-x64": "0.25.4",
- "@esbuild/linux-arm": "0.25.4",
- "@esbuild/linux-arm64": "0.25.4",
- "@esbuild/linux-ia32": "0.25.4",
- "@esbuild/linux-loong64": "0.25.4",
- "@esbuild/linux-mips64el": "0.25.4",
- "@esbuild/linux-ppc64": "0.25.4",
- "@esbuild/linux-riscv64": "0.25.4",
- "@esbuild/linux-s390x": "0.25.4",
- "@esbuild/linux-x64": "0.25.4",
- "@esbuild/netbsd-arm64": "0.25.4",
- "@esbuild/netbsd-x64": "0.25.4",
- "@esbuild/openbsd-arm64": "0.25.4",
- "@esbuild/openbsd-x64": "0.25.4",
- "@esbuild/sunos-x64": "0.25.4",
- "@esbuild/win32-arm64": "0.25.4",
- "@esbuild/win32-ia32": "0.25.4",
- "@esbuild/win32-x64": "0.25.4"
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
}
},
"node_modules/esbuild-register": {
@@ -10852,22 +11011,6 @@
"esbuild": ">=0.12 <1"
}
},
- "node_modules/esbuild/node_modules/@esbuild/darwin-arm64": {
- "version": "0.25.4",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz",
- "integrity": "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=18"
- }
- },
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -11103,18 +11246,6 @@
"ms": "^2.1.1"
}
},
- "node_modules/eslint-plugin-import/node_modules/doctrine": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
- "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==",
- "dev": true,
- "dependencies": {
- "esutils": "^2.0.2"
- },
- "engines": {
- "node": ">=0.10.0"
- }
- },
"node_modules/eslint-plugin-import/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -11206,15 +11337,6 @@
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
}
},
- "node_modules/eslint-plugin-react-hooks/node_modules/zod": {
- "version": "4.1.12",
- "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",
- "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
- "dev": true,
- "funding": {
- "url": "https://github.com/sponsors/colinhacks"
- }
- },
"node_modules/eslint-plugin-react-hooks/node_modules/zod-validation-error": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
@@ -11227,19 +11349,6 @@
"zod": "^3.25.0 || ^4.0.0"
}
},
- "node_modules/eslint-plugin-react/node_modules/doctrine": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
- "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "esutils": "^2.0.2"
- },
- "engines": {
- "node": ">=0.10.0"
- }
- },
"node_modules/eslint-plugin-react/node_modules/resolve": {
"version": "2.0.0-next.5",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
@@ -11433,11 +11542,12 @@
}
},
"node_modules/estree-util-to-js/node_modules/source-map": {
- "version": "0.7.4",
- "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
- "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==",
+ "version": "0.7.6",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz",
+ "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==",
+ "license": "BSD-3-Clause",
"engines": {
- "node": ">= 8"
+ "node": ">= 12"
}
},
"node_modules/estree-util-visit": {
@@ -11599,21 +11709,6 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
- "node_modules/express/node_modules/qs": {
- "version": "6.13.0",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
- "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
- "license": "BSD-3-Clause",
- "dependencies": {
- "side-channel": "^1.0.6"
- },
- "engines": {
- "node": ">=0.6"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
"node_modules/ext": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz",
@@ -11635,16 +11730,17 @@
"dev": true
},
"node_modules/fast-glob": {
- "version": "3.3.2",
- "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
- "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==",
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
"glob-parent": "^5.1.2",
"merge2": "^1.3.0",
- "micromatch": "^4.0.4"
+ "micromatch": "^4.0.8"
},
"engines": {
"node": ">=8.6.0"
@@ -11928,6 +12024,21 @@
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"dev": true
},
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -12178,10 +12289,11 @@
"dev": true
},
"node_modules/glob/node_modules/brace-expansion": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
- "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
@@ -12721,11 +12833,12 @@
}
},
"node_modules/https-proxy-agent": {
- "version": "7.0.4",
- "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz",
- "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==",
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "license": "MIT",
"dependencies": {
- "agent-base": "^7.0.2",
+ "agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
@@ -12765,10 +12878,11 @@
"license": "BSD-3-Clause"
},
"node_modules/ignore": {
- "version": "5.3.1",
- "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz",
- "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==",
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">= 4"
}
@@ -12837,9 +12951,10 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/inline-style-parser": {
- "version": "0.2.4",
- "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz",
- "integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q=="
+ "version": "0.2.7",
+ "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
+ "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
+ "license": "MIT"
},
"node_modules/inngest": {
"version": "3.40.1",
@@ -13000,9 +13115,9 @@
}
},
"node_modules/ipaddr.js": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz",
- "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==",
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz",
+ "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==",
"license": "MIT",
"engines": {
"node": ">= 10"
@@ -14173,12 +14288,10 @@
}
},
"node_modules/lru-cache": {
- "version": "10.2.2",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.2.tgz",
- "integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==",
- "engines": {
- "node": "14 || >=16.14"
- }
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "license": "ISC"
},
"node_modules/lru-queue": {
"version": "0.1.0",
@@ -15282,10 +15395,11 @@
]
},
"node_modules/micromatch": {
- "version": "4.0.7",
- "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz",
- "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==",
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
@@ -15352,10 +15466,11 @@
}
},
"node_modules/miniflare/node_modules/undici": {
- "version": "5.28.4",
- "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz",
- "integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==",
+ "version": "5.29.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz",
+ "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@fastify/busboy": "^2.0.0"
},
@@ -15449,9 +15564,9 @@
"license": "MIT"
},
"node_modules/multiformats": {
- "version": "13.3.2",
- "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.3.2.tgz",
- "integrity": "sha512-qbB0CQDt3QKfiAzZ5ZYjLFOs+zW43vA4uyM8g27PeEuXZybUOFyjrVdP93HPBHMoglibwfkdVwbzfUq8qGcH6g==",
+ "version": "13.4.2",
+ "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.4.2.tgz",
+ "integrity": "sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==",
"license": "Apache-2.0 OR MIT"
},
"node_modules/mustache": {
@@ -16037,10 +16152,11 @@
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
},
"node_modules/path-to-regexp": {
- "version": "6.2.2",
- "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.2.tgz",
- "integrity": "sha512-GQX3SSMokngb36+whdpRXE+3f9V8UzyAorlYvOGx87ufGHehNTn5lCxrKtLyZ4Yl/wEKnNnr98ZzOwwDZV5ogw==",
- "dev": true
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
+ "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
+ "dev": true,
+ "license": "MIT"
},
"node_modules/pg": {
"version": "8.16.3",
@@ -16510,18 +16626,6 @@
"prosemirror-view": "^1.37.2"
}
},
- "node_modules/prosemirror-tables/node_modules/prosemirror-view": {
- "version": "1.39.2",
- "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.39.2.tgz",
- "integrity": "sha512-BmOkml0QWNob165gyUxXi5K5CVUgVPpqMEAAml/qzgKn9boLUWVPzQ6LtzXw8Cn1GtRQX4ELumPxqtLTDaAKtg==",
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "prosemirror-model": "^1.20.0",
- "prosemirror-state": "^1.0.0",
- "prosemirror-transform": "^1.1.0"
- }
- },
"node_modules/prosemirror-trailing-node": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz",
@@ -16548,9 +16652,9 @@
}
},
"node_modules/prosemirror-view": {
- "version": "1.37.1",
- "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.37.1.tgz",
- "integrity": "sha512-MEAnjOdXU1InxEmhjgmEzQAikaS6lF3hD64MveTPpjOGNTl87iRLA1HupC/DEV6YuK7m4Q9DHFNTjwIVtqz5NA==",
+ "version": "1.41.5",
+ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.5.tgz",
+ "integrity": "sha512-UDQbIPnDrjE8tqUBbPmCOZgtd75htE6W3r0JCmY9bL6W1iemDM37MZEKC49d+tdQ0v/CKx4gjxLoLsfkD2NiZA==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.20.0",
@@ -16629,9 +16733,9 @@
}
},
"node_modules/qs": {
- "version": "6.13.1",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.1.tgz",
- "integrity": "sha512-EJPeIn0CYrGu+hli1xilKAPXODtJ12T0sP63Ijx2/khC2JtuaN3JyNIpvmnkmaEtha9ocbG4A4cMcr+TvqvwQg==",
+ "version": "6.13.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
+ "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.0.6"
@@ -17371,17 +17475,21 @@
}
},
"node_modules/resolve": {
- "version": "1.22.8",
- "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",
- "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==",
+ "version": "1.22.11",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
+ "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
+ "license": "MIT",
"dependencies": {
- "is-core-module": "^2.13.0",
+ "is-core-module": "^2.16.1",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
},
"bin": {
"resolve": "bin/resolve"
},
+ "engines": {
+ "node": ">= 0.4"
+ },
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
@@ -17604,9 +17712,9 @@
}
},
"node_modules/semver": {
- "version": "7.7.2",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
- "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -18235,11 +18343,12 @@
}
},
"node_modules/style-to-object": {
- "version": "1.0.8",
- "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.8.tgz",
- "integrity": "sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g==",
+ "version": "1.0.14",
+ "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
+ "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==",
+ "license": "MIT",
"dependencies": {
- "inline-style-parser": "0.2.4"
+ "inline-style-parser": "0.2.7"
}
},
"node_modules/styled-jsx": {
@@ -18805,9 +18914,9 @@
}
},
"node_modules/undici": {
- "version": "6.21.3",
- "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz",
- "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==",
+ "version": "6.23.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
+ "integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
"license": "MIT",
"engines": {
"node": ">=18.17"
@@ -19805,9 +19914,10 @@
}
},
"node_modules/ws": {
- "version": "8.17.0",
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.0.tgz",
- "integrity": "sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow==",
+ "version": "8.19.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
+ "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
+ "license": "MIT",
"engines": {
"node": ">=10.0.0"
},
@@ -19995,9 +20105,10 @@
}
},
"node_modules/zod": {
- "version": "3.23.8",
- "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz",
- "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==",
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/package.json b/package.json
index e3f13bed..8c589ee7 100644
--- a/package.json
+++ b/package.json
@@ -23,7 +23,7 @@
"@atproto/api": "^0.16.9",
"@atproto/common": "^0.4.8",
"@atproto/identity": "^0.4.6",
- "@atproto/lexicon": "^0.5.1",
+ "@atproto/lexicon": "^0.6.1",
"@atproto/oauth-client-node": "^0.3.8",
"@atproto/sync": "^0.1.34",
"@atproto/syntax": "^0.3.3",
@@ -124,6 +124,7 @@
"ajv": "^8.17.1",
"whatwg-url": "^14.0.0",
"@types/react": "19.2.6",
- "@types/react-dom": "19.2.3"
+ "@types/react-dom": "19.2.3",
+ "@atproto/lexicon": "^0.6.1"
}
}
--
2.51.2
From 90fee99c82c5c837d27a83b829b10befbe1a70d9 Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Tue, 3 Feb 2026 08:30:06 +0000
Subject: [PATCH 02/51] properly open atmentions in editor and resolve pubs
---
.../Blocks/TextBlock/mountProsemirror.ts | 42 +++++++++----------
src/utils/mentionUtils.ts | 7 +---
2 files changed, 21 insertions(+), 28 deletions(-)
diff --git a/components/Blocks/TextBlock/mountProsemirror.ts b/components/Blocks/TextBlock/mountProsemirror.ts
index 965f8a43..ff646a80 100644
--- a/components/Blocks/TextBlock/mountProsemirror.ts
+++ b/components/Blocks/TextBlock/mountProsemirror.ts
@@ -80,6 +80,23 @@ export function useMountProsemirror({
handlePaste,
handleClickOn: (_view, _pos, node, _nodePos, _event, direct) => {
if (!direct) return;
+
+ // Check for didMention inline nodes
+ if (node?.type === schema.nodes.didMention) {
+ window.open(
+ didToBlueskyUrl(node.attrs.did),
+ "_blank",
+ "noopener,noreferrer",
+ );
+ return;
+ }
+
+ // Check for atMention inline nodes
+ if (node?.type === schema.nodes.atMention) {
+ const url = atUriToUrl(node.attrs.atURI);
+ window.open(url, "_blank", "noopener,noreferrer");
+ return;
+ }
if (node.nodeSize - 2 <= _pos) return;
// Check for marks at the clicked position
@@ -87,34 +104,13 @@ export function useMountProsemirror({
const nodeAt2 = node.nodeAt(Math.max(_pos - 2, 0));
// Check for link marks
- let linkMark = nodeAt1?.marks.find((f) => f.type === schema.marks.link) ||
+ let linkMark =
+ nodeAt1?.marks.find((f) => f.type === schema.marks.link) ||
nodeAt2?.marks.find((f) => f.type === schema.marks.link);
if (linkMark) {
window.open(linkMark.attrs.href, "_blank");
return;
}
-
- // Check for didMention inline nodes
- if (nodeAt1?.type === schema.nodes.didMention) {
- window.open(didToBlueskyUrl(nodeAt1.attrs.did), "_blank", "noopener,noreferrer");
- return;
- }
- if (nodeAt2?.type === schema.nodes.didMention) {
- window.open(didToBlueskyUrl(nodeAt2.attrs.did), "_blank", "noopener,noreferrer");
- return;
- }
-
- // Check for atMention inline nodes
- if (nodeAt1?.type === schema.nodes.atMention) {
- const url = atUriToUrl(nodeAt1.attrs.atURI);
- window.open(url, "_blank", "noopener,noreferrer");
- return;
- }
- if (nodeAt2?.type === schema.nodes.atMention) {
- const url = atUriToUrl(nodeAt2.attrs.atURI);
- window.open(url, "_blank", "noopener,noreferrer");
- return;
- }
},
dispatchTransaction,
},
diff --git a/src/utils/mentionUtils.ts b/src/utils/mentionUtils.ts
index 706928e0..8e97482f 100644
--- a/src/utils/mentionUtils.ts
+++ b/src/utils/mentionUtils.ts
@@ -19,11 +19,8 @@ export function atUriToUrl(atUri: string): string {
const uri = new AtUri(atUri);
if (isPublicationCollection(uri.collection)) {
- // Publication URL: /lish/{did}/{rkey}
- return `/lish/${uri.host}/${uri.rkey}`;
+ return `/lish/uri/${encodeURIComponent(atUri)}`;
} else if (isDocumentCollection(uri.collection)) {
- // Document URL - we need to resolve this via the API
- // For now, create a redirect route that will handle it
return `/lish/uri/${encodeURIComponent(atUri)}`;
}
@@ -42,7 +39,7 @@ export function atUriToUrl(atUri: string): string {
export function handleMentionClick(
e: MouseEvent | React.MouseEvent,
type: "did" | "at-uri",
- value: string
+ value: string,
) {
e.preventDefault();
e.stopPropagation();
--
2.51.2
From 81a61895f85c9b5b93c9fb26a2e44386d6b40d03 Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Tue, 3 Feb 2026 10:10:45 +0000
Subject: [PATCH 03/51] log bsky post ingest errors
---
appview/index.ts | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/appview/index.ts b/appview/index.ts
index 3588b659..139ea9fd 100644
--- a/appview/index.ts
+++ b/appview/index.ts
@@ -378,7 +378,10 @@ async function handleEvent(evt: Event) {
// Now validate the record since we know it contains our quote param
let record = AppBskyFeedPost.validateRecord(evt.record);
- if (!record.success) return;
+ if (!record.success) {
+ console.log(record.error);
+ return;
+ }
let embed: string | null = null;
if (
--
2.51.2
From 4ba387abfdb0fe2db2a7712ec9f122beaf14a374 Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Tue, 3 Feb 2026 10:18:17 +0000
Subject: [PATCH 04/51] fix bsky post indexing function
---
app/api/inngest/functions/index_post_mention.ts | 15 ++++++++++++---
1 file changed, 12 insertions(+), 3 deletions(-)
diff --git a/app/api/inngest/functions/index_post_mention.ts b/app/api/inngest/functions/index_post_mention.ts
index 98ebef6d..80ff24f9 100644
--- a/app/api/inngest/functions/index_post_mention.ts
+++ b/app/api/inngest/functions/index_post_mention.ts
@@ -3,7 +3,10 @@ import { inngest } from "../client";
import { AtpAgent, AtUri } from "@atproto/api";
import { Json } from "supabase/database.types";
import { ids } from "lexicons/api/lexicons";
-import { Notification, pingIdentityToUpdateNotification } from "src/notifications";
+import {
+ Notification,
+ pingIdentityToUpdateNotification,
+} from "src/notifications";
import { v7 } from "uuid";
import { idResolver } from "app/(home-pages)/reader/idResolver";
import { documentUriFilter } from "src/utils/uriHelpers";
@@ -60,7 +63,11 @@ export const index_post_mention = inngest.createFunction(
let { data: pub, error } = await supabaseServerClient
.from("publications")
.select("*")
- .or(`record->>base_path.eq.${url.host},record->>url.eq.https://${url.host}`)
+ .or(
+ `record->>base_path.eq.${url.host},record->>url.eq.https://${url.host}`,
+ )
+ .order("uri", { ascending: false })
+ .limit(1)
.single();
if (!pub) {
@@ -80,7 +87,9 @@ export const index_post_mention = inngest.createFunction(
const docData = docDataArr?.[0];
if (!docData) {
- return { message: `No document found for publication ${url.host}/${path[0]}` };
+ return {
+ message: `No document found for publication ${url.host}/${path[0]}`,
+ };
}
documentUri = docData.uri;
--
2.51.2
From dcb147ea4cfc390746728c1cfdb7be09f1c62ebc Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Tue, 3 Feb 2026 23:24:49 +0000
Subject: [PATCH 05/51] show proper error for not found bsky posts
---
app/lish/[did]/[publication]/[rkey]/PostContent.tsx | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx
index 8e53ba17..37e7980b 100644
--- a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx
+++ b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx
@@ -32,6 +32,7 @@ import { PublishedPageLinkBlock } from "./Blocks/PublishedPageBlock";
import { PublishedPollBlock } from "./Blocks/PublishedPollBlock";
import { PollData } from "./fetchPollData";
import { ButtonPrimary } from "components/Buttons";
+import { PostNotAvailable } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed";
export function PostContent({
blocks,
@@ -170,7 +171,7 @@ export let Block = ({
case PubLeafletBlocksBskyPost.isMain(b.block): {
let uri = b.block.postRef.uri;
let post = bskyPostData.find((p) => p.uri === uri);
- if (!post) return no prefetched post rip
;
+ if (!post) return ;
return (
Date: Wed, 4 Feb 2026 00:25:11 -0500
Subject: [PATCH 06/51] fwd refs on action Button
---
components/ActionBar/ActionButton.tsx | 13 ++++++++-----
1 file changed, 8 insertions(+), 5 deletions(-)
diff --git a/components/ActionBar/ActionButton.tsx b/components/ActionBar/ActionButton.tsx
index 17dac49e..d093d8ff 100644
--- a/components/ActionBar/ActionButton.tsx
+++ b/components/ActionBar/ActionButton.tsx
@@ -7,8 +7,9 @@ import { PopoverOpenContext } from "components/Popover/PopoverContext";
type ButtonProps = Omit;
-export const ActionButton = (
- _props: ButtonProps & {
+export const ActionButton = forwardRef<
+ HTMLButtonElement,
+ ButtonProps & {
id?: string;
icon: React.ReactNode;
label: React.ReactNode;
@@ -19,8 +20,8 @@ export const ActionButton = (
subtext?: string;
labelOnMobile?: boolean;
z?: boolean;
- },
-) => {
+ }
+>((_props, ref) => {
let {
id,
icon,
@@ -50,6 +51,7 @@ export const ActionButton = (
return (
);
-};
+});
+ActionButton.displayName = "ActionButton";
--
2.51.2
From 0af3b2656e7a10ef647e1e1793c59a02c4ff8828 Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Wed, 4 Feb 2026 16:56:45 +0000
Subject: [PATCH 07/51] only push inngest event in production
---
app/api/oauth/[route]/route.ts | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/app/api/oauth/[route]/route.ts b/app/api/oauth/[route]/route.ts
index 903ce627..33b37f07 100644
--- a/app/api/oauth/[route]/route.ts
+++ b/app/api/oauth/[route]/route.ts
@@ -89,10 +89,11 @@ export async function GET(
// Trigger migration if identity needs it
const metadata = identity?.metadata as Record | null;
if (metadata?.needsStandardSiteMigration) {
- await inngest.send({
- name: "user/migrate-to-standard",
- data: { did: session.did },
- });
+ if (process.env.NODE_ENV === "production")
+ await inngest.send({
+ name: "user/migrate-to-standard",
+ data: { did: session.did },
+ });
}
let { data: token } = await supabaseServerClient
--
2.51.2
From 9b47b512a937e3df720964e3f125393b79e66872 Mon Sep 17 00:00:00 2001
From: celine
Date: Sat, 7 Feb 2026 04:23:32 -1000
Subject: [PATCH 08/51] Feature/recommend (#264)
* just like a bunch of stuff sorry
* cleaned up naming
* refactor the bskypostcontent to use booleans for reply and quote rather
than functions, include its own reply line, some renaming of variables
to be more consistant
* styling the grandparents in the threadviewer
* styling the quotepage
* adjustments to make everything more consistent
* use BskyPostContent in the leaflet as well
* fixing the width of thread and quote pages so they don't get too wide
* make the reply line emcompass it's replies as well
* little tweaks to the embed block
* fixed a bunch of small things
* init the recommend button, some stuff is broken tho
* oops forgot the new files
* add local like data fetching w/ batcher
* adjusting interaction button styles for consistency
* add user preference to show/hide recommends
* made some chnages to canvas layout to better match
* `add a little smoker to the recommend acion to keep it cute
* copy fixes
* spacing fixes
---------
Co-authored-by: Jared Pereira
---
.claude/settings.local.json | 15 +
.claude/skills/lexicons.md | 298 ------------------
.../p/[didOrHandle]/getProfilePosts.ts | 27 +-
app/(home-pages)/reader/getReaderFeed.ts | 3 +
.../tag/[tag]/getDocumentsByTag.ts | 2 +
app/api/oauth/[route]/route.ts | 3 +-
app/api/rpc/[command]/get_publication_data.ts | 5 +-
.../rpc/[command]/get_user_recommendations.ts | 40 +++
app/api/rpc/[command]/route.ts | 2 +
app/lish/Subscribe.tsx | 4 +-
.../[did]/[publication]/[rkey]/CanvasPage.tsx | 6 +
.../[rkey]/Interactions/Interactions.tsx | 124 +++++---
.../[rkey]/Interactions/recommendAction.ts | 135 ++++++++
.../[rkey]/LinearDocumentPage.tsx | 6 +-
.../[rkey]/PostHeader/PostHeader.tsx | 27 +-
.../[did]/[publication]/[rkey]/PostPages.tsx | 2 +
.../[publication]/[rkey]/getPostPageData.ts | 43 ++-
.../dashboard/PublishedPostsLists.tsx | 16 +-
.../dashboard/settings/PostOptions.tsx | 22 +-
app/lish/[did]/[publication]/page.tsx | 22 +-
app/lish/createPub/CreatePubForm.tsx | 1 +
app/lish/createPub/UpdatePubForm.tsx | 3 +-
app/lish/createPub/createPublication.ts | 32 +-
app/lish/createPub/updatePublication.ts | 265 ++++++++++------
components/Canvas.tsx | 21 +-
components/Icons/RecommendTiny.tsx | 37 +++
components/InteractionsPreview.tsx | 34 +-
components/Pages/PublicationMetadata.tsx | 68 ++--
components/PostListing.tsx | 10 +-
components/RecommendButton.tsx | 173 ++++++++++
contexts/DocumentContext.tsx | 1 +
lexicons/api/types/pub/leaflet/publication.ts | 83 ++---
.../api/types/site/standard/publication.ts | 61 ++--
lexicons/pub/leaflet/publication.json | 4 +
lexicons/site/standard/publication.json | 4 +
lexicons/src/normalize.ts | 27 +-
lexicons/src/publication.ts | 1 +
package-lock.json | 16 +
package.json | 1 +
39 files changed, 1023 insertions(+), 621 deletions(-)
create mode 100644 .claude/settings.local.json
delete mode 100644 .claude/skills/lexicons.md
create mode 100644 app/api/rpc/[command]/get_user_recommendations.ts
create mode 100644 app/lish/[did]/[publication]/[rkey]/Interactions/recommendAction.ts
create mode 100644 components/Icons/RecommendTiny.tsx
create mode 100644 components/RecommendButton.tsx
diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 00000000..9371392c
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,15 @@
+{
+ "permissions": {
+ "allow": [
+ "mcp__acp__Edit",
+ "mcp__acp__Write",
+ "mcp__acp__Bash",
+ "mcp__primitive__say_hello",
+ "mcp__primitive__pending_delegations",
+ "mcp__primitive__claim_delegation",
+ "mcp__primitive__tasks_update",
+ "mcp__primitive__contexts_update",
+ "mcp__primitive__contexts_list"
+ ]
+ }
+}
diff --git a/.claude/skills/lexicons.md b/.claude/skills/lexicons.md
deleted file mode 100644
index bc14e81b..00000000
--- a/.claude/skills/lexicons.md
+++ /dev/null
@@ -1,298 +0,0 @@
-# Lexicon System
-
-## Overview
-
-Lexicons define the schema for AT Protocol records. This project has two namespaces:
-- **`pub.leaflet.*`** - Leaflet-specific lexicons (documents, publications, blocks, etc.)
-- **`site.standard.*`** - Standard site lexicons for interoperability
-
-The lexicons are defined as TypeScript in `lexicons/src/`, built to JSON in `lexicons/pub/leaflet/` and `lexicons/site/standard/`, and TypeScript types are generated in `lexicons/api/`.
-
-## Key Files
-
-- **`lexicons/src/*.ts`** - Source definitions for `pub.leaflet.*` lexicons
-- **`lexicons/site/standard/**/*.json`** - JSON definitions for `site.standard.*` lexicons (manually maintained)
-- **`lexicons/build.ts`** - Builds TypeScript sources to JSON
-- **`lexicons/api/`** - Generated TypeScript types and client
-- **`package.json`** - Contains `lexgen` script
-
-## Running Lexicon Generation
-
-```bash
-npm run lexgen
-```
-
-This runs:
-1. `tsx ./lexicons/build.ts` - Builds `pub.leaflet.*` JSON from TypeScript
-2. `lex gen-api` - Generates TypeScript types from all JSON lexicons
-3. `tsx ./lexicons/fix-extensions.ts` - Fixes import extensions
-
-## Adding a New pub.leaflet Lexicon
-
-### 1. Create the Source Definition
-
-Create a file in `lexicons/src/` (e.g., `lexicons/src/myLexicon.ts`):
-
-```typescript
-import { LexiconDoc } from "@atproto/lexicon";
-
-export const PubLeafletMyLexicon: LexiconDoc = {
- lexicon: 1,
- id: "pub.leaflet.myLexicon",
- defs: {
- main: {
- type: "record", // or "object" for non-record types
- key: "tid",
- record: {
- type: "object",
- required: ["field1"],
- properties: {
- field1: { type: "string", maxLength: 1000 },
- field2: { type: "integer", minimum: 0 },
- optionalRef: { type: "ref", ref: "other.lexicon#def" },
- },
- },
- },
- // Additional defs for sub-objects
- subType: {
- type: "object",
- properties: {
- nested: { type: "string" },
- },
- },
- },
-};
-```
-
-### 2. Add to Build
-
-Update `lexicons/build.ts`:
-
-```typescript
-import { PubLeafletMyLexicon } from "./src/myLexicon";
-
-const lexicons = [
- // ... existing lexicons
- PubLeafletMyLexicon,
-];
-```
-
-### 3. Update lexgen Command (if needed)
-
-If your lexicon is at the top level of `pub/leaflet/` (not in a subdirectory), add it to the `lexgen` script in `package.json`:
-
-```json
-"lexgen": "tsx ./lexicons/build.ts && lex gen-api ./lexicons/api ./lexicons/pub/leaflet/document.json ./lexicons/pub/leaflet/myLexicon.json ./lexicons/pub/leaflet/*/* ..."
-```
-
-Note: Files in subdirectories (`pub/leaflet/*/*`) are automatically included.
-
-### 4. Add to authFullPermissions (for record types)
-
-If your lexicon is a record type that users should be able to create/update/delete, add it to the `authFullPermissions` permission set in `lexicons/src/authFullPermissions.ts`:
-
-```typescript
-import { PubLeafletMyLexicon } from "./myLexicon";
-
-// In the permissions collection array:
-collection: [
- // ... existing lexicons
- PubLeafletMyLexicon.id,
-],
-```
-
-### 5. Regenerate Types
-
-```bash
-npm run lexgen
-```
-
-### 6. Use the Generated Types
-
-```typescript
-import { PubLeafletMyLexicon } from "lexicons/api";
-
-// Type for the record
-type MyRecord = PubLeafletMyLexicon.Record;
-
-// Validation
-const result = PubLeafletMyLexicon.validateRecord(data);
-if (result.success) {
- // result.value is typed
-}
-
-// Type guard
-if (PubLeafletMyLexicon.isRecord(data)) {
- // data is typed as Record
-}
-```
-
-## Adding a New site.standard Lexicon
-
-### 1. Create the JSON Definition
-
-Create a file in `lexicons/site/standard/` (e.g., `lexicons/site/standard/myType.json`):
-
-```json
-{
- "lexicon": 1,
- "id": "site.standard.myType",
- "defs": {
- "main": {
- "type": "record",
- "key": "tid",
- "record": {
- "type": "object",
- "required": ["field1"],
- "properties": {
- "field1": {
- "type": "string",
- "maxLength": 1000
- }
- }
- }
- }
- }
-}
-```
-
-### 2. Regenerate Types
-
-```bash
-npm run lexgen
-```
-
-The `site/*/* site/*/*/*` globs in the lexgen command automatically pick up new files.
-
-## Common Lexicon Patterns
-
-### Referencing Other Lexicons
-
-```typescript
-// Reference another lexicon's main def
-{ type: "ref", ref: "pub.leaflet.publication" }
-
-// Reference a specific def within a lexicon
-{ type: "ref", ref: "pub.leaflet.publication#theme" }
-
-// Reference within the same lexicon
-{ type: "ref", ref: "#myDef" }
-```
-
-### Union Types
-
-```typescript
-{
- type: "union",
- refs: [
- "pub.leaflet.pages.linearDocument",
- "pub.leaflet.pages.canvas",
- ],
-}
-
-// Open union (allows unknown types)
-{
- type: "union",
- closed: false, // default is true
- refs: ["pub.leaflet.content"],
-}
-```
-
-### Blob Types (for images/files)
-
-```typescript
-{
- type: "blob",
- accept: ["image/*"], // or specific types like ["image/png", "image/jpeg"]
- maxSize: 1000000, // bytes
-}
-```
-
-### Color Types
-
-The project has color types defined:
-- `pub.leaflet.theme.color#rgb` / `#rgba`
-- `site.standard.theme.color#rgb` / `#rgba`
-
-```typescript
-// In lexicons/src/theme.ts
-export const ColorUnion = {
- type: "union",
- refs: [
- "pub.leaflet.theme.color#rgba",
- "pub.leaflet.theme.color#rgb",
- ],
-};
-```
-
-## Normalization Between Formats
-
-Use `lexicons/src/normalize.ts` to convert between `pub.leaflet` and `site.standard` formats:
-
-```typescript
-import {
- normalizeDocument,
- normalizePublication,
- isLeafletDocument,
- isStandardDocument,
- getDocumentPages,
-} from "lexicons/src/normalize";
-
-// Normalize a document from either format
-const normalized = normalizeDocument(record);
-if (normalized) {
- // normalized is always in site.standard.document format
- console.log(normalized.title, normalized.site);
-
- // Get pages if content is pub.leaflet.content
- const pages = getDocumentPages(normalized);
-}
-
-// Normalize a publication
-const pub = normalizePublication(record);
-if (pub) {
- console.log(pub.name, pub.url);
-}
-```
-
-## Handling in Appview (Firehose Consumer)
-
-When processing records from the firehose in `appview/index.ts`:
-
-```typescript
-import { ids } from "lexicons/api/lexicons";
-import { PubLeafletMyLexicon } from "lexicons/api";
-
-// In filterCollections:
-filterCollections: [
- ids.PubLeafletMyLexicon,
- // ...
-],
-
-// In handleEvent:
-if (evt.collection === ids.PubLeafletMyLexicon) {
- if (evt.event === "create" || evt.event === "update") {
- let record = PubLeafletMyLexicon.validateRecord(evt.record);
- if (!record.success) return;
-
- // Store in database
- await supabase.from("my_table").upsert({
- uri: evt.uri.toString(),
- data: record.value as Json,
- });
- }
- if (evt.event === "delete") {
- await supabase.from("my_table").delete().eq("uri", evt.uri.toString());
- }
-}
-```
-
-## Publishing Lexicons
-
-To publish lexicons to an AT Protocol PDS:
-
-```bash
-npm run publish-lexicons
-```
-
-This runs `lexicons/publish.ts` which publishes lexicons to the configured PDS.
diff --git a/app/(home-pages)/p/[didOrHandle]/getProfilePosts.ts b/app/(home-pages)/p/[didOrHandle]/getProfilePosts.ts
index 131b821f..92ca1bf6 100644
--- a/app/(home-pages)/p/[didOrHandle]/getProfilePosts.ts
+++ b/app/(home-pages)/p/[didOrHandle]/getProfilePosts.ts
@@ -26,6 +26,7 @@ export async function getProfilePosts(
`*,
comments_on_documents(count),
document_mentions_in_bsky(count),
+ recommends_on_documents(count),
documents_in_publications(publications(*))`,
)
.like("uri", `at://${did}/%`)
@@ -39,18 +40,19 @@ export async function getProfilePosts(
);
}
- let [{ data: rawDocs }, { data: rawPubs }, { data: profile }] = await Promise.all([
- query,
- supabaseServerClient
- .from("publications")
- .select("*")
- .eq("identity_did", did),
- supabaseServerClient
- .from("bsky_profiles")
- .select("handle")
- .eq("did", did)
- .single(),
- ]);
+ let [{ data: rawDocs }, { data: rawPubs }, { data: profile }] =
+ await Promise.all([
+ query,
+ supabaseServerClient
+ .from("publications")
+ .select("*")
+ .eq("identity_did", did),
+ supabaseServerClient
+ .from("bsky_profiles")
+ .select("handle")
+ .eq("did", did)
+ .single(),
+ ]);
// Deduplicate records that may exist under both pub.leaflet and site.standard namespaces
const docs = deduplicateByUriOrdered(rawDocs || []);
@@ -82,6 +84,7 @@ export async function getProfilePosts(
sort_date: doc.sort_date,
comments_on_documents: doc.comments_on_documents,
document_mentions_in_bsky: doc.document_mentions_in_bsky,
+ recommends_on_documents: doc.recommends_on_documents,
},
};
diff --git a/app/(home-pages)/reader/getReaderFeed.ts b/app/(home-pages)/reader/getReaderFeed.ts
index d3996eb6..4ff16591 100644
--- a/app/(home-pages)/reader/getReaderFeed.ts
+++ b/app/(home-pages)/reader/getReaderFeed.ts
@@ -32,6 +32,7 @@ export async function getReaderFeed(
`*,
comments_on_documents(count),
document_mentions_in_bsky(count),
+ recommends_on_documents(count),
documents_in_publications!inner(publications!inner(*, publication_subscriptions!inner(*)))`,
)
.eq(
@@ -76,6 +77,7 @@ export async function getReaderFeed(
documents: {
comments_on_documents: post.comments_on_documents,
document_mentions_in_bsky: post.document_mentions_in_bsky,
+ recommends_on_documents: post.recommends_on_documents,
data: normalizedData,
uri: post.uri,
sort_date: post.sort_date,
@@ -112,5 +114,6 @@ export type Post = {
sort_date: string;
comments_on_documents: { count: number }[] | undefined;
document_mentions_in_bsky: { count: number }[] | undefined;
+ recommends_on_documents: { count: number }[] | undefined;
};
};
diff --git a/app/(home-pages)/tag/[tag]/getDocumentsByTag.ts b/app/(home-pages)/tag/[tag]/getDocumentsByTag.ts
index ebaffbe0..bb6394c9 100644
--- a/app/(home-pages)/tag/[tag]/getDocumentsByTag.ts
+++ b/app/(home-pages)/tag/[tag]/getDocumentsByTag.ts
@@ -21,6 +21,7 @@ export async function getDocumentsByTag(
`*,
comments_on_documents(count),
document_mentions_in_bsky(count),
+ recommends_on_documents(count),
documents_in_publications(publications(*))`,
)
.contains("data->tags", `["${tag}"]`)
@@ -67,6 +68,7 @@ export async function getDocumentsByTag(
documents: {
comments_on_documents: doc.comments_on_documents,
document_mentions_in_bsky: doc.document_mentions_in_bsky,
+ recommends_on_documents: doc.recommends_on_documents,
data: normalizedData,
uri: doc.uri,
sort_date: doc.sort_date,
diff --git a/app/api/oauth/[route]/route.ts b/app/api/oauth/[route]/route.ts
index 33b37f07..db2ab8f4 100644
--- a/app/api/oauth/[route]/route.ts
+++ b/app/api/oauth/[route]/route.ts
@@ -105,7 +105,7 @@ export async function GET(
})
.select()
.single();
-
+ console.log({ token });
if (token) await setAuthToken(token.id);
// Process successful authentication here
@@ -114,6 +114,7 @@ export async function GET(
console.log("User authenticated as:", session.did);
return handleAction(s.action, redirectPath);
} catch (e) {
+ console.log(e);
redirect(redirectPath);
}
}
diff --git a/app/api/rpc/[command]/get_publication_data.ts b/app/api/rpc/[command]/get_publication_data.ts
index b33b9203..09cc91f4 100644
--- a/app/api/rpc/[command]/get_publication_data.ts
+++ b/app/api/rpc/[command]/get_publication_data.ts
@@ -40,7 +40,8 @@ export const get_publication_data = makeRoute({
documents_in_publications(documents(
*,
comments_on_documents(count),
- document_mentions_in_bsky(count)
+ document_mentions_in_bsky(count),
+ recommends_on_documents(count)
)),
publication_subscriptions(*, identities(bsky_profiles(*))),
publication_domains(*),
@@ -87,6 +88,8 @@ export const get_publication_data = makeRoute({
data: dip.documents.data,
commentsCount: dip.documents.comments_on_documents[0]?.count || 0,
mentionsCount: dip.documents.document_mentions_in_bsky[0]?.count || 0,
+ recommendsCount:
+ dip.documents.recommends_on_documents?.[0]?.count || 0,
};
})
.filter((d): d is NonNullable => d !== null);
diff --git a/app/api/rpc/[command]/get_user_recommendations.ts b/app/api/rpc/[command]/get_user_recommendations.ts
new file mode 100644
index 00000000..1c608695
--- /dev/null
+++ b/app/api/rpc/[command]/get_user_recommendations.ts
@@ -0,0 +1,40 @@
+import { z } from "zod";
+import { makeRoute } from "../lib";
+import type { Env } from "./route";
+import { getIdentityData } from "actions/getIdentityData";
+
+export type GetUserRecommendationsReturnType = Awaited<
+ ReturnType<(typeof get_user_recommendations)["handler"]>
+>;
+
+export const get_user_recommendations = makeRoute({
+ route: "get_user_recommendations",
+ input: z.object({
+ documentUris: z.array(z.string()),
+ }),
+ handler: async ({ documentUris }, { supabase }: Pick) => {
+ const identity = await getIdentityData();
+ const currentUserDid = identity?.atp_did;
+
+ if (!currentUserDid || documentUris.length === 0) {
+ return {
+ result: {} as Record,
+ };
+ }
+
+ const { data: recommendations } = await supabase
+ .from("recommends_on_documents")
+ .select("document")
+ .eq("recommender_did", currentUserDid)
+ .in("document", documentUris);
+
+ const recommendedSet = new Set(recommendations?.map((r) => r.document));
+
+ const result: Record = {};
+ for (const uri of documentUris) {
+ result[uri] = recommendedSet.has(uri);
+ }
+
+ return { result };
+ },
+});
diff --git a/app/api/rpc/[command]/route.ts b/app/api/rpc/[command]/route.ts
index df7a865b..c6ba128e 100644
--- a/app/api/rpc/[command]/route.ts
+++ b/app/api/rpc/[command]/route.ts
@@ -14,6 +14,7 @@ import { get_publication_data } from "./get_publication_data";
import { search_publication_names } from "./search_publication_names";
import { search_publication_documents } from "./search_publication_documents";
import { get_profile_data } from "./get_profile_data";
+import { get_user_recommendations } from "./get_user_recommendations";
let supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_API_URL as string,
@@ -41,6 +42,7 @@ let Routes = [
search_publication_names,
search_publication_documents,
get_profile_data,
+ get_user_recommendations,
];
export async function POST(
req: Request,
diff --git a/app/lish/Subscribe.tsx b/app/lish/Subscribe.tsx
index 4cfd5326..eb2e1411 100644
--- a/app/lish/Subscribe.tsx
+++ b/app/lish/Subscribe.tsx
@@ -87,7 +87,9 @@ export const ManageSubscription = (props: {
return (
Manage Subscription
+
+ Manage Subscription
+
}
>
diff --git a/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx b/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
index 9b3a1058..74e3763b 100644
--- a/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
+++ b/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
@@ -71,6 +71,7 @@ export function CanvasPage({
preferences={preferences}
commentsCount={getCommentCount(document.comments_on_documents, pageId)}
quotesCount={getQuoteCount(document.quotesAndMentions, pageId)}
+ recommendsCount={document.recommendsCount}
/>
{
let isMobile = useIsMobile();
return (
@@ -216,8 +219,10 @@ const CanvasMetadata = (props: {
{!props.isSubpage && (
@@ -233,6 +238,7 @@ const CanvasMetadata = (props: {
data={props.data}
profile={props.profile}
preferences={props.preferences}
+ isCanvas
/>
>
diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
index 9dcc4421..36713943 100644
--- a/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
+++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
@@ -18,6 +18,9 @@ import { useIdentityData } from "components/IdentityProvider";
import { ManageSubscription, SubscribeWithBluesky } from "app/lish/Subscribe";
import { EditTiny } from "components/Icons/EditTiny";
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
+import { RecommendButton } from "components/RecommendButton";
+import { ButtonSecondary } from "components/Buttons";
+import { Separator } from "components/Layout";
export type InteractionState = {
drawerOpen: undefined | boolean;
@@ -105,12 +108,18 @@ export function openInteractionDrawer(
export const Interactions = (props: {
quotesCount: number;
commentsCount: number;
+ recommendsCount: number;
className?: string;
showComments: boolean;
showMentions: boolean;
+ showRecommends: boolean;
pageId?: string;
}) => {
- const { uri: document_uri, quotesAndMentions, normalizedDocument } = useDocument();
+ const {
+ uri: document_uri,
+ quotesAndMentions,
+ normalizedDocument,
+ } = useDocument();
let { identity } = useIdentityData();
let { drawerOpen, drawer, pageId } = useInteractionState(document_uri);
@@ -124,13 +133,24 @@ export const Interactions = (props: {
const tags = normalizedDocument.tags;
const tagCount = tags?.length || 0;
+ let interactionsAvailable =
+ props.showComments || props.showMentions || props.showRecommends;
+
return (
-
- {tagCount > 0 &&
}
+
+ {props.showRecommends === false ? null : (
+
+ )}
+ {/*MENTIONS BUTTON*/}
{props.quotesCount === 0 || props.showMentions === false ? null : (
{
if (!drawerOpen || drawer !== "quotes")
openInteractionDrawer("quotes", document_uri, props.pageId);
@@ -143,9 +163,10 @@ export const Interactions = (props: {
{props.quotesCount}
)}
+ {/*COMMENT BUTTON*/}
{props.showComments === false ? null : (
{
if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId)
openInteractionDrawer("comments", document_uri, props.pageId);
@@ -156,6 +177,13 @@ export const Interactions = (props: {
{props.commentsCount}
)}
+
+ {tagCount > 0 && (
+ <>
+ {interactionsAvailable && }
+
+ >
+ )}
);
};
@@ -163,12 +191,20 @@ export const Interactions = (props: {
export const ExpandedInteractions = (props: {
quotesCount: number;
commentsCount: number;
+ recommendsCount: number;
className?: string;
showComments: boolean;
showMentions: boolean;
+ showRecommends: boolean;
pageId?: string;
}) => {
- const { uri: document_uri, quotesAndMentions, normalizedDocument, publication, leafletId } = useDocument();
+ const {
+ uri: document_uri,
+ quotesAndMentions,
+ normalizedDocument,
+ publication,
+ leafletId,
+ } = useDocument();
let { identity } = useIdentityData();
let { drawerOpen, drawer, pageId } = useInteractionState(document_uri);
@@ -182,7 +218,8 @@ export const ExpandedInteractions = (props: {
const tags = normalizedDocument.tags;
const tagCount = tags?.length || 0;
- let noInteractions = !props.showComments && !props.showMentions;
+ let noInteractions =
+ !props.showComments && !props.showMentions && !props.showRecommends;
let subscribed =
identity?.atp_did &&
@@ -191,11 +228,6 @@ export const ExpandedInteractions = (props: {
(s) => s.identity === identity.atp_did,
);
- let isAuthor =
- identity &&
- identity.atp_did === publication?.identity_did &&
- leafletId;
-
return (
) : (
- <>
-
+
+
+ {props.showRecommends === false ? null : (
+
+ )}
{props.quotesCount === 0 || !props.showMentions ? null : (
- {
if (!drawerOpen || drawer !== "quotes")
openInteractionDrawer(
@@ -233,15 +271,13 @@ export const ExpandedInteractions = (props: {
onTouchStart={handleQuotePrefetch}
aria-label="Post quotes"
>
- {props.quotesCount}{" "}
- {`Mention${props.quotesCount === 1 ? "" : "s"}`}
-
+ {props.quotesCount}
+
+ Mention{props.quotesCount > 1 ? "s" : ""}
+
)}
{!props.showComments ? null : (
- {
if (
!drawerOpen ||
@@ -259,27 +295,27 @@ export const ExpandedInteractions = (props: {
aria-label="Post comments"
>
{" "}
- {props.commentsCount > 0 ? (
-
- {`${props.commentsCount} Comment${props.commentsCount === 1 ? "" : "s"}`}
-
- ) : (
- "Comment"
+ {props.commentsCount > 0 && (
+ <>
+ {props.commentsCount}
+
+ >
)}
-
+ Comment{props.commentsCount > 1 ? "s" : ""}
+
)}
- >
+ {subscribed && publication && (
+
+ )}
+
)}
- {subscribed && publication && (
-
- )}
);
@@ -313,7 +349,10 @@ const TagList = (props: { className?: string; tags: string[] | undefined }) => {
);
};
-export function getQuoteCount(quotesAndMentions: { uri: string; link?: string }[], pageId?: string) {
+export function getQuoteCount(
+ quotesAndMentions: { uri: string; link?: string }[],
+ pageId?: string,
+) {
return getQuoteCountFromArray(quotesAndMentions, pageId);
}
@@ -338,7 +377,10 @@ export function getQuoteCountFromArray(
}
}
-export function getCommentCount(comments: CommentOnDocument[], pageId?: string) {
+export function getCommentCount(
+ comments: CommentOnDocument[],
+ pageId?: string,
+) {
if (pageId)
return comments.filter(
(c) => (c.record as PubLeafletComment.Record)?.onPage === pageId,
@@ -362,7 +404,7 @@ const EditButton = (props: {
return (
Edit Post
diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/recommendAction.ts b/app/lish/[did]/[publication]/[rkey]/Interactions/recommendAction.ts
new file mode 100644
index 00000000..d0295f26
--- /dev/null
+++ b/app/lish/[did]/[publication]/[rkey]/Interactions/recommendAction.ts
@@ -0,0 +1,135 @@
+"use server";
+
+import { AtpBaseClient, PubLeafletInteractionsRecommend } from "lexicons/api";
+import { getIdentityData } from "actions/getIdentityData";
+import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth";
+import { TID } from "@atproto/common";
+import { AtUri, Un$Typed } from "@atproto/api";
+import { supabaseServerClient } from "supabase/serverClient";
+import { Json } from "supabase/database.types";
+
+type RecommendResult =
+ | { success: true; uri: string }
+ | {
+ success: false;
+ error: OAuthSessionError | { type: string; message: string };
+ };
+
+export async function recommendAction(args: {
+ document: string;
+}): Promise {
+ console.log("recommend action...");
+ let identity = await getIdentityData();
+ if (!identity || !identity.atp_did) {
+ return {
+ success: false,
+ error: {
+ type: "oauth_session_expired",
+ message: "Not authenticated",
+ did: "",
+ },
+ };
+ }
+
+ const sessionResult = await restoreOAuthSession(identity.atp_did);
+ if (!sessionResult.ok) {
+ return { success: false, error: sessionResult.error };
+ }
+ let credentialSession = sessionResult.value;
+ let agent = new AtpBaseClient(
+ credentialSession.fetchHandler.bind(credentialSession),
+ );
+
+ let record: Un$Typed = {
+ subject: args.document,
+ createdAt: new Date().toISOString(),
+ };
+
+ let rkey = TID.nextStr();
+ let uri = AtUri.make(
+ credentialSession.did!,
+ "pub.leaflet.interactions.recommend",
+ rkey,
+ );
+
+ await agent.pub.leaflet.interactions.recommend.create(
+ { rkey, repo: credentialSession.did! },
+ record,
+ );
+
+ let res = await supabaseServerClient.from("recommends_on_documents").upsert({
+ uri: uri.toString(),
+ document: args.document,
+ recommender_did: credentialSession.did!,
+ record: {
+ $type: "pub.leaflet.interactions.recommend",
+ ...record,
+ } as unknown as Json,
+ });
+ console.log(res);
+
+ return {
+ success: true,
+ uri: uri.toString(),
+ };
+}
+
+export async function unrecommendAction(args: {
+ document: string;
+}): Promise {
+ let identity = await getIdentityData();
+ if (!identity || !identity.atp_did) {
+ return {
+ success: false,
+ error: {
+ type: "oauth_session_expired",
+ message: "Not authenticated",
+ did: "",
+ },
+ };
+ }
+
+ const sessionResult = await restoreOAuthSession(identity.atp_did);
+ if (!sessionResult.ok) {
+ return { success: false, error: sessionResult.error };
+ }
+ let credentialSession = sessionResult.value;
+ let agent = new AtpBaseClient(
+ credentialSession.fetchHandler.bind(credentialSession),
+ );
+
+ // Find the existing recommend record
+ const { data: existingRecommend } = await supabaseServerClient
+ .from("recommends_on_documents")
+ .select("uri")
+ .eq("document", args.document)
+ .eq("recommender_did", credentialSession.did!)
+ .single();
+
+ if (!existingRecommend) {
+ return {
+ success: false,
+ error: {
+ type: "not_found",
+ message: "Recommend not found",
+ },
+ };
+ }
+
+ let uri = new AtUri(existingRecommend.uri);
+
+ await agent.pub.leaflet.interactions.recommend.delete({
+ rkey: uri.rkey,
+ repo: credentialSession.did!,
+ });
+
+ await supabaseServerClient
+ .from("recommends_on_documents")
+ .delete()
+ .eq("uri", existingRecommend.uri);
+
+ return {
+ success: true,
+ uri: existingRecommend.uri,
+ };
+}
diff --git a/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx b/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
index 1046025a..04b9d7d9 100644
--- a/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
+++ b/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
@@ -87,8 +87,12 @@ export function LinearDocumentPage({
pageId={pageId}
showComments={preferences.showComments !== false}
showMentions={preferences.showMentions !== false}
- commentsCount={getCommentCount(document.comments_on_documents, pageId) || 0}
+ showRecommends={preferences.showRecommends !== false}
+ commentsCount={
+ getCommentCount(document.comments_on_documents, pageId) || 0
+ }
quotesCount={getQuoteCount(document.quotesAndMentions, pageId) || 0}
+ recommendsCount={document.recommendsCount}
/>
{!hasPageBackground &&
}
diff --git a/app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx b/app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
index 86216159..1159683a 100644
--- a/app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
+++ b/app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
@@ -18,7 +18,12 @@ import { ProfilePopover } from "components/ProfilePopover";
export function PostHeader(props: {
data: PostPageData;
profile: ProfileViewDetailed;
- preferences: { showComments?: boolean; showMentions?: boolean };
+ preferences: {
+ showComments?: boolean;
+ showMentions?: boolean;
+ showRecommends?: boolean;
+ };
+ isCanvas?: boolean;
}) {
let { identity } = useIdentityData();
let document = props.data;
@@ -84,12 +89,20 @@ export function PostHeader(props: {
>
) : null}
-
+ {!props.isCanvas && (
+
+ )}
>
}
/>
diff --git a/app/lish/[did]/[publication]/[rkey]/PostPages.tsx b/app/lish/[did]/[publication]/[rkey]/PostPages.tsx
index 234f2041..9b2a8a67 100644
--- a/app/lish/[did]/[publication]/[rkey]/PostPages.tsx
+++ b/app/lish/[did]/[publication]/[rkey]/PostPages.tsx
@@ -170,6 +170,7 @@ export type SharedPageProps = {
preferences: {
showComments?: boolean;
showMentions?: boolean;
+ showRecommends?: boolean;
showPrevNext?: boolean;
};
pubRecord?: NormalizedPublication | null;
@@ -233,6 +234,7 @@ export function PostPages({
preferences: {
showComments?: boolean;
showMentions?: boolean;
+ showRecommends?: boolean;
showPrevNext?: boolean;
};
pollData: PollData[];
diff --git a/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts b/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
index 9f7f44a2..7f16418a 100644
--- a/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
+++ b/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
@@ -22,7 +22,8 @@ export async function getPostPageData(did: string, rkey: string) {
publication_subscriptions(*))
),
document_mentions_in_bsky(*),
- leaflets_in_publications(*)
+ leaflets_in_publications(*),
+ recommends_on_documents(count)
`,
)
.or(documentUriFilter(did, rkey))
@@ -33,12 +34,15 @@ export async function getPostPageData(did: string, rkey: string) {
if (!document) return null;
// Normalize the document record - this is the primary way consumers should access document data
- const normalizedDocument = normalizeDocumentRecord(document.data, document.uri);
+ const normalizedDocument = normalizeDocumentRecord(
+ document.data,
+ document.uri,
+ );
if (!normalizedDocument) return null;
// Normalize the publication record - this is the primary way consumers should access publication data
const normalizedPublication = normalizePublicationRecord(
- document.documents_in_publications[0]?.publications?.record
+ document.documents_in_publications[0]?.publications?.record,
);
// Fetch constellation backlinks for mentions
@@ -83,7 +87,10 @@ export async function getPostPageData(did: string, rkey: string) {
// Filter and sort documents by publishedAt
const sortedDocs = allDocs
.map((dip) => {
- const normalizedData = normalizeDocumentRecord(dip?.documents?.data, dip?.documents?.uri);
+ const normalizedData = normalizeDocumentRecord(
+ dip?.documents?.data,
+ dip?.documents?.uri,
+ );
return {
uri: dip?.documents?.uri,
title: normalizedData?.title,
@@ -98,7 +105,9 @@ export async function getPostPageData(did: string, rkey: string) {
);
// Find current document index
- const currentIndex = sortedDocs.findIndex((doc) => doc.uri === document.uri);
+ const currentIndex = sortedDocs.findIndex(
+ (doc) => doc.uri === document.uri,
+ );
if (currentIndex !== -1) {
prevNext = {
@@ -122,13 +131,21 @@ export async function getPostPageData(did: string, rkey: string) {
// Build explicit publication context for consumers
const rawPub = document.documents_in_publications[0]?.publications;
- const publication = rawPub ? {
- uri: rawPub.uri,
- name: rawPub.name,
- identity_did: rawPub.identity_did,
- record: rawPub.record as PubLeafletPublication.Record | SiteStandardPublication.Record | null,
- publication_subscriptions: rawPub.publication_subscriptions || [],
- } : null;
+ const publication = rawPub
+ ? {
+ uri: rawPub.uri,
+ name: rawPub.name,
+ identity_did: rawPub.identity_did,
+ record: rawPub.record as
+ | PubLeafletPublication.Record
+ | SiteStandardPublication.Record
+ | null,
+ publication_subscriptions: rawPub.publication_subscriptions || [],
+ }
+ : null;
+
+ // Get recommends count from the aggregated query result
+ const recommendsCount = document.recommends_on_documents?.[0]?.count ?? 0;
return {
...document,
@@ -143,6 +160,8 @@ export async function getPostPageData(did: string, rkey: string) {
comments: document.comments_on_documents,
mentions: document.document_mentions_in_bsky,
leafletId: document.leaflets_in_publications[0]?.leaflet || null,
+ // Recommends data
+ recommendsCount,
};
}
diff --git a/app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx b/app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
index b8a24406..a7e4c630 100644
--- a/app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
+++ b/app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
@@ -60,7 +60,9 @@ export function PublishedPostsList(props: {
function PublishedPostItem(props: {
doc: PublishedDocument;
- publication: NonNullable["data"]>["publication"]>;
+ publication: NonNullable<
+ NonNullable["data"]>["publication"]
+ >;
pubRecord: ReturnType;
showPageBackground: boolean;
}) {
@@ -94,10 +96,7 @@ function PublishedPostItem(props: {
{leaflet && leaflet.permission_tokens && (
<>
-
+
@@ -129,9 +128,7 @@ function PublishedPostItem(props: {
{doc.record.description ? (
-
- {doc.record.description}
-
+ {doc.record.description}
) : null}
{doc.record.publishedAt ? (
@@ -140,9 +137,12 @@ function PublishedPostItem(props: {
diff --git a/app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx b/app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx
index 076568ef..70ae3d75 100644
--- a/app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx
+++ b/app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx
@@ -29,6 +29,11 @@ export const PostOptions = (props: {
? true
: record.preferences.showMentions,
);
+ let [showRecommends, setShowRecommends] = useState(
+ record?.preferences?.showRecommends === undefined
+ ? true
+ : record.preferences.showRecommends,
+ );
let [showPrevNext, setShowPrevNext] = useState(
record?.preferences?.showPrevNext === undefined
? true
@@ -53,6 +58,7 @@ export const PostOptions = (props: {
showComments: showComments,
showMentions: showMentions,
showPrevNext: showPrevNext,
+ showRecommends: showRecommends,
},
});
toast({ type: "success", content: Posts Updated! });
@@ -99,7 +105,21 @@ export const PostOptions = (props: {
Show Mentions
- Display a list of posts on Bluesky that mention your post
+ Display a list Bluesky mentions about your post
+
+
+
+
+ {
+ setShowRecommends(!showRecommends);
+ }}
+ >
+
+
Show Recommends
+
+ Allow readers to recommend/like your post
diff --git a/app/lish/[did]/[publication]/page.tsx b/app/lish/[did]/[publication]/page.tsx
index 0a51e1b4..457f38b2 100644
--- a/app/lish/[did]/[publication]/page.tsx
+++ b/app/lish/[did]/[publication]/page.tsx
@@ -38,7 +38,8 @@ export default async function Publication(props: {
documents_in_publications(documents(
*,
comments_on_documents(count),
- document_mentions_in_bsky(count)
+ document_mentions_in_bsky(count),
+ recommends_on_documents(count)
))
`,
)
@@ -119,7 +120,9 @@ export default async function Publication(props: {
})
.map((doc) => {
if (!doc.documents) return null;
- const doc_record = normalizeDocumentRecord(doc.documents.data);
+ const doc_record = normalizeDocumentRecord(
+ doc.documents.data,
+ );
if (!doc_record) return null;
let uri = new AtUri(doc.documents.uri);
let quotes =
@@ -128,6 +131,8 @@ export default async function Publication(props: {
record?.preferences?.showComments === false
? 0
: doc.documents.comments_on_documents[0].count || 0;
+ let recommends =
+ doc.documents.recommends_on_documents?.[0]?.count || 0;
let tags = doc_record.tags || [];
return (
@@ -143,7 +148,7 @@ export default async function Publication(props: {
-
+
{doc_record.publishedAt && (
)}{" "}
- {comments > 0 || quotes > 0 || tags.length > 0 ? (
-
- ) : (
- ""
- )}
+
diff --git a/app/lish/createPub/CreatePubForm.tsx b/app/lish/createPub/CreatePubForm.tsx
index 2dbe924e..749b4397 100644
--- a/app/lish/createPub/CreatePubForm.tsx
+++ b/app/lish/createPub/CreatePubForm.tsx
@@ -58,6 +58,7 @@ export const CreatePubForm = () => {
showComments: true,
showMentions: true,
showPrevNext: true,
+ showRecommends: true,
},
});
diff --git a/app/lish/createPub/UpdatePubForm.tsx b/app/lish/createPub/UpdatePubForm.tsx
index 3672c5f8..f9844796 100644
--- a/app/lish/createPub/UpdatePubForm.tsx
+++ b/app/lish/createPub/UpdatePubForm.tsx
@@ -88,6 +88,7 @@ export const EditPubForm = (props: {
showComments: showComments,
showMentions: showMentions,
showPrevNext: showPrevNext,
+ showRecommends: record?.preferences?.showRecommends ?? true,
},
});
toast({ type: "success", content: "Updated!" });
@@ -194,8 +195,6 @@ export const EditPubForm = (props: {
-
-
);
diff --git a/app/lish/createPub/createPublication.ts b/app/lish/createPub/createPublication.ts
index a705a0e6..5adcc7ee 100644
--- a/app/lish/createPub/createPublication.ts
+++ b/app/lish/createPub/createPublication.ts
@@ -5,10 +5,7 @@ import {
PubLeafletPublication,
SiteStandardPublication,
} from "lexicons/api";
-import {
- restoreOAuthSession,
- OAuthSessionError,
-} from "src/atproto-oauth";
+import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth";
import { getIdentityData } from "actions/getIdentityData";
import { supabaseServerClient } from "supabase/serverClient";
import { Json } from "supabase/database.types";
@@ -76,7 +73,11 @@ export async function createPublication({
// Build record based on publication type
let record: SiteStandardPublication.Record | PubLeafletPublication.Record;
- let iconBlob: Awaited>["data"]["blob"] | undefined;
+ let iconBlob:
+ | Awaited<
+ ReturnType
+ >["data"]["blob"]
+ | undefined;
// Upload the icon if provided
if (iconFile && iconFile.size > 0) {
@@ -97,16 +98,29 @@ export async function createPublication({
...(iconBlob && { icon: iconBlob }),
basicTheme: {
$type: "site.standard.theme.basic",
- background: { $type: "site.standard.theme.color#rgb", ...PubThemeDefaultsRGB.background },
- foreground: { $type: "site.standard.theme.color#rgb", ...PubThemeDefaultsRGB.foreground },
- accent: { $type: "site.standard.theme.color#rgb", ...PubThemeDefaultsRGB.accent },
- accentForeground: { $type: "site.standard.theme.color#rgb", ...PubThemeDefaultsRGB.accentForeground },
+ background: {
+ $type: "site.standard.theme.color#rgb",
+ ...PubThemeDefaultsRGB.background,
+ },
+ foreground: {
+ $type: "site.standard.theme.color#rgb",
+ ...PubThemeDefaultsRGB.foreground,
+ },
+ accent: {
+ $type: "site.standard.theme.color#rgb",
+ ...PubThemeDefaultsRGB.accent,
+ },
+ accentForeground: {
+ $type: "site.standard.theme.color#rgb",
+ ...PubThemeDefaultsRGB.accentForeground,
+ },
},
preferences: {
showInDiscover: preferences.showInDiscover,
showComments: preferences.showComments,
showMentions: preferences.showMentions,
showPrevNext: preferences.showPrevNext,
+ showRecommends: preferences.showRecommends,
},
} satisfies SiteStandardPublication.Record;
} else {
diff --git a/app/lish/createPub/updatePublication.ts b/app/lish/createPub/updatePublication.ts
index a9bd3e56..350b3f88 100644
--- a/app/lish/createPub/updatePublication.ts
+++ b/app/lish/createPub/updatePublication.ts
@@ -77,7 +77,9 @@ async function withPublicationUpdate(
}
const aturi = new AtUri(existingPub.uri);
- const publicationType = getPublicationType(aturi.collection) as PublicationType;
+ const publicationType = getPublicationType(
+ aturi.collection,
+ ) as PublicationType;
// Normalize existing record
const normalizedPub = normalizePublicationRecord(existingPub.record);
@@ -128,7 +130,11 @@ interface RecordOverrides {
}
/** Merges override with existing value, respecting explicit undefined */
-function resolveField(override: T | undefined, existing: T | undefined, hasOverride: boolean): T | undefined {
+function resolveField(
+ override: T | undefined,
+ existing: T | undefined,
+ hasOverride: boolean,
+): T | undefined {
return hasOverride ? override : existing;
}
@@ -146,17 +152,32 @@ function buildLeafletRecord(
return {
$type: "pub.leaflet.publication",
name: overrides.name ?? normalizedPub?.name ?? "",
- description: resolveField(overrides.description, normalizedPub?.description, "description" in overrides),
- icon: resolveField(overrides.icon, normalizedPub?.icon, "icon" in overrides),
- theme: resolveField(overrides.theme, normalizedPub?.theme, "theme" in overrides),
+ description: resolveField(
+ overrides.description,
+ normalizedPub?.description,
+ "description" in overrides,
+ ),
+ icon: resolveField(
+ overrides.icon,
+ normalizedPub?.icon,
+ "icon" in overrides,
+ ),
+ theme: resolveField(
+ overrides.theme,
+ normalizedPub?.theme,
+ "theme" in overrides,
+ ),
base_path: overrides.basePath ?? existingBasePath,
- preferences: preferences ? {
- $type: "pub.leaflet.publication#preferences",
- showInDiscover: preferences.showInDiscover,
- showComments: preferences.showComments,
- showMentions: preferences.showMentions,
- showPrevNext: preferences.showPrevNext,
- } : undefined,
+ preferences: preferences
+ ? {
+ $type: "pub.leaflet.publication#preferences",
+ showInDiscover: preferences.showInDiscover,
+ showComments: preferences.showComments,
+ showMentions: preferences.showMentions,
+ showPrevNext: preferences.showPrevNext,
+ showRecommends: preferences.showRecommends,
+ }
+ : undefined,
};
}
@@ -175,17 +196,36 @@ function buildStandardRecord(
return {
$type: "site.standard.publication",
name: overrides.name ?? normalizedPub?.name ?? "",
- description: resolveField(overrides.description, normalizedPub?.description, "description" in overrides),
- icon: resolveField(overrides.icon, normalizedPub?.icon, "icon" in overrides),
- theme: resolveField(overrides.theme, normalizedPub?.theme, "theme" in overrides),
- basicTheme: resolveField(overrides.basicTheme, normalizedPub?.basicTheme, "basicTheme" in overrides),
+ description: resolveField(
+ overrides.description,
+ normalizedPub?.description,
+ "description" in overrides,
+ ),
+ icon: resolveField(
+ overrides.icon,
+ normalizedPub?.icon,
+ "icon" in overrides,
+ ),
+ theme: resolveField(
+ overrides.theme,
+ normalizedPub?.theme,
+ "theme" in overrides,
+ ),
+ basicTheme: resolveField(
+ overrides.basicTheme,
+ normalizedPub?.basicTheme,
+ "basicTheme" in overrides,
+ ),
url: basePath ? `https://${basePath}` : normalizedPub?.url || "",
- preferences: preferences ? {
- showInDiscover: preferences.showInDiscover,
- showComments: preferences.showComments,
- showMentions: preferences.showMentions,
- showPrevNext: preferences.showPrevNext,
- } : undefined,
+ preferences: preferences
+ ? {
+ showInDiscover: preferences.showInDiscover,
+ showComments: preferences.showComments,
+ showMentions: preferences.showMentions,
+ showPrevNext: preferences.showPrevNext,
+ showRecommends: preferences.showRecommends,
+ }
+ : undefined,
};
}
@@ -217,27 +257,30 @@ export async function updatePublication({
iconFile?: File | null;
preferences?: Omit;
}): Promise {
- return withPublicationUpdate(uri, async ({ normalizedPub, existingBasePath, publicationType, agent }) => {
- // Upload icon if provided
- let iconBlob = normalizedPub?.icon;
- if (iconFile && iconFile.size > 0) {
- const buffer = await iconFile.arrayBuffer();
- const uploadResult = await agent.com.atproto.repo.uploadBlob(
- new Uint8Array(buffer),
- { encoding: iconFile.type },
- );
- if (uploadResult.data.blob) {
- iconBlob = uploadResult.data.blob;
+ return withPublicationUpdate(
+ uri,
+ async ({ normalizedPub, existingBasePath, publicationType, agent }) => {
+ // Upload icon if provided
+ let iconBlob = normalizedPub?.icon;
+ if (iconFile && iconFile.size > 0) {
+ const buffer = await iconFile.arrayBuffer();
+ const uploadResult = await agent.com.atproto.repo.uploadBlob(
+ new Uint8Array(buffer),
+ { encoding: iconFile.type },
+ );
+ if (uploadResult.data.blob) {
+ iconBlob = uploadResult.data.blob;
+ }
}
- }
- return buildRecord(normalizedPub, existingBasePath, publicationType, {
- name,
- description,
- icon: iconBlob,
- preferences,
- });
- });
+ return buildRecord(normalizedPub, existingBasePath, publicationType, {
+ name,
+ description,
+ icon: iconBlob,
+ preferences,
+ });
+ },
+ );
}
export async function updatePublicationBasePath({
@@ -247,11 +290,14 @@ export async function updatePublicationBasePath({
uri: string;
base_path: string;
}): Promise {
- return withPublicationUpdate(uri, async ({ normalizedPub, existingBasePath, publicationType }) => {
- return buildRecord(normalizedPub, existingBasePath, publicationType, {
- basePath: base_path,
- });
- });
+ return withPublicationUpdate(
+ uri,
+ async ({ normalizedPub, existingBasePath, publicationType }) => {
+ return buildRecord(normalizedPub, existingBasePath, publicationType, {
+ basePath: base_path,
+ });
+ },
+ );
}
type Color =
@@ -275,58 +321,81 @@ export async function updatePublicationTheme({
accentText: Color;
};
}): Promise {
- return withPublicationUpdate(uri, async ({ normalizedPub, existingBasePath, publicationType, agent }) => {
- // Build theme object
- const themeData = {
- $type: "pub.leaflet.publication#theme" as const,
- backgroundImage: theme.backgroundImage
- ? {
- $type: "pub.leaflet.theme.backgroundImage",
- image: (
- await agent.com.atproto.repo.uploadBlob(
- new Uint8Array(await theme.backgroundImage.arrayBuffer()),
- { encoding: theme.backgroundImage.type },
- )
- )?.data.blob,
- width: theme.backgroundRepeat || undefined,
- repeat: !!theme.backgroundRepeat,
- }
- : theme.backgroundImage === null
- ? undefined
- : normalizedPub?.theme?.backgroundImage,
- backgroundColor: theme.backgroundColor
- ? {
- ...theme.backgroundColor,
- }
- : undefined,
- pageWidth: theme.pageWidth,
- primary: {
- ...theme.primary,
- },
- pageBackground: {
- ...theme.pageBackground,
- },
- showPageBackground: theme.showPageBackground,
- accentBackground: {
- ...theme.accentBackground,
- },
- accentText: {
- ...theme.accentText,
- },
- };
+ return withPublicationUpdate(
+ uri,
+ async ({ normalizedPub, existingBasePath, publicationType, agent }) => {
+ // Build theme object
+ const themeData = {
+ $type: "pub.leaflet.publication#theme" as const,
+ backgroundImage: theme.backgroundImage
+ ? {
+ $type: "pub.leaflet.theme.backgroundImage",
+ image: (
+ await agent.com.atproto.repo.uploadBlob(
+ new Uint8Array(await theme.backgroundImage.arrayBuffer()),
+ { encoding: theme.backgroundImage.type },
+ )
+ )?.data.blob,
+ width: theme.backgroundRepeat || undefined,
+ repeat: !!theme.backgroundRepeat,
+ }
+ : theme.backgroundImage === null
+ ? undefined
+ : normalizedPub?.theme?.backgroundImage,
+ backgroundColor: theme.backgroundColor
+ ? {
+ ...theme.backgroundColor,
+ }
+ : undefined,
+ pageWidth: theme.pageWidth,
+ primary: {
+ ...theme.primary,
+ },
+ pageBackground: {
+ ...theme.pageBackground,
+ },
+ showPageBackground: theme.showPageBackground,
+ accentBackground: {
+ ...theme.accentBackground,
+ },
+ accentText: {
+ ...theme.accentText,
+ },
+ };
- // Derive basicTheme from the theme colors for site.standard.publication
- const basicTheme: NormalizedPublication["basicTheme"] = {
- $type: "site.standard.theme.basic",
- background: { $type: "site.standard.theme.color#rgb", r: theme.backgroundColor.r, g: theme.backgroundColor.g, b: theme.backgroundColor.b },
- foreground: { $type: "site.standard.theme.color#rgb", r: theme.primary.r, g: theme.primary.g, b: theme.primary.b },
- accent: { $type: "site.standard.theme.color#rgb", r: theme.accentBackground.r, g: theme.accentBackground.g, b: theme.accentBackground.b },
- accentForeground: { $type: "site.standard.theme.color#rgb", r: theme.accentText.r, g: theme.accentText.g, b: theme.accentText.b },
- };
+ // Derive basicTheme from the theme colors for site.standard.publication
+ const basicTheme: NormalizedPublication["basicTheme"] = {
+ $type: "site.standard.theme.basic",
+ background: {
+ $type: "site.standard.theme.color#rgb",
+ r: theme.backgroundColor.r,
+ g: theme.backgroundColor.g,
+ b: theme.backgroundColor.b,
+ },
+ foreground: {
+ $type: "site.standard.theme.color#rgb",
+ r: theme.primary.r,
+ g: theme.primary.g,
+ b: theme.primary.b,
+ },
+ accent: {
+ $type: "site.standard.theme.color#rgb",
+ r: theme.accentBackground.r,
+ g: theme.accentBackground.g,
+ b: theme.accentBackground.b,
+ },
+ accentForeground: {
+ $type: "site.standard.theme.color#rgb",
+ r: theme.accentText.r,
+ g: theme.accentText.g,
+ b: theme.accentText.b,
+ },
+ };
- return buildRecord(normalizedPub, existingBasePath, publicationType, {
- theme: themeData,
- basicTheme,
- });
- });
+ return buildRecord(normalizedPub, existingBasePath, publicationType, {
+ theme: themeData,
+ basicTheme,
+ });
+ },
+ );
}
diff --git a/components/Canvas.tsx b/components/Canvas.tsx
index 0faa3302..118c163e 100644
--- a/components/Canvas.tsx
+++ b/components/Canvas.tsx
@@ -19,10 +19,11 @@ import { Popover } from "./Popover";
import { Separator } from "./Layout";
import { CommentTiny } from "./Icons/CommentTiny";
import { QuoteTiny } from "./Icons/QuoteTiny";
-import { PublicationMetadata } from "./Pages/PublicationMetadata";
+import { AddTags, PublicationMetadata } from "./Pages/PublicationMetadata";
import { useLeafletPublicationData } from "./PageSWRDataProvider";
import { useHandleCanvasDrop } from "./Blocks/useHandleCanvasDrop";
import { useBlockMouseHandlers } from "./Blocks/useBlockMouseHandlers";
+import { RecommendTinyEmpty } from "./Icons/RecommendTiny";
export function Canvas(props: {
entityID: string;
@@ -168,20 +169,34 @@ const CanvasMetadata = (props: { isSubpage: boolean | undefined }) => {
if (!normalizedPublication) return null;
let showComments = normalizedPublication.preferences?.showComments !== false;
let showMentions = normalizedPublication.preferences?.showMentions !== false;
+ let showRecommends =
+ normalizedPublication.preferences?.showRecommends !== false;
return (
+ {showRecommends && (
+
+ —
+
+ )}
{showComments && (
—
)}
- {showComments && (
+ {showMentions && (
—
)}
+ {showMentions !== false ||
+ showComments !== false ||
+ showRecommends === false ? (
+
+ ) : null}
+
+
{!props.isSubpage && (
<>
@@ -191,7 +206,7 @@ const CanvasMetadata = (props: { isSubpage: boolean | undefined }) => {
className="flex flex-col gap-2 p-0! max-w-sm w-[1000px]"
trigger={
}
>
-
+
>
)}
diff --git a/components/Icons/RecommendTiny.tsx b/components/Icons/RecommendTiny.tsx
new file mode 100644
index 00000000..7ec07c14
--- /dev/null
+++ b/components/Icons/RecommendTiny.tsx
@@ -0,0 +1,37 @@
+import { Props } from "./Props";
+
+export const RecommendTinyFilled = (props: Props) => {
+ return (
+
+
+
+ );
+};
+
+export const RecommendTinyEmpty = (props: Props) => {
+ return (
+
+
+
+ );
+};
diff --git a/components/InteractionsPreview.tsx b/components/InteractionsPreview.tsx
index 30ddefbb..9f393f86 100644
--- a/components/InteractionsPreview.tsx
+++ b/components/InteractionsPreview.tsx
@@ -7,35 +7,36 @@ import { Tag } from "./Tags";
import { Popover } from "./Popover";
import { TagTiny } from "./Icons/TagTiny";
import { SpeedyLink } from "./SpeedyLink";
+import { RecommendButton } from "./RecommendButton";
export const InteractionPreview = (props: {
quotesCount: number;
commentsCount: number;
+ recommendsCount: number;
+ documentUri: string;
tags?: string[];
postUrl: string;
showComments: boolean;
showMentions: boolean;
+ showRecommends: boolean;
share?: boolean;
}) => {
let smoker = useSmoker();
let interactionsAvailable =
(props.quotesCount > 0 && props.showMentions) ||
- (props.showComments !== false && props.commentsCount > 0);
+ (props.showComments !== false && props.commentsCount > 0) ||
+ (props.showRecommends !== false && props.recommendsCount > 0);
const tagsCount = props.tags?.length || 0;
return (
-
- {tagsCount === 0 ? null : (
- <>
-
- {interactionsAvailable || props.share ? (
-
- ) : null}
- >
+
+ {props.showRecommends === false ? null : (
+
)}
{!props.showMentions || props.quotesCount === 0 ? null : (
@@ -56,11 +57,16 @@ export const InteractionPreview = (props: {
{props.commentsCount}
)}
- {interactionsAvailable && props.share ? (
-
- ) : null}
+ {tagsCount === 0 ? null : (
+ <>
+ {interactionsAvailable ?
: null}
+
+ >
+ )}
{props.share && (
<>
+
+
{
+export const PublicationMetadata = (props: { noInteractions?: boolean }) => {
let { rep } = useReplicache();
- let { data: pub, normalizedDocument, normalizedPublication } = useLeafletPublicationData();
+ let {
+ data: pub,
+ normalizedDocument,
+ normalizedPublication,
+ } = useLeafletPublicationData();
let { identity } = useIdentityData();
let title = useSubscribe(rep, (tx) => tx.get("publication_title"));
let description = useSubscribe(rep, (tx) =>
@@ -114,27 +119,37 @@ export const PublicationMetadata = () => {
) : (
Draft
)}
-
- {tags && (
- <>
-
- {normalizedPublication?.preferences?.showMentions !== false ||
- normalizedPublication?.preferences?.showComments !== false ? (
-
- ) : null}
- >
- )}
- {normalizedPublication?.preferences?.showMentions !== false && (
-
- —
-
- )}
- {normalizedPublication?.preferences?.showComments !== false && (
-
- —
-
- )}
-
+ {!props.noInteractions && (
+
+ {normalizedPublication?.preferences?.showRecommends !== false && (
+
+ —
+
+ )}
+
+ {normalizedPublication?.preferences?.showMentions !== false && (
+
+ —
+
+ )}
+ {normalizedPublication?.preferences?.showComments !== false && (
+
+ —
+
+ )}
+ {tags && (
+ <>
+ {normalizedPublication?.preferences?.showRecommends !==
+ false ||
+ normalizedPublication?.preferences?.showMentions !== false ||
+ normalizedPublication?.preferences?.showComments !== false ? (
+
+ ) : null}
+
+ >
+ )}
+
+ )}
>
}
/>
@@ -238,7 +253,7 @@ export const PublicationMetadataPreview = () => {
);
};
-const AddTags = () => {
+export const AddTags = () => {
let { data: pub, normalizedDocument } = useLeafletPublicationData();
let { rep } = useReplicache();
@@ -251,7 +266,10 @@ const AddTags = () => {
let tags: string[] = [];
if (Array.isArray(replicacheTags)) {
tags = replicacheTags;
- } else if (normalizedDocument?.tags && Array.isArray(normalizedDocument.tags)) {
+ } else if (
+ normalizedDocument?.tags &&
+ Array.isArray(normalizedDocument.tags)
+ ) {
tags = normalizedDocument.tags as string[];
}
diff --git a/components/PostListing.tsx b/components/PostListing.tsx
index cf25dd8e..ef6105a4 100644
--- a/components/PostListing.tsx
+++ b/components/PostListing.tsx
@@ -53,6 +53,7 @@ export const PostListing = (props: Post) => {
pubRecord?.preferences?.showComments === false
? 0
: props.documents.comments_on_documents?.[0]?.count || 0;
+ let recommends = props.documents.recommends_on_documents?.[0]?.count || 0;
let tags = (postRecord?.tags as string[] | undefined) || [];
// For standalone posts, link directly to the document
@@ -88,7 +89,9 @@ export const PostListing = (props: Post) => {
>
{postRecord.title}
- {postRecord.description}
+
+ {postRecord.description}
+
{props.publication && pubRecord && (
{
postUrl={postHref}
quotesCount={quotes}
commentsCount={comments}
+ recommendsCount={recommends}
+ documentUri={props.documents.uri}
tags={tags}
showComments={pubRecord?.preferences?.showComments !== false}
showMentions={pubRecord?.preferences?.showMentions !== false}
+ showRecommends={
+ pubRecord?.preferences?.showRecommends !== false
+ }
share
/>
diff --git a/components/RecommendButton.tsx b/components/RecommendButton.tsx
new file mode 100644
index 00000000..12cdd260
--- /dev/null
+++ b/components/RecommendButton.tsx
@@ -0,0 +1,173 @@
+"use client";
+
+import { useState } from "react";
+import useSWR, { mutate } from "swr";
+import { create, windowScheduler } from "@yornaath/batshit";
+import { RecommendTinyEmpty, RecommendTinyFilled } from "./Icons/RecommendTiny";
+import {
+ recommendAction,
+ unrecommendAction,
+} from "app/lish/[did]/[publication]/[rkey]/Interactions/recommendAction";
+import { callRPC } from "app/api/rpc/client";
+import { useSmoker, useToaster } from "./Toast";
+import { OAuthErrorMessage, isOAuthSessionError } from "./OAuthError";
+import { ButtonSecondary } from "./Buttons";
+import { Separator } from "./Layout";
+
+// Create a batcher for recommendation checks
+// Batches requests made within 10ms window
+const recommendationBatcher = create({
+ fetcher: async (documentUris: string[]) => {
+ const response = await callRPC("get_user_recommendations", {
+ documentUris,
+ });
+ return response.result;
+ },
+ resolver: (results, documentUri) => results[documentUri] ?? false,
+ scheduler: windowScheduler(10),
+});
+
+const getRecommendationKey = (documentUri: string) =>
+ `recommendation:${documentUri}`;
+
+function useUserRecommendation(documentUri: string) {
+ const { data: hasRecommended, isLoading } = useSWR(
+ getRecommendationKey(documentUri),
+ () => recommendationBatcher.fetch(documentUri),
+ );
+
+ return {
+ hasRecommended: hasRecommended ?? false,
+ isLoading,
+ };
+}
+
+function mutateRecommendation(documentUri: string, hasRecommended: boolean) {
+ mutate(getRecommendationKey(documentUri), hasRecommended, {
+ revalidate: false,
+ });
+}
+
+/**
+ * RecommendButton that fetches the user's recommendation status asynchronously.
+ * Uses SWR with batched requests for efficient fetching when many buttons are rendered.
+ */
+export function RecommendButton(props: {
+ documentUri: string;
+ recommendsCount: number;
+ className?: string;
+ expanded?: boolean;
+}) {
+ const { hasRecommended, isLoading } = useUserRecommendation(
+ props.documentUri,
+ );
+ const [count, setCount] = useState(props.recommendsCount);
+ const [isPending, setIsPending] = useState(false);
+ const [optimisticRecommended, setOptimisticRecommended] = useState<
+ boolean | null
+ >(null);
+ const toaster = useToaster();
+ const smoker = useSmoker();
+
+ // Use optimistic state if set, otherwise use fetched state
+ const displayRecommended =
+ optimisticRecommended !== null ? optimisticRecommended : hasRecommended;
+
+ const handleClick = async (e: React.MouseEvent) => {
+ if (isPending || isLoading) return;
+
+ const currentlyRecommended = displayRecommended;
+ setIsPending(true);
+ setOptimisticRecommended(!currentlyRecommended);
+ setCount((c) => (currentlyRecommended ? c - 1 : c + 1));
+
+ if (!currentlyRecommended) {
+ smoker({
+ position: {
+ x: e.clientX,
+ y: e.clientY - 16,
+ },
+ text: Recc'd!
,
+ });
+ }
+
+ const result = currentlyRecommended
+ ? await unrecommendAction({ document: props.documentUri })
+ : await recommendAction({ document: props.documentUri });
+ if (!result.success) {
+ // Revert optimistic update
+ setOptimisticRecommended(null);
+ setCount((c) => (currentlyRecommended ? c + 1 : c - 1));
+ setIsPending(false);
+
+ toaster({
+ content: isOAuthSessionError(result.error) ? (
+
+ ) : (
+ "oh no! error!"
+ ),
+ type: "error",
+ });
+ return;
+ }
+
+ // Update the SWR cache to match the new state
+ mutateRecommendation(props.documentUri, !currentlyRecommended);
+ setOptimisticRecommended(null);
+ setIsPending(false);
+ };
+
+ if (props.expanded)
+ return (
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ handleClick(e);
+ }}
+ >
+ {displayRecommended ? (
+
+ ) : (
+
+ )}
+
+ {count > 0 && (
+ <>
+
+ {count}
+
+
+ >
+ )}
+ {displayRecommended ? "Recommended!" : "Recommend"}
+
+
+ );
+
+ return (
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ handleClick(e);
+ }}
+ disabled={isPending || isLoading}
+ className={`recommendButton relative flex gap-1 items-center hover:text-accent-contrast ${props.className || ""}`}
+ aria-label={displayRecommended ? "Remove recommend" : "Recommend"}
+ >
+ {displayRecommended ? (
+
+ ) : (
+
+ )}
+ {count > 0 && (
+
+ {count}
+
+ )}
+
+ );
+}
diff --git a/contexts/DocumentContext.tsx b/contexts/DocumentContext.tsx
index 467d9ad9..d3157d69 100644
--- a/contexts/DocumentContext.tsx
+++ b/contexts/DocumentContext.tsx
@@ -21,6 +21,7 @@ export type DocumentContextValue = Pick<
| "comments"
| "mentions"
| "leafletId"
+ | "recommendsCount"
>;
const DocumentContext = createContext(null);
diff --git a/lexicons/api/types/pub/leaflet/publication.ts b/lexicons/api/types/pub/leaflet/publication.ts
index 88314a28..51143ee0 100644
--- a/lexicons/api/types/pub/leaflet/publication.ts
+++ b/lexicons/api/types/pub/leaflet/publication.ts
@@ -1,89 +1,94 @@
/**
* GENERATED CODE - DO NOT MODIFY
*/
-import { type ValidationResult, BlobRef } from '@atproto/lexicon'
-import { CID } from 'multiformats/cid'
-import { validate as _validate } from '../../../lexicons'
-import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util'
-import type * as PubLeafletThemeColor from './theme/color'
-import type * as PubLeafletThemeBackgroundImage from './theme/backgroundImage'
+import { type ValidationResult, BlobRef } from "@atproto/lexicon";
+import { CID } from "multiformats/cid";
+import { validate as _validate } from "../../../lexicons";
+import {
+ type $Typed,
+ is$typed as _is$typed,
+ type OmitKey,
+} from "../../../util";
+import type * as PubLeafletThemeColor from "./theme/color";
+import type * as PubLeafletThemeBackgroundImage from "./theme/backgroundImage";
const is$typed = _is$typed,
- validate = _validate
-const id = 'pub.leaflet.publication'
+ validate = _validate;
+const id = "pub.leaflet.publication";
export interface Record {
- $type: 'pub.leaflet.publication'
- name: string
- base_path?: string
- description?: string
- icon?: BlobRef
- theme?: Theme
- preferences?: Preferences
- [k: string]: unknown
+ $type: "pub.leaflet.publication";
+ name: string;
+ base_path?: string;
+ description?: string;
+ icon?: BlobRef;
+ theme?: Theme;
+ preferences?: Preferences;
+ [k: string]: unknown;
}
-const hashRecord = 'main'
+const hashRecord = "main";
export function isRecord(v: V) {
- return is$typed(v, id, hashRecord)
+ return is$typed(v, id, hashRecord);
}
export function validateRecord(v: V) {
- return validate(v, id, hashRecord, true)
+ return validate(v, id, hashRecord, true);
}
export interface Preferences {
- $type?: 'pub.leaflet.publication#preferences'
- showInDiscover: boolean
- showComments: boolean
- showMentions: boolean
- showPrevNext: boolean
+ $type?: "pub.leaflet.publication#preferences";
+ showInDiscover: boolean;
+ showComments: boolean;
+ showMentions: boolean;
+ showPrevNext: boolean;
+ showRecommends: boolean;
}
-const hashPreferences = 'preferences'
+const hashPreferences = "preferences";
export function isPreferences(v: V) {
- return is$typed(v, id, hashPreferences)
+ return is$typed(v, id, hashPreferences);
}
export function validatePreferences(v: V) {
- return validate(v, id, hashPreferences)
+ return validate(v, id, hashPreferences);
}
export interface Theme {
- $type?: 'pub.leaflet.publication#theme'
+ $type?: "pub.leaflet.publication#theme";
backgroundColor?:
| $Typed
| $Typed
- | { $type: string }
- backgroundImage?: PubLeafletThemeBackgroundImage.Main
- pageWidth?: number
+ | { $type: string };
+ backgroundImage?: PubLeafletThemeBackgroundImage.Main;
+ pageWidth?: number;
primary?:
| $Typed
| $Typed
- | { $type: string }
+ | { $type: string };
pageBackground?:
| $Typed
| $Typed
- | { $type: string }
- showPageBackground: boolean
+ | { $type: string };
+ showPageBackground: boolean;
accentBackground?:
| $Typed
| $Typed
- | { $type: string }
+ | { $type: string };
accentText?:
| $Typed
| $Typed
- | { $type: string }
+ | { $type: string };
}
-const hashTheme = 'theme'
+const hashTheme = "theme";
export function isTheme(v: V) {
- return is$typed(v, id, hashTheme)
+ return is$typed(v, id, hashTheme);
}
export function validateTheme(v: V) {
- return validate(v, id, hashTheme)
+ return validate(v, id, hashTheme);
}
diff --git a/lexicons/api/types/site/standard/publication.ts b/lexicons/api/types/site/standard/publication.ts
index 2daf12b6..08f80d9c 100644
--- a/lexicons/api/types/site/standard/publication.ts
+++ b/lexicons/api/types/site/standard/publication.ts
@@ -1,53 +1,58 @@
/**
* GENERATED CODE - DO NOT MODIFY
*/
-import { type ValidationResult, BlobRef } from '@atproto/lexicon'
-import { CID } from 'multiformats/cid'
-import { validate as _validate } from '../../../lexicons'
-import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util'
-import type * as SiteStandardThemeBasic from './theme/basic'
-import type * as PubLeafletPublication from '../../pub/leaflet/publication'
+import { type ValidationResult, BlobRef } from "@atproto/lexicon";
+import { CID } from "multiformats/cid";
+import { validate as _validate } from "../../../lexicons";
+import {
+ type $Typed,
+ is$typed as _is$typed,
+ type OmitKey,
+} from "../../../util";
+import type * as SiteStandardThemeBasic from "./theme/basic";
+import type * as PubLeafletPublication from "../../pub/leaflet/publication";
const is$typed = _is$typed,
- validate = _validate
-const id = 'site.standard.publication'
+ validate = _validate;
+const id = "site.standard.publication";
export interface Record {
- $type: 'site.standard.publication'
- basicTheme?: SiteStandardThemeBasic.Main
- theme?: $Typed | { $type: string }
- description?: string
- icon?: BlobRef
- name: string
- preferences?: Preferences
- url: string
- [k: string]: unknown
+ $type: "site.standard.publication";
+ basicTheme?: SiteStandardThemeBasic.Main;
+ theme?: $Typed | { $type: string };
+ description?: string;
+ icon?: BlobRef;
+ name: string;
+ preferences?: Preferences;
+ url: string;
+ [k: string]: unknown;
}
-const hashRecord = 'main'
+const hashRecord = "main";
export function isRecord(v: V) {
- return is$typed(v, id, hashRecord)
+ return is$typed(v, id, hashRecord);
}
export function validateRecord(v: V) {
- return validate(v, id, hashRecord, true)
+ return validate(v, id, hashRecord, true);
}
export interface Preferences {
- $type?: 'site.standard.publication#preferences'
- showInDiscover: boolean
- showComments: boolean
- showMentions: boolean
- showPrevNext: boolean
+ $type?: "site.standard.publication#preferences";
+ showInDiscover: boolean;
+ showComments: boolean;
+ showMentions: boolean;
+ showPrevNext: boolean;
+ showRecommends: boolean;
}
-const hashPreferences = 'preferences'
+const hashPreferences = "preferences";
export function isPreferences(v: V) {
- return is$typed(v, id, hashPreferences)
+ return is$typed(v, id, hashPreferences);
}
export function validatePreferences(v: V) {
- return validate(v, id, hashPreferences)
+ return validate(v, id, hashPreferences);
}
diff --git a/lexicons/pub/leaflet/publication.json b/lexicons/pub/leaflet/publication.json
index 0627addf..c98b1eb6 100644
--- a/lexicons/pub/leaflet/publication.json
+++ b/lexicons/pub/leaflet/publication.json
@@ -59,6 +59,10 @@
"showPrevNext": {
"type": "boolean",
"default": true
+ },
+ "showRecommends": {
+ "type": "boolean",
+ "default": true
}
}
},
diff --git a/lexicons/site/standard/publication.json b/lexicons/site/standard/publication.json
index b1449052..7fdbcc22 100644
--- a/lexicons/site/standard/publication.json
+++ b/lexicons/site/standard/publication.json
@@ -58,6 +58,10 @@
"showPrevNext": {
"default": false,
"type": "boolean"
+ },
+ "showRecommends": {
+ "default": true,
+ "type": "boolean"
}
},
"type": "object"
diff --git a/lexicons/src/normalize.ts b/lexicons/src/normalize.ts
index d4890e07..4f7294c7 100644
--- a/lexicons/src/normalize.ts
+++ b/lexicons/src/normalize.ts
@@ -50,7 +50,7 @@ export type NormalizedPublication = {
* Checks if the record is a pub.leaflet.document
*/
export function isLeafletDocument(
- record: unknown
+ record: unknown,
): record is PubLeafletDocument.Record {
if (!record || typeof record !== "object") return false;
const r = record as Record;
@@ -65,7 +65,7 @@ export function isLeafletDocument(
* Checks if the record is a site.standard.document
*/
export function isStandardDocument(
- record: unknown
+ record: unknown,
): record is SiteStandardDocument.Record {
if (!record || typeof record !== "object") return false;
const r = record as Record;
@@ -76,7 +76,7 @@ export function isStandardDocument(
* Checks if the record is a pub.leaflet.publication
*/
export function isLeafletPublication(
- record: unknown
+ record: unknown,
): record is PubLeafletPublication.Record {
if (!record || typeof record !== "object") return false;
const r = record as Record;
@@ -91,7 +91,7 @@ export function isLeafletPublication(
* Checks if the record is a site.standard.publication
*/
export function isStandardPublication(
- record: unknown
+ record: unknown,
): record is SiteStandardPublication.Record {
if (!record || typeof record !== "object") return false;
const r = record as Record;
@@ -106,7 +106,7 @@ function extractRgb(
| $Typed
| $Typed
| { $type: string }
- | undefined
+ | undefined,
): { r: number; g: number; b: number } | undefined {
if (!color || typeof color !== "object") return undefined;
const c = color as Record;
@@ -124,12 +124,13 @@ function extractRgb(
* Converts a pub.leaflet theme to a site.standard.theme.basic format
*/
export function leafletThemeToBasicTheme(
- theme: PubLeafletPublication.Theme | undefined
+ theme: PubLeafletPublication.Theme | undefined,
): SiteStandardThemeBasic.Main | undefined {
if (!theme) return undefined;
const background = extractRgb(theme.backgroundColor);
- const accent = extractRgb(theme.accentBackground) || extractRgb(theme.primary);
+ const accent =
+ extractRgb(theme.accentBackground) || extractRgb(theme.primary);
const accentForeground = extractRgb(theme.accentText);
// If we don't have the required colors, return undefined
@@ -160,7 +161,10 @@ export function leafletThemeToBasicTheme(
* @param uri - Optional document URI, used to extract the rkey for the path field when normalizing pub.leaflet records
* @returns A normalized document in site.standard format, or null if invalid/unrecognized
*/
-export function normalizeDocument(record: unknown, uri?: string): NormalizedDocument | null {
+export function normalizeDocument(
+ record: unknown,
+ uri?: string,
+): NormalizedDocument | null {
if (!record || typeof record !== "object") return null;
// Pass through site.standard records directly (theme is already in correct format if present)
@@ -219,7 +223,7 @@ export function normalizeDocument(record: unknown, uri?: string): NormalizedDocu
* @returns A normalized publication in site.standard format, or null if invalid/unrecognized
*/
export function normalizePublication(
- record: unknown
+ record: unknown,
): NormalizedPublication | null {
if (!record || typeof record !== "object") return null;
@@ -268,6 +272,7 @@ export function normalizePublication(
showComments: record.preferences.showComments,
showMentions: record.preferences.showMentions,
showPrevNext: record.preferences.showPrevNext,
+ showRecommends: record.preferences.showRecommends,
}
: undefined;
@@ -290,7 +295,7 @@ export function normalizePublication(
* Type guard to check if a normalized document has leaflet content
*/
export function hasLeafletContent(
- doc: NormalizedDocument
+ doc: NormalizedDocument,
): doc is NormalizedDocument & {
content: $Typed;
} {
@@ -304,7 +309,7 @@ export function hasLeafletContent(
* Gets the pages array from a normalized document, handling both formats
*/
export function getDocumentPages(
- doc: NormalizedDocument
+ doc: NormalizedDocument,
): PubLeafletContent.Main["pages"] | undefined {
if (!doc.content) return undefined;
diff --git a/lexicons/src/publication.ts b/lexicons/src/publication.ts
index 57bbbef9..cf5904d2 100644
--- a/lexicons/src/publication.ts
+++ b/lexicons/src/publication.ts
@@ -29,6 +29,7 @@ export const PubLeafletPublication: LexiconDoc = {
showComments: { type: "boolean", default: true },
showMentions: { type: "boolean", default: true },
showPrevNext: { type: "boolean", default: true },
+ showRecommends: { type: "boolean", default: true },
},
},
theme: {
diff --git a/package-lock.json b/package-lock.json
index 08cf16a1..bffdd153 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -38,6 +38,7 @@
"@vercel/analytics": "^1.5.0",
"@vercel/functions": "^2.2.12",
"@vercel/sdk": "^1.11.4",
+ "@yornaath/batshit": "^0.14.0",
"babel-plugin-react-compiler": "^19.1.0-rc.1",
"base64-js": "^1.5.1",
"colorjs.io": "^0.5.2",
@@ -8661,6 +8662,21 @@
}
}
},
+ "node_modules/@yornaath/batshit": {
+ "version": "0.14.0",
+ "resolved": "https://registry.npmjs.org/@yornaath/batshit/-/batshit-0.14.0.tgz",
+ "integrity": "sha512-0I+xMi5JoRs3+qVXXhk2AmsEl43MwrG+L+VW+nqw/qQqMFtgRPszLaxhJCfsBKnjfJ0gJzTI1Q9Q9+y903HyHQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@yornaath/batshit-devtools": "^1.7.1"
+ }
+ },
+ "node_modules/@yornaath/batshit-devtools": {
+ "version": "1.7.1",
+ "resolved": "https://registry.npmjs.org/@yornaath/batshit-devtools/-/batshit-devtools-1.7.1.tgz",
+ "integrity": "sha512-AyttV1Njj5ug+XqEWY1smV45dTWMlWKtj1B8jcFYgBKUFyUlF/qEhD+iP1E5UaRYW6hQRYD9T2WNDwFTrOMWzQ==",
+ "license": "MIT"
+ },
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
diff --git a/package.json b/package.json
index 8c589ee7..5e9e2ee4 100644
--- a/package.json
+++ b/package.json
@@ -49,6 +49,7 @@
"@vercel/analytics": "^1.5.0",
"@vercel/functions": "^2.2.12",
"@vercel/sdk": "^1.11.4",
+ "@yornaath/batshit": "^0.14.0",
"babel-plugin-react-compiler": "^19.1.0-rc.1",
"base64-js": "^1.5.1",
"colorjs.io": "^0.5.2",
--
2.51.2
From 8ae93e160e0a14b929337d19620e98f59992bde4 Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Sun, 8 Feb 2026 05:09:42 +0400
Subject: [PATCH 09/51] add oauth scopes
---
app/api/oauth/[route]/oauth-metadata.ts | 3 ++-
app/api/oauth/[route]/route.ts | 3 ++-
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/app/api/oauth/[route]/oauth-metadata.ts b/app/api/oauth/[route]/oauth-metadata.ts
index 8375f993..855fc0bc 100644
--- a/app/api/oauth/[route]/oauth-metadata.ts
+++ b/app/api/oauth/[route]/oauth-metadata.ts
@@ -7,7 +7,8 @@ const hostname =
? "http://localhost:3000"
: "https://leaflet.pub";
-const scope = "atproto transition:generic transition:email";
+const scope =
+ "atproto transition:generic transition:email include:pub.leaflet.authFullPermissions include:site.standard.authFull include:app.bsky.authCreatePosts";
const localconfig: OAuthClientMetadataInput = {
client_id: `http://localhost/?redirect_uri=${encodeURI(`http://127.0.0.1:3000/api/oauth/callback`)}&scope=${encodeURIComponent(scope)}`,
client_name: `Leaflet`,
diff --git a/app/api/oauth/[route]/route.ts b/app/api/oauth/[route]/route.ts
index db2ab8f4..3d2c7641 100644
--- a/app/api/oauth/[route]/route.ts
+++ b/app/api/oauth/[route]/route.ts
@@ -42,7 +42,8 @@ export async function GET(
const ac = new AbortController();
const url = await client.authorize(handle || "https://bsky.social", {
- scope: "atproto transition:generic transition:email",
+ scope:
+ "atproto transition:email include:pub.leaflet.authFullPermissions include:site.standard.authFull include:app.bsky.authCreatePosts",
signal: ac.signal,
state: JSON.stringify(state),
});
--
2.51.2
From dbf7cf24e7b43fd2541d024c7c92848929e15f02 Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Sun, 8 Feb 2026 10:07:24 +0400
Subject: [PATCH 10/51] add per post interactions
---
actions/publishToPublication.ts | 21 +
app/[leaflet_id]/Sidebar.tsx | 2 +
app/[leaflet_id]/actions/PublishButton.tsx | 10 +
app/[leaflet_id]/publish/PublishPost.tsx | 10 +
app/api/rpc/[command]/pull.ts | 7 +
.../[rkey]/DocumentPageRenderer.tsx | 3 +-
.../[did]/[publication]/[rkey]/PostPages.tsx | 8 +-
components/Canvas.tsx | 21 +-
components/Pages/PublicationMetadata.tsx | 25 +-
components/PostListing.tsx | 13 +-
components/PostSettings.tsx | 96 +++++
lexicons/api/lexicons.ts | 17 +
lexicons/api/types/pub/leaflet/document.ts | 1 +
lexicons/api/types/pub/leaflet/publication.ts | 84 ++--
lexicons/api/types/site/standard/document.ts | 1 +
.../api/types/site/standard/publication.ts | 62 ++-
lexicons/pub/leaflet/document.json | 4 +
lexicons/site/standard/document.json | 5 +
lexicons/src/document.ts | 4 +
lexicons/src/normalize.ts | 10 +
package-lock.json | 397 +-----------------
package.json | 1 -
src/replicache/mutations.ts | 13 +
src/utils/mergePreferences.ts | 24 ++
supabase/database.types.ts | 6 +
...260208000000_add_preferences_to_drafts.sql | 2 +
26 files changed, 356 insertions(+), 491 deletions(-)
create mode 100644 components/PostSettings.tsx
create mode 100644 src/utils/mergePreferences.ts
create mode 100644 supabase/migrations/20260208000000_add_preferences_to_drafts.sql
diff --git a/actions/publishToPublication.ts b/actions/publishToPublication.ts
index db3c789e..a1a93592 100644
--- a/actions/publishToPublication.ts
+++ b/actions/publishToPublication.ts
@@ -78,6 +78,7 @@ export async function publishToPublication({
cover_image,
entitiesToDelete,
publishedAt,
+ postPreferences,
}: {
root_entity: string;
publication_uri?: string;
@@ -88,6 +89,11 @@ export async function publishToPublication({
cover_image?: string | null;
entitiesToDelete?: string[];
publishedAt?: string;
+ postPreferences?: {
+ showComments?: boolean;
+ showMentions?: boolean;
+ showRecommends?: boolean;
+ } | null;
}): Promise {
let identity = await getIdentityData();
if (!identity || !identity.atp_did) {
@@ -175,6 +181,9 @@ export async function publishToPublication({
};
}
+ // Resolve preferences: explicit param > draft DB value
+ const preferences = postPreferences ?? draft?.preferences;
+
// Extract theme for standalone documents (not for publications)
let theme: PubLeafletPublication.Theme | undefined;
if (!publication_uri) {
@@ -245,6 +254,12 @@ export async function publishToPublication({
...(coverImageBlob && { coverImage: coverImageBlob }),
// Include theme for standalone documents (not for publication documents)
...(!publication_uri && theme && { theme }),
+ ...(preferences && {
+ preferences: {
+ $type: "pub.leaflet.publication#preferences" as const,
+ ...preferences,
+ },
+ }),
content: {
$type: "pub.leaflet.content" as const,
pages: pagesArray,
@@ -257,6 +272,12 @@ export async function publishToPublication({
author: credentialSession.did!,
...(publication_uri && { publication: publication_uri }),
...(theme && { theme }),
+ ...(preferences && {
+ preferences: {
+ $type: "pub.leaflet.publication#preferences" as const,
+ ...preferences,
+ },
+ }),
title: title || "Untitled",
description: description || "",
...(tags !== undefined && { tags }),
diff --git a/app/[leaflet_id]/Sidebar.tsx b/app/[leaflet_id]/Sidebar.tsx
index 568ed74d..14ac86cc 100644
--- a/app/[leaflet_id]/Sidebar.tsx
+++ b/app/[leaflet_id]/Sidebar.tsx
@@ -8,6 +8,7 @@ import { useLeafletPublicationData } from "components/PageSWRDataProvider";
import { ShareOptions } from "app/[leaflet_id]/actions/ShareOptions";
import { ThemePopover } from "components/ThemeManager/ThemeSetter";
import { PublishButton } from "./actions/PublishButton";
+import { PostSettings } from "components/PostSettings";
import { Watermark } from "components/Watermark";
import { BackToPubButton } from "./actions/BackToPubButton";
import { useIdentityData } from "components/IdentityProvider";
@@ -30,6 +31,7 @@ export function LeafletSidebar() {
+
diff --git a/app/[leaflet_id]/actions/PublishButton.tsx b/app/[leaflet_id]/actions/PublishButton.tsx
index 0e6c4bc2..7ba596ef 100644
--- a/app/[leaflet_id]/actions/PublishButton.tsx
+++ b/app/[leaflet_id]/actions/PublishButton.tsx
@@ -96,6 +96,15 @@ const UpdateButton = () => {
tx.get("publication_cover_image"),
);
+ // Get post preferences from Replicache state
+ let postPreferences = useSubscribe(rep, (tx) =>
+ tx.get<{
+ showComments?: boolean;
+ showMentions?: boolean;
+ showRecommends?: boolean;
+ } | null>("post_preferences"),
+ );
+
// Get local published at from Replicache (session-only state, not persisted to DB)
let publishedAt = useLocalPublishedAt((s) =>
pub?.doc ? s[pub?.doc] : undefined,
@@ -118,6 +127,7 @@ const UpdateButton = () => {
tags: currentTags,
cover_image: coverImage,
publishedAt: publishedAt?.toISOString(),
+ postPreferences,
});
setIsLoading(false);
mutate();
diff --git a/app/[leaflet_id]/publish/PublishPost.tsx b/app/[leaflet_id]/publish/PublishPost.tsx
index adf48c04..20c3e202 100644
--- a/app/[leaflet_id]/publish/PublishPost.tsx
+++ b/app/[leaflet_id]/publish/PublishPost.tsx
@@ -91,6 +91,15 @@ const PublishPostForm = (
tx.get("publication_cover_image"),
);
+ // Get post preferences from Replicache state
+ let postPreferences = useSubscribe(rep, (tx) =>
+ tx.get<{
+ showComments?: boolean;
+ showMentions?: boolean;
+ showRecommends?: boolean;
+ } | null>("post_preferences"),
+ );
+
// Use Replicache tags only when we have a draft
const currentTags = props.hasDraft
? Array.isArray(replicacheTags)
@@ -124,6 +133,7 @@ const PublishPostForm = (
cover_image: replicacheCoverImage,
entitiesToDelete: props.entitiesToDelete,
publishedAt: localPublishedAt?.toISOString() || new Date().toISOString(),
+ postPreferences,
});
if (!result.success) {
diff --git a/app/api/rpc/[command]/pull.ts b/app/api/rpc/[command]/pull.ts
index ea3b3845..31ae3ac6 100644
--- a/app/api/rpc/[command]/pull.ts
+++ b/app/api/rpc/[command]/pull.ts
@@ -9,6 +9,7 @@ import { FactWithIndexes } from "src/replicache/utils";
import type { Attribute } from "src/replicache/attributes";
import { makeRoute } from "../lib";
import type { Env } from "./route";
+import type { Json } from "supabase/database.types";
// First define the sub-types for V0 and V1 requests
const pullRequestV0 = z.object({
@@ -75,6 +76,7 @@ export const pull = makeRoute({
title: string;
tags: string[];
cover_image: string | null;
+ preferences: Json | null;
}[];
let pub_patch = publication_data?.[0]
? [
@@ -98,6 +100,11 @@ export const pull = makeRoute({
key: "publication_cover_image",
value: publication_data[0].cover_image || null,
},
+ {
+ op: "put",
+ key: "post_preferences",
+ value: publication_data[0].preferences || null,
+ },
]
: [];
diff --git a/app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx b/app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx
index bf57101e..7c9a5d55 100644
--- a/app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx
+++ b/app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx
@@ -21,6 +21,7 @@ import {
} from "src/utils/normalizeRecords";
import { DocumentProvider } from "contexts/DocumentContext";
import { LeafletContentProvider } from "contexts/LeafletContentContext";
+import { mergePreferences } from "src/utils/mergePreferences";
export async function DocumentPageRenderer({
did,
@@ -133,7 +134,7 @@ export async function DocumentPageRenderer({
{
let { data: pub, normalizedPublication } = useLeafletPublicationData();
+ let { rep } = useReplicache();
+ let postPreferences = useSubscribe(rep, (tx) =>
+ tx.get<{
+ showComments?: boolean;
+ showMentions?: boolean;
+ showRecommends?: boolean;
+ } | null>("post_preferences"),
+ );
if (!pub || !pub.publications) return null;
if (!normalizedPublication) return null;
- let showComments = normalizedPublication.preferences?.showComments !== false;
- let showMentions = normalizedPublication.preferences?.showMentions !== false;
- let showRecommends =
- normalizedPublication.preferences?.showRecommends !== false;
+ let merged = mergePreferences(
+ postPreferences || undefined,
+ normalizedPublication.preferences,
+ );
+ let showComments = merged.showComments !== false;
+ let showMentions = merged.showMentions !== false;
+ let showRecommends = merged.showRecommends !== false;
return (
diff --git a/components/Pages/PublicationMetadata.tsx b/components/Pages/PublicationMetadata.tsx
index 2caa7d15..27803b9b 100644
--- a/components/Pages/PublicationMetadata.tsx
+++ b/components/Pages/PublicationMetadata.tsx
@@ -21,6 +21,7 @@ import { useIdentityData } from "components/IdentityProvider";
import { PostHeaderLayout } from "app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader";
import { Backdater } from "./Backdater";
import { RecommendTinyEmpty } from "components/Icons/RecommendTiny";
+import { mergePreferences } from "src/utils/mergePreferences";
export const PublicationMetadata = (props: { noInteractions?: boolean }) => {
let { rep } = useReplicache();
@@ -34,6 +35,17 @@ export const PublicationMetadata = (props: { noInteractions?: boolean }) => {
let description = useSubscribe(rep, (tx) =>
tx.get
("publication_description"),
);
+ let postPreferences = useSubscribe(rep, (tx) =>
+ tx.get<{
+ showComments?: boolean;
+ showMentions?: boolean;
+ showRecommends?: boolean;
+ } | null>("post_preferences"),
+ );
+ let merged = mergePreferences(
+ postPreferences || undefined,
+ normalizedPublication?.preferences,
+ );
let publishedAt = normalizedDocument?.publishedAt;
if (!pub) return null;
@@ -121,28 +133,27 @@ export const PublicationMetadata = (props: { noInteractions?: boolean }) => {
)}
{!props.noInteractions && (
- {normalizedPublication?.preferences?.showRecommends !== false && (
+ {merged.showRecommends !== false && (
—
)}
- {normalizedPublication?.preferences?.showMentions !== false && (
+ {merged.showMentions !== false && (
—
)}
- {normalizedPublication?.preferences?.showComments !== false && (
+ {merged.showComments !== false && (
—
)}
{tags && (
<>
- {normalizedPublication?.preferences?.showRecommends !==
- false ||
- normalizedPublication?.preferences?.showMentions !== false ||
- normalizedPublication?.preferences?.showComments !== false ? (
+ {merged.showRecommends !== false ||
+ merged.showMentions !== false ||
+ merged.showComments !== false ? (
) : null}
diff --git a/components/PostListing.tsx b/components/PostListing.tsx
index ef6105a4..32bd9bd4 100644
--- a/components/PostListing.tsx
+++ b/components/PostListing.tsx
@@ -17,6 +17,7 @@ import type { Post } from "app/(home-pages)/reader/getReaderFeed";
import Link from "next/link";
import { InteractionPreview } from "./InteractionsPreview";
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
+import { mergePreferences } from "src/utils/mergePreferences";
export const PostListing = (props: Post) => {
let pubRecord = props.publication?.pubRecord as
@@ -48,9 +49,11 @@ export const PostListing = (props: Post) => {
? pubRecord?.theme?.showPageBackground
: postRecord.theme?.showPageBackground ?? true;
+ let mergedPrefs = mergePreferences(postRecord?.preferences, pubRecord?.preferences);
+
let quotes = props.documents.document_mentions_in_bsky?.[0]?.count || 0;
let comments =
- pubRecord?.preferences?.showComments === false
+ mergedPrefs.showComments === false
? 0
: props.documents.comments_on_documents?.[0]?.count || 0;
let recommends = props.documents.recommends_on_documents?.[0]?.count || 0;
@@ -109,11 +112,9 @@ export const PostListing = (props: Post) => {
recommendsCount={recommends}
documentUri={props.documents.uri}
tags={tags}
- showComments={pubRecord?.preferences?.showComments !== false}
- showMentions={pubRecord?.preferences?.showMentions !== false}
- showRecommends={
- pubRecord?.preferences?.showRecommends !== false
- }
+ showComments={mergedPrefs.showComments !== false}
+ showMentions={mergedPrefs.showMentions !== false}
+ showRecommends={mergedPrefs.showRecommends !== false}
share
/>
diff --git a/components/PostSettings.tsx b/components/PostSettings.tsx
new file mode 100644
index 00000000..d8a92a0b
--- /dev/null
+++ b/components/PostSettings.tsx
@@ -0,0 +1,96 @@
+"use client";
+
+import { ActionButton } from "components/ActionBar/ActionButton";
+import { SettingsSmall } from "components/Icons/SettingsSmall";
+import { Toggle } from "components/Toggle";
+import { Popover } from "components/Popover";
+import { useLeafletPublicationData } from "components/PageSWRDataProvider";
+import { useReplicache } from "src/replicache";
+import { useSubscribe } from "src/replicache/useSubscribe";
+
+type PostPreferences = {
+ showComments?: boolean;
+ showMentions?: boolean;
+ showRecommends?: boolean;
+};
+
+export function PostSettings() {
+ let { data: pub, normalizedPublication } = useLeafletPublicationData();
+ let { rep } = useReplicache();
+
+ let postPreferences = useSubscribe(rep, (tx) =>
+ tx.get("post_preferences"),
+ );
+
+ if (!pub || !pub.publications) return null;
+
+ let pubPrefs = normalizedPublication?.preferences;
+
+ let showComments =
+ postPreferences?.showComments ?? pubPrefs?.showComments ?? true;
+ let showMentions =
+ postPreferences?.showMentions ?? pubPrefs?.showMentions ?? true;
+ let showRecommends =
+ postPreferences?.showRecommends ?? pubPrefs?.showRecommends ?? true;
+
+ const updatePreference = (
+ field: keyof PostPreferences,
+ value: boolean,
+ ) => {
+ let current: PostPreferences = postPreferences || {};
+ rep?.mutate.updatePublicationDraft({
+ preferences: { ...current, [field]: value },
+ });
+ };
+
+ return (
+ }
+ label="Settings"
+ />
+ }
+ >
+
+
+ This Post Settings
+
+
+
updatePreference("showComments", !showComments)}
+ >
+ Show Comments
+
+
updatePreference("showMentions", !showMentions)}
+ >
+
+
Show Mentions
+
+ Display a list Bluesky mentions about your post
+
+
+
+
updatePreference("showRecommends", !showRecommends)}
+ >
+
+
Show Recommends
+
+ Allow readers to recommend/like your post
+
+
+
+
+
+
+ );
+}
diff --git a/lexicons/api/lexicons.ts b/lexicons/api/lexicons.ts
index a197ea95..a4b2a81f 100644
--- a/lexicons/api/lexicons.ts
+++ b/lexicons/api/lexicons.ts
@@ -1469,6 +1469,10 @@ export const schemaDict = {
type: 'ref',
ref: 'lex:pub.leaflet.publication#theme',
},
+ preferences: {
+ type: 'ref',
+ ref: 'lex:pub.leaflet.publication#preferences',
+ },
tags: {
type: 'array',
items: {
@@ -1868,6 +1872,10 @@ export const schemaDict = {
type: 'boolean',
default: true,
},
+ showRecommends: {
+ type: 'boolean',
+ default: true,
+ },
},
},
theme: {
@@ -2194,6 +2202,11 @@ export const schemaDict = {
maxLength: 5000,
type: 'string',
},
+ preferences: {
+ type: 'union',
+ refs: ['lex:pub.leaflet.publication#preferences'],
+ closed: false,
+ },
updatedAt: {
format: 'datetime',
type: 'string',
@@ -2290,6 +2303,10 @@ export const schemaDict = {
default: false,
type: 'boolean',
},
+ showRecommends: {
+ default: true,
+ type: 'boolean',
+ },
},
type: 'object',
},
diff --git a/lexicons/api/types/pub/leaflet/document.ts b/lexicons/api/types/pub/leaflet/document.ts
index 6f6805da..ff587ec7 100644
--- a/lexicons/api/types/pub/leaflet/document.ts
+++ b/lexicons/api/types/pub/leaflet/document.ts
@@ -23,6 +23,7 @@ export interface Record {
publication?: string
author: string
theme?: PubLeafletPublication.Theme
+ preferences?: PubLeafletPublication.Preferences
tags?: string[]
coverImage?: BlobRef
pages: (
diff --git a/lexicons/api/types/pub/leaflet/publication.ts b/lexicons/api/types/pub/leaflet/publication.ts
index 51143ee0..a2aaee34 100644
--- a/lexicons/api/types/pub/leaflet/publication.ts
+++ b/lexicons/api/types/pub/leaflet/publication.ts
@@ -1,94 +1,90 @@
/**
* GENERATED CODE - DO NOT MODIFY
*/
-import { type ValidationResult, BlobRef } from "@atproto/lexicon";
-import { CID } from "multiformats/cid";
-import { validate as _validate } from "../../../lexicons";
-import {
- type $Typed,
- is$typed as _is$typed,
- type OmitKey,
-} from "../../../util";
-import type * as PubLeafletThemeColor from "./theme/color";
-import type * as PubLeafletThemeBackgroundImage from "./theme/backgroundImage";
+import { type ValidationResult, BlobRef } from '@atproto/lexicon'
+import { CID } from 'multiformats/cid'
+import { validate as _validate } from '../../../lexicons'
+import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util'
+import type * as PubLeafletThemeColor from './theme/color'
+import type * as PubLeafletThemeBackgroundImage from './theme/backgroundImage'
const is$typed = _is$typed,
- validate = _validate;
-const id = "pub.leaflet.publication";
+ validate = _validate
+const id = 'pub.leaflet.publication'
export interface Record {
- $type: "pub.leaflet.publication";
- name: string;
- base_path?: string;
- description?: string;
- icon?: BlobRef;
- theme?: Theme;
- preferences?: Preferences;
- [k: string]: unknown;
+ $type: 'pub.leaflet.publication'
+ name: string
+ base_path?: string
+ description?: string
+ icon?: BlobRef
+ theme?: Theme
+ preferences?: Preferences
+ [k: string]: unknown
}
-const hashRecord = "main";
+const hashRecord = 'main'
export function isRecord(v: V) {
- return is$typed(v, id, hashRecord);
+ return is$typed(v, id, hashRecord)
}
export function validateRecord(v: V) {
- return validate(v, id, hashRecord, true);
+ return validate(v, id, hashRecord, true)
}
export interface Preferences {
- $type?: "pub.leaflet.publication#preferences";
- showInDiscover: boolean;
- showComments: boolean;
- showMentions: boolean;
- showPrevNext: boolean;
- showRecommends: boolean;
+ $type?: 'pub.leaflet.publication#preferences'
+ showInDiscover: boolean
+ showComments: boolean
+ showMentions: boolean
+ showPrevNext: boolean
+ showRecommends: boolean
}
-const hashPreferences = "preferences";
+const hashPreferences = 'preferences'
export function isPreferences(v: V) {
- return is$typed(v, id, hashPreferences);
+ return is$typed(v, id, hashPreferences)
}
export function validatePreferences(v: V) {
- return validate(v, id, hashPreferences);
+ return validate(v, id, hashPreferences)
}
export interface Theme {
- $type?: "pub.leaflet.publication#theme";
+ $type?: 'pub.leaflet.publication#theme'
backgroundColor?:
| $Typed
| $Typed
- | { $type: string };
- backgroundImage?: PubLeafletThemeBackgroundImage.Main;
- pageWidth?: number;
+ | { $type: string }
+ backgroundImage?: PubLeafletThemeBackgroundImage.Main
+ pageWidth?: number
primary?:
| $Typed
| $Typed
- | { $type: string };
+ | { $type: string }
pageBackground?:
| $Typed
| $Typed
- | { $type: string };
- showPageBackground: boolean;
+ | { $type: string }
+ showPageBackground: boolean
accentBackground?:
| $Typed
| $Typed
- | { $type: string };
+ | { $type: string }
accentText?:
| $Typed
| $Typed
- | { $type: string };
+ | { $type: string }
}
-const hashTheme = "theme";
+const hashTheme = 'theme'
export function isTheme(v: V) {
- return is$typed(v, id, hashTheme);
+ return is$typed(v, id, hashTheme)
}
export function validateTheme(v: V) {
- return validate(v, id, hashTheme);
+ return validate(v, id, hashTheme)
}
diff --git a/lexicons/api/types/site/standard/document.ts b/lexicons/api/types/site/standard/document.ts
index dccb1dfe..b6ff4fc0 100644
--- a/lexicons/api/types/site/standard/document.ts
+++ b/lexicons/api/types/site/standard/document.ts
@@ -28,6 +28,7 @@ export interface Record {
textContent?: string
theme?: PubLeafletPublication.Theme
title: string
+ preferences?: $Typed | { $type: string }
updatedAt?: string
[k: string]: unknown
}
diff --git a/lexicons/api/types/site/standard/publication.ts b/lexicons/api/types/site/standard/publication.ts
index 08f80d9c..42211459 100644
--- a/lexicons/api/types/site/standard/publication.ts
+++ b/lexicons/api/types/site/standard/publication.ts
@@ -1,58 +1,54 @@
/**
* GENERATED CODE - DO NOT MODIFY
*/
-import { type ValidationResult, BlobRef } from "@atproto/lexicon";
-import { CID } from "multiformats/cid";
-import { validate as _validate } from "../../../lexicons";
-import {
- type $Typed,
- is$typed as _is$typed,
- type OmitKey,
-} from "../../../util";
-import type * as SiteStandardThemeBasic from "./theme/basic";
-import type * as PubLeafletPublication from "../../pub/leaflet/publication";
+import { type ValidationResult, BlobRef } from '@atproto/lexicon'
+import { CID } from 'multiformats/cid'
+import { validate as _validate } from '../../../lexicons'
+import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util'
+import type * as SiteStandardThemeBasic from './theme/basic'
+import type * as PubLeafletPublication from '../../pub/leaflet/publication'
const is$typed = _is$typed,
- validate = _validate;
-const id = "site.standard.publication";
+ validate = _validate
+const id = 'site.standard.publication'
export interface Record {
- $type: "site.standard.publication";
- basicTheme?: SiteStandardThemeBasic.Main;
- theme?: $Typed | { $type: string };
- description?: string;
- icon?: BlobRef;
- name: string;
- preferences?: Preferences;
- url: string;
- [k: string]: unknown;
+ $type: 'site.standard.publication'
+ basicTheme?: SiteStandardThemeBasic.Main
+ theme?: $Typed | { $type: string }
+ description?: string
+ icon?: BlobRef
+ name: string
+ preferences?: Preferences
+ url: string
+ [k: string]: unknown
}
-const hashRecord = "main";
+const hashRecord = 'main'
export function isRecord(v: V) {
- return is$typed(v, id, hashRecord);
+ return is$typed(v, id, hashRecord)
}
export function validateRecord(v: V) {
- return validate(v, id, hashRecord, true);
+ return validate(v, id, hashRecord, true)
}
export interface Preferences {
- $type?: "site.standard.publication#preferences";
- showInDiscover: boolean;
- showComments: boolean;
- showMentions: boolean;
- showPrevNext: boolean;
- showRecommends: boolean;
+ $type?: 'site.standard.publication#preferences'
+ showInDiscover: boolean
+ showComments: boolean
+ showMentions: boolean
+ showPrevNext: boolean
+ showRecommends: boolean
}
-const hashPreferences = "preferences";
+const hashPreferences = 'preferences'
export function isPreferences(v: V) {
- return is$typed(v, id, hashPreferences);
+ return is$typed(v, id, hashPreferences)
}
export function validatePreferences(v: V) {
- return validate(v, id, hashPreferences);
+ return validate(v, id, hashPreferences)
}
diff --git a/lexicons/pub/leaflet/document.json b/lexicons/pub/leaflet/document.json
index 2e714226..7ab00088 100644
--- a/lexicons/pub/leaflet/document.json
+++ b/lexicons/pub/leaflet/document.json
@@ -46,6 +46,10 @@
"type": "ref",
"ref": "pub.leaflet.publication#theme"
},
+ "preferences": {
+ "type": "ref",
+ "ref": "pub.leaflet.publication#preferences"
+ },
"tags": {
"type": "array",
"items": {
diff --git a/lexicons/site/standard/document.json b/lexicons/site/standard/document.json
index 433f19dc..8305dd47 100644
--- a/lexicons/site/standard/document.json
+++ b/lexicons/site/standard/document.json
@@ -57,6 +57,11 @@
"maxLength": 5000,
"type": "string"
},
+ "preferences": {
+ "type": "union",
+ "refs": ["pub.leaflet.publication#preferences"],
+ "closed": false
+ },
"updatedAt": {
"format": "datetime",
"type": "string"
diff --git a/lexicons/src/document.ts b/lexicons/src/document.ts
index f1beabff..15e3f00b 100644
--- a/lexicons/src/document.ts
+++ b/lexicons/src/document.ts
@@ -23,6 +23,10 @@ export const PubLeafletDocument: LexiconDoc = {
publication: { type: "string", format: "at-uri" },
author: { type: "string", format: "at-identifier" },
theme: { type: "ref", ref: "pub.leaflet.publication#theme" },
+ preferences: {
+ type: "ref",
+ ref: "pub.leaflet.publication#preferences",
+ },
tags: { type: "array", items: { type: "string", maxLength: 50 } },
coverImage: {
type: "blob",
diff --git a/lexicons/src/normalize.ts b/lexicons/src/normalize.ts
index 4f7294c7..9e8755ec 100644
--- a/lexicons/src/normalize.ts
+++ b/lexicons/src/normalize.ts
@@ -28,6 +28,7 @@ import { AtUri } from "@atproto/syntax";
export type NormalizedDocument = SiteStandardDocument.Record & {
// Keep the original theme for components that need leaflet-specific styling
theme?: PubLeafletPublication.Theme;
+ preferences?: SiteStandardPublication.Preferences;
};
// Normalized publication type - uses the generated site.standard.publication type
@@ -169,9 +170,13 @@ export function normalizeDocument(
// Pass through site.standard records directly (theme is already in correct format if present)
if (isStandardDocument(record)) {
+ const preferences = record.preferences as
+ | SiteStandardPublication.Preferences
+ | undefined;
return {
...record,
theme: record.theme,
+ preferences,
} as NormalizedDocument;
}
@@ -198,6 +203,10 @@ export function normalizeDocument(
}
: undefined;
+ // Extract preferences if present (available after lexicon rebuild)
+ const leafletPrefs = (record as Record)
+ .preferences as SiteStandardPublication.Preferences | undefined;
+
return {
$type: "site.standard.document",
title: record.title,
@@ -210,6 +219,7 @@ export function normalizeDocument(
bskyPostRef: record.postRef,
content,
theme: record.theme,
+ preferences: leafletPrefs,
};
}
diff --git a/package-lock.json b/package-lock.json
index bffdd153..9fb9d0cb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,7 +16,6 @@
"@atproto/oauth-client-node": "^0.3.8",
"@atproto/sync": "^0.1.34",
"@atproto/syntax": "^0.3.3",
- "@atproto/tap": "^0.1.1",
"@atproto/xrpc": "^0.7.5",
"@atproto/xrpc-server": "^0.9.5",
"@hono/node-server": "^1.14.3",
@@ -396,92 +395,6 @@
"integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==",
"license": "(Apache-2.0 AND MIT)"
},
- "node_modules/@atproto/lex": {
- "version": "0.0.9",
- "resolved": "https://registry.npmjs.org/@atproto/lex/-/lex-0.0.9.tgz",
- "integrity": "sha512-o6gauf1lz0iyzJR0rqSj4VHOrO+Nt8+/iPb0KPojw1ieXk13zOSTSxotAoDzO/dP6y8Ey5jxwuCQGuzab/4XnQ==",
- "license": "MIT",
- "dependencies": {
- "@atproto/lex-builder": "0.0.9",
- "@atproto/lex-client": "0.0.7",
- "@atproto/lex-data": "0.0.6",
- "@atproto/lex-installer": "0.0.9",
- "@atproto/lex-json": "0.0.6",
- "@atproto/lex-schema": "0.0.7",
- "tslib": "^2.8.1",
- "yargs": "^17.0.0"
- },
- "bin": {
- "lex": "bin/lex",
- "ts-lex": "bin/lex"
- }
- },
- "node_modules/@atproto/lex-builder": {
- "version": "0.0.9",
- "resolved": "https://registry.npmjs.org/@atproto/lex-builder/-/lex-builder-0.0.9.tgz",
- "integrity": "sha512-buOFk1JpuW3twI7To7f/67zQQ1NulLHf/oasH/kTOPUAd0dNyeAa13t9eRSVGbwi0BcZYxRxBm0QzPmdLKyuyw==",
- "license": "MIT",
- "dependencies": {
- "@atproto/lex-document": "0.0.8",
- "@atproto/lex-schema": "0.0.7",
- "prettier": "^3.2.5",
- "ts-morph": "^27.0.0",
- "tslib": "^2.8.1"
- }
- },
- "node_modules/@atproto/lex-builder/node_modules/@ts-morph/common": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.28.1.tgz",
- "integrity": "sha512-W74iWf7ILp1ZKNYXY5qbddNaml7e9Sedv5lvU1V8lftlitkc9Pq1A+jlH23ltDgWYeZFFEqGCD1Ies9hqu3O+g==",
- "license": "MIT",
- "dependencies": {
- "minimatch": "^10.0.1",
- "path-browserify": "^1.0.1",
- "tinyglobby": "^0.2.14"
- }
- },
- "node_modules/@atproto/lex-builder/node_modules/minimatch": {
- "version": "10.1.1",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
- "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
- "license": "BlueOak-1.0.0",
- "dependencies": {
- "@isaacs/brace-expansion": "^5.0.0"
- },
- "engines": {
- "node": "20 || >=22"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/@atproto/lex-builder/node_modules/ts-morph": {
- "version": "27.0.2",
- "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-27.0.2.tgz",
- "integrity": "sha512-fhUhgeljcrdZ+9DZND1De1029PrE+cMkIP7ooqkLRTrRLTqcki2AstsyJm0vRNbTbVCNJ0idGlbBrfqc7/nA8w==",
- "license": "MIT",
- "dependencies": {
- "@ts-morph/common": "~0.28.1",
- "code-block-writer": "^13.0.3"
- }
- },
- "node_modules/@atproto/lex-cbor": {
- "version": "0.0.6",
- "resolved": "https://registry.npmjs.org/@atproto/lex-cbor/-/lex-cbor-0.0.6.tgz",
- "integrity": "sha512-lee2T00owDy3I1plRHuURT6f98NIpYZZr2wXa5pJZz5JzefZ+nv8gJ2V70C2f+jmSG+5S9NTIy4uJw94vaHf4A==",
- "license": "MIT",
- "dependencies": {
- "@atproto/lex-data": "0.0.6",
- "multiformats": "^9.9.0",
- "tslib": "^2.8.1"
- }
- },
- "node_modules/@atproto/lex-cbor/node_modules/multiformats": {
- "version": "9.9.0",
- "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz",
- "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==",
- "license": "(Apache-2.0 AND MIT)"
- },
"node_modules/@atproto/lex-cli": {
"version": "0.9.5",
"resolved": "https://registry.npmjs.org/@atproto/lex-cli/-/lex-cli-0.9.5.tgz",
@@ -515,149 +428,6 @@
"tslib": "^2.8.1"
}
},
- "node_modules/@atproto/lex-client": {
- "version": "0.0.7",
- "resolved": "https://registry.npmjs.org/@atproto/lex-client/-/lex-client-0.0.7.tgz",
- "integrity": "sha512-ofUz3yXJ0nN/M9aqqF2ZUL/4D1wWT1P4popCfV3OEDsDrtWofMflYPFz1IWuyPa2e83paaEHRhaw3bZEhgXH1w==",
- "license": "MIT",
- "dependencies": {
- "@atproto/lex-data": "0.0.6",
- "@atproto/lex-json": "0.0.6",
- "@atproto/lex-schema": "0.0.7",
- "tslib": "^2.8.1"
- }
- },
- "node_modules/@atproto/lex-data": {
- "version": "0.0.6",
- "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.6.tgz",
- "integrity": "sha512-MBNB4ghRJQzuXK1zlUPljpPbQcF1LZ5dzxy274KqPt4p3uPuRw0mHjgcCoWzRUNBQC685WMQR4IN9DHtsnG57A==",
- "license": "MIT",
- "dependencies": {
- "@atproto/syntax": "0.4.2",
- "multiformats": "^9.9.0",
- "tslib": "^2.8.1",
- "uint8arrays": "3.0.0",
- "unicode-segmenter": "^0.14.0"
- }
- },
- "node_modules/@atproto/lex-data/node_modules/@atproto/syntax": {
- "version": "0.4.2",
- "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.2.tgz",
- "integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==",
- "license": "MIT"
- },
- "node_modules/@atproto/lex-data/node_modules/multiformats": {
- "version": "9.9.0",
- "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz",
- "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==",
- "license": "(Apache-2.0 AND MIT)"
- },
- "node_modules/@atproto/lex-document": {
- "version": "0.0.8",
- "resolved": "https://registry.npmjs.org/@atproto/lex-document/-/lex-document-0.0.8.tgz",
- "integrity": "sha512-p3l5h96Hx0vxUwbO/eas6x5h2vU0JVN1a/ktX4k3PlK9YLXfWMFsv+RdVwVZom8o0irHwlcyh1D/cY0PyUojDA==",
- "license": "MIT",
- "dependencies": {
- "@atproto/lex-schema": "0.0.7",
- "core-js": "^3",
- "tslib": "^2.8.1"
- }
- },
- "node_modules/@atproto/lex-installer": {
- "version": "0.0.9",
- "resolved": "https://registry.npmjs.org/@atproto/lex-installer/-/lex-installer-0.0.9.tgz",
- "integrity": "sha512-zEeIeSaSCb3j+zNsqqMY7+X5FO6fxy/MafaCEj42KsXQHNcobuygZsnG/0fxMj/kMvhjrNUCp/w9PyOMwx4hQg==",
- "license": "MIT",
- "dependencies": {
- "@atproto/lex-builder": "0.0.9",
- "@atproto/lex-cbor": "0.0.6",
- "@atproto/lex-data": "0.0.6",
- "@atproto/lex-document": "0.0.8",
- "@atproto/lex-resolver": "0.0.8",
- "@atproto/lex-schema": "0.0.7",
- "@atproto/syntax": "0.4.2",
- "tslib": "^2.8.1"
- }
- },
- "node_modules/@atproto/lex-installer/node_modules/@atproto/syntax": {
- "version": "0.4.2",
- "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.2.tgz",
- "integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==",
- "license": "MIT"
- },
- "node_modules/@atproto/lex-json": {
- "version": "0.0.6",
- "resolved": "https://registry.npmjs.org/@atproto/lex-json/-/lex-json-0.0.6.tgz",
- "integrity": "sha512-EILnN5cditPvf+PCNjXt7reMuzjugxAL1fpSzmzJbEMGMUwxOf5pPWxRsaA/M3Boip4NQZ+6DVrPOGUMlnqceg==",
- "license": "MIT",
- "dependencies": {
- "@atproto/lex-data": "0.0.6",
- "tslib": "^2.8.1"
- }
- },
- "node_modules/@atproto/lex-resolver": {
- "version": "0.0.8",
- "resolved": "https://registry.npmjs.org/@atproto/lex-resolver/-/lex-resolver-0.0.8.tgz",
- "integrity": "sha512-4hXT560+k5BIttouuhXOr+UkhAuFvvkJaVdqYb8vx2Ez7eHPiZ+yWkUK6FKpyGsx2whHkJzgleEA6DNWtdDlWA==",
- "license": "MIT",
- "dependencies": {
- "@atproto-labs/did-resolver": "0.2.5",
- "@atproto/crypto": "0.4.5",
- "@atproto/lex-client": "0.0.7",
- "@atproto/lex-data": "0.0.6",
- "@atproto/lex-document": "0.0.8",
- "@atproto/lex-schema": "0.0.7",
- "@atproto/repo": "0.8.12",
- "@atproto/syntax": "0.4.2",
- "tslib": "^2.8.1"
- }
- },
- "node_modules/@atproto/lex-resolver/node_modules/@atproto-labs/did-resolver": {
- "version": "0.2.5",
- "resolved": "https://registry.npmjs.org/@atproto-labs/did-resolver/-/did-resolver-0.2.5.tgz",
- "integrity": "sha512-he7EC6OMSifNs01a4RT9mta/yYitoKDzlK9ty2TFV5Uj/+HpB4vYMRdIDFrRW0Hcsehy90E2t/dw0t7361MEKQ==",
- "license": "MIT",
- "dependencies": {
- "@atproto-labs/fetch": "0.2.3",
- "@atproto-labs/pipe": "0.1.1",
- "@atproto-labs/simple-store": "0.3.0",
- "@atproto-labs/simple-store-memory": "0.1.4",
- "@atproto/did": "0.2.4",
- "zod": "^3.23.8"
- }
- },
- "node_modules/@atproto/lex-resolver/node_modules/@atproto/did": {
- "version": "0.2.4",
- "resolved": "https://registry.npmjs.org/@atproto/did/-/did-0.2.4.tgz",
- "integrity": "sha512-nxNiCgXeo7pfjojq9fpfZxCO0X0xUipNVKW+AHNZwQKiUDt6zYL0VXEfm8HBUwQOCmKvj2pRRSM1Cur+tUWk3g==",
- "license": "MIT",
- "dependencies": {
- "zod": "^3.23.8"
- }
- },
- "node_modules/@atproto/lex-resolver/node_modules/@atproto/syntax": {
- "version": "0.4.2",
- "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.2.tgz",
- "integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==",
- "license": "MIT"
- },
- "node_modules/@atproto/lex-schema": {
- "version": "0.0.7",
- "resolved": "https://registry.npmjs.org/@atproto/lex-schema/-/lex-schema-0.0.7.tgz",
- "integrity": "sha512-/7HkTUsnP1rlzmVE6nnY0kl/hydL/W8V29V8BhFwdAvdDKpYcdRgzzsMe38LAt+ZOjHknRCZDIKGsbQMSbJErw==",
- "license": "MIT",
- "dependencies": {
- "@atproto/lex-data": "0.0.6",
- "@atproto/syntax": "0.4.2",
- "tslib": "^2.8.1"
- }
- },
- "node_modules/@atproto/lex-schema/node_modules/@atproto/syntax": {
- "version": "0.4.2",
- "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.2.tgz",
- "integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==",
- "license": "MIT"
- },
"node_modules/@atproto/lexicon": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.6.1.tgz",
@@ -848,135 +618,6 @@
"integrity": "sha512-8CNmi5DipOLaVeSMPggMe7FCksVag0aO6XZy9WflbduTKM4dFZVCs4686UeMLfGRXX+X966XgwECHoLYrovMMg==",
"license": "MIT"
},
- "node_modules/@atproto/tap": {
- "version": "0.1.1",
- "resolved": "https://registry.npmjs.org/@atproto/tap/-/tap-0.1.1.tgz",
- "integrity": "sha512-gW4NzLOxj74TzaDOVzzzt5kl2PdC0r75XkIpYpI5xobwCfsc/DmVtwpuSw1fW9gr4Vzk2Q90S9UE4ifAFl2gyA==",
- "license": "MIT",
- "dependencies": {
- "@atproto/common": "^0.5.6",
- "@atproto/lex": "^0.0.9",
- "@atproto/syntax": "^0.4.2",
- "@atproto/ws-client": "^0.0.4",
- "ws": "^8.12.0",
- "zod": "^3.23.8"
- },
- "engines": {
- "node": ">=18.7.0"
- }
- },
- "node_modules/@atproto/tap/node_modules/@atproto/common": {
- "version": "0.5.10",
- "resolved": "https://registry.npmjs.org/@atproto/common/-/common-0.5.10.tgz",
- "integrity": "sha512-A1+4W3JmjZIgmtJFLJBAaoVruZhRL0ANtyjZ91aJR4rjHcZuaQ+v4IFR1UcE6yyTATacLdBk6ADy8OtxXzq14g==",
- "license": "MIT",
- "dependencies": {
- "@atproto/common-web": "^0.4.15",
- "@atproto/lex-cbor": "^0.0.10",
- "@atproto/lex-data": "^0.0.10",
- "iso-datestring-validator": "^2.2.2",
- "multiformats": "^9.9.0",
- "pino": "^8.21.0"
- },
- "engines": {
- "node": ">=18.7.0"
- }
- },
- "node_modules/@atproto/tap/node_modules/@atproto/lex-cbor": {
- "version": "0.0.10",
- "resolved": "https://registry.npmjs.org/@atproto/lex-cbor/-/lex-cbor-0.0.10.tgz",
- "integrity": "sha512-5RtV90iIhRNCXXvvETd3KlraV8XGAAAgOmiszUb+l8GySDU/sGk7AlVvArFfXnj/S/GXJq8DP6IaUxCw/sPASA==",
- "license": "MIT",
- "dependencies": {
- "@atproto/lex-data": "^0.0.10",
- "tslib": "^2.8.1"
- }
- },
- "node_modules/@atproto/tap/node_modules/@atproto/lex-data": {
- "version": "0.0.10",
- "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.10.tgz",
- "integrity": "sha512-FDbcy8VIUVzS9Mi1F8SMxbkL/jOUmRRpqbeM1xB4A0fMxeZJTxf6naAbFt4gYF3quu/+TPJGmio6/7cav05FqQ==",
- "license": "MIT",
- "dependencies": {
- "multiformats": "^9.9.0",
- "tslib": "^2.8.1",
- "uint8arrays": "3.0.0",
- "unicode-segmenter": "^0.14.0"
- }
- },
- "node_modules/@atproto/tap/node_modules/@atproto/syntax": {
- "version": "0.4.3",
- "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.3.tgz",
- "integrity": "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA==",
- "license": "MIT",
- "dependencies": {
- "tslib": "^2.8.1"
- }
- },
- "node_modules/@atproto/tap/node_modules/multiformats": {
- "version": "9.9.0",
- "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz",
- "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==",
- "license": "(Apache-2.0 AND MIT)"
- },
- "node_modules/@atproto/ws-client": {
- "version": "0.0.4",
- "resolved": "https://registry.npmjs.org/@atproto/ws-client/-/ws-client-0.0.4.tgz",
- "integrity": "sha512-dox1XIymuC7/ZRhUqKezIGgooZS45C6vHCfu0PnWjfvsLCK2kAlnvX4IBkA/WpcoijDhQ9ejChnFbo/sLmgvAg==",
- "license": "MIT",
- "dependencies": {
- "@atproto/common": "^0.5.3",
- "ws": "^8.12.0"
- },
- "engines": {
- "node": ">=18.7.0"
- }
- },
- "node_modules/@atproto/ws-client/node_modules/@atproto/common": {
- "version": "0.5.10",
- "resolved": "https://registry.npmjs.org/@atproto/common/-/common-0.5.10.tgz",
- "integrity": "sha512-A1+4W3JmjZIgmtJFLJBAaoVruZhRL0ANtyjZ91aJR4rjHcZuaQ+v4IFR1UcE6yyTATacLdBk6ADy8OtxXzq14g==",
- "license": "MIT",
- "dependencies": {
- "@atproto/common-web": "^0.4.15",
- "@atproto/lex-cbor": "^0.0.10",
- "@atproto/lex-data": "^0.0.10",
- "iso-datestring-validator": "^2.2.2",
- "multiformats": "^9.9.0",
- "pino": "^8.21.0"
- },
- "engines": {
- "node": ">=18.7.0"
- }
- },
- "node_modules/@atproto/ws-client/node_modules/@atproto/lex-cbor": {
- "version": "0.0.10",
- "resolved": "https://registry.npmjs.org/@atproto/lex-cbor/-/lex-cbor-0.0.10.tgz",
- "integrity": "sha512-5RtV90iIhRNCXXvvETd3KlraV8XGAAAgOmiszUb+l8GySDU/sGk7AlVvArFfXnj/S/GXJq8DP6IaUxCw/sPASA==",
- "license": "MIT",
- "dependencies": {
- "@atproto/lex-data": "^0.0.10",
- "tslib": "^2.8.1"
- }
- },
- "node_modules/@atproto/ws-client/node_modules/@atproto/lex-data": {
- "version": "0.0.10",
- "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.10.tgz",
- "integrity": "sha512-FDbcy8VIUVzS9Mi1F8SMxbkL/jOUmRRpqbeM1xB4A0fMxeZJTxf6naAbFt4gYF3quu/+TPJGmio6/7cav05FqQ==",
- "license": "MIT",
- "dependencies": {
- "multiformats": "^9.9.0",
- "tslib": "^2.8.1",
- "uint8arrays": "3.0.0",
- "unicode-segmenter": "^0.14.0"
- }
- },
- "node_modules/@atproto/ws-client/node_modules/multiformats": {
- "version": "9.9.0",
- "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz",
- "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==",
- "license": "(Apache-2.0 AND MIT)"
- },
"node_modules/@atproto/xrpc": {
"version": "0.7.5",
"resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.7.5.tgz",
@@ -3052,27 +2693,6 @@
"integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==",
"license": "(Apache-2.0 AND MIT)"
},
- "node_modules/@isaacs/balanced-match": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
- "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
- "license": "MIT",
- "engines": {
- "node": "20 || >=22"
- }
- },
- "node_modules/@isaacs/brace-expansion": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
- "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
- "license": "MIT",
- "dependencies": {
- "@isaacs/balanced-match": "^4.0.1"
- },
- "engines": {
- "node": "20 || >=22"
- }
- },
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
@@ -9631,6 +9251,7 @@
"version": "13.0.3",
"resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz",
"integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==",
+ "dev": true,
"license": "MIT"
},
"node_modules/collapse-white-space": {
@@ -9739,17 +9360,6 @@
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
- "node_modules/core-js": {
- "version": "3.47.0",
- "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.47.0.tgz",
- "integrity": "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==",
- "hasInstallScript": true,
- "license": "MIT",
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/core-js"
- }
- },
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
@@ -16142,6 +15752,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
+ "dev": true,
"license": "MIT"
},
"node_modules/path-exists": {
@@ -16424,6 +16035,7 @@
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz",
"integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==",
+ "dev": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -18520,6 +18132,7 @@
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
@@ -18536,6 +18149,7 @@
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
@@ -18553,6 +18167,7 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
diff --git a/package.json b/package.json
index 5e9e2ee4..4f9c5397 100644
--- a/package.json
+++ b/package.json
@@ -27,7 +27,6 @@
"@atproto/oauth-client-node": "^0.3.8",
"@atproto/sync": "^0.1.34",
"@atproto/syntax": "^0.3.3",
- "@atproto/tap": "^0.1.1",
"@atproto/xrpc": "^0.7.5",
"@atproto/xrpc-server": "^0.9.5",
"@hono/node-server": "^1.14.3",
diff --git a/src/replicache/mutations.ts b/src/replicache/mutations.ts
index d8bf93c6..40db9f73 100644
--- a/src/replicache/mutations.ts
+++ b/src/replicache/mutations.ts
@@ -659,6 +659,11 @@ const updatePublicationDraft: Mutation<{
tags?: string[];
cover_image?: string | null;
localPublishedAt?: string | null;
+ preferences?: {
+ showComments?: boolean;
+ showMentions?: boolean;
+ showRecommends?: boolean;
+ } | null;
}> = async (args, ctx) => {
await ctx.runOnServer(async (serverCtx) => {
console.log("updating");
@@ -667,11 +672,17 @@ const updatePublicationDraft: Mutation<{
title?: string;
tags?: string[];
cover_image?: string | null;
+ preferences?: {
+ showComments?: boolean;
+ showMentions?: boolean;
+ showRecommends?: boolean;
+ } | null;
} = {};
if (args.description !== undefined) updates.description = args.description;
if (args.title !== undefined) updates.title = args.title;
if (args.tags !== undefined) updates.tags = args.tags;
if (args.cover_image !== undefined) updates.cover_image = args.cover_image;
+ if (args.preferences !== undefined) updates.preferences = args.preferences;
if (Object.keys(updates).length > 0) {
// First try to update leaflets_in_publications (for publications)
@@ -700,6 +711,8 @@ const updatePublicationDraft: Mutation<{
await tx.set("publication_cover_image", args.cover_image);
if (args.localPublishedAt !== undefined)
await tx.set("publication_local_published_at", args.localPublishedAt);
+ if (args.preferences !== undefined)
+ await tx.set("post_preferences", args.preferences);
});
};
diff --git a/src/utils/mergePreferences.ts b/src/utils/mergePreferences.ts
new file mode 100644
index 00000000..c2e75a87
--- /dev/null
+++ b/src/utils/mergePreferences.ts
@@ -0,0 +1,24 @@
+type PreferencesInput = {
+ showComments?: boolean;
+ showMentions?: boolean;
+ showRecommends?: boolean;
+ showPrevNext?: boolean;
+} | null;
+
+export function mergePreferences(
+ documentPrefs?: PreferencesInput,
+ publicationPrefs?: PreferencesInput,
+): {
+ showComments?: boolean;
+ showMentions?: boolean;
+ showRecommends?: boolean;
+ showPrevNext?: boolean;
+} {
+ return {
+ showComments: documentPrefs?.showComments ?? publicationPrefs?.showComments,
+ showMentions: documentPrefs?.showMentions ?? publicationPrefs?.showMentions,
+ showRecommends:
+ documentPrefs?.showRecommends ?? publicationPrefs?.showRecommends,
+ showPrevNext: publicationPrefs?.showPrevNext,
+ };
+}
diff --git a/supabase/database.types.ts b/supabase/database.types.ts
index edbfe86e..6153540b 100644
--- a/supabase/database.types.ts
+++ b/supabase/database.types.ts
@@ -589,6 +589,7 @@ export type Database = {
description: string
doc: string | null
leaflet: string
+ preferences: Json | null
publication: string
tags: string[] | null
title: string
@@ -599,6 +600,7 @@ export type Database = {
description?: string
doc?: string | null
leaflet: string
+ preferences?: Json | null
publication: string
tags?: string[] | null
title?: string
@@ -609,6 +611,7 @@ export type Database = {
description?: string
doc?: string | null
leaflet?: string
+ preferences?: Json | null
publication?: string
tags?: string[] | null
title?: string
@@ -645,6 +648,7 @@ export type Database = {
description: string
document: string
leaflet: string
+ preferences: Json | null
tags: string[] | null
title: string
}
@@ -655,6 +659,7 @@ export type Database = {
description?: string
document: string
leaflet: string
+ preferences?: Json | null
tags?: string[] | null
title?: string
}
@@ -665,6 +670,7 @@ export type Database = {
description?: string
document?: string
leaflet?: string
+ preferences?: Json | null
tags?: string[] | null
title?: string
}
diff --git a/supabase/migrations/20260208000000_add_preferences_to_drafts.sql b/supabase/migrations/20260208000000_add_preferences_to_drafts.sql
new file mode 100644
index 00000000..e54b639a
--- /dev/null
+++ b/supabase/migrations/20260208000000_add_preferences_to_drafts.sql
@@ -0,0 +1,2 @@
+ALTER TABLE leaflets_in_publications ADD COLUMN preferences jsonb;
+ALTER TABLE leaflets_to_documents ADD COLUMN preferences jsonb;
--
2.51.2
From 04578602b038f2f1a9a5c885e1242056632676ea Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Sun, 8 Feb 2026 10:37:22 +0400
Subject: [PATCH 11/51] fix types
---
lexicons/src/normalize.ts | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/lexicons/src/normalize.ts b/lexicons/src/normalize.ts
index 9e8755ec..70ac45b9 100644
--- a/lexicons/src/normalize.ts
+++ b/lexicons/src/normalize.ts
@@ -219,7 +219,9 @@ export function normalizeDocument(
bskyPostRef: record.postRef,
content,
theme: record.theme,
- preferences: leafletPrefs,
+ preferences: leafletPrefs
+ ? { ...leafletPrefs, $type: "site.standard.publication#preferences" as const }
+ : undefined,
};
}
--
2.51.2
From 71e47d3e8fada27ccc60eab1fefb9f904bd1a3ac Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Sun, 8 Feb 2026 11:21:25 +0400
Subject: [PATCH 12/51] request blob permissions
---
app/api/oauth/[route]/oauth-metadata.ts | 2 +-
app/api/oauth/[route]/route.ts | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/app/api/oauth/[route]/oauth-metadata.ts b/app/api/oauth/[route]/oauth-metadata.ts
index 855fc0bc..32ed3b35 100644
--- a/app/api/oauth/[route]/oauth-metadata.ts
+++ b/app/api/oauth/[route]/oauth-metadata.ts
@@ -8,7 +8,7 @@ const hostname =
: "https://leaflet.pub";
const scope =
- "atproto transition:generic transition:email include:pub.leaflet.authFullPermissions include:site.standard.authFull include:app.bsky.authCreatePosts";
+ "atproto transition:generic transition:email include:pub.leaflet.authFullPermissions include:site.standard.authFull include:app.bsky.authCreatePosts blob:*/*";
const localconfig: OAuthClientMetadataInput = {
client_id: `http://localhost/?redirect_uri=${encodeURI(`http://127.0.0.1:3000/api/oauth/callback`)}&scope=${encodeURIComponent(scope)}`,
client_name: `Leaflet`,
diff --git a/app/api/oauth/[route]/route.ts b/app/api/oauth/[route]/route.ts
index 3d2c7641..108e032b 100644
--- a/app/api/oauth/[route]/route.ts
+++ b/app/api/oauth/[route]/route.ts
@@ -43,7 +43,7 @@ export async function GET(
const url = await client.authorize(handle || "https://bsky.social", {
scope:
- "atproto transition:email include:pub.leaflet.authFullPermissions include:site.standard.authFull include:app.bsky.authCreatePosts",
+ "atproto transition:email include:pub.leaflet.authFullPermissions include:site.standard.authFull include:app.bsky.authCreatePosts blob:*/*",
signal: ac.signal,
state: JSON.stringify(state),
});
--
2.51.2
From 7451ae3aa9c984ed04883220af38376d7d071f08 Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Sun, 8 Feb 2026 11:27:31 +0400
Subject: [PATCH 13/51] add post settings to footer
---
app/[leaflet_id]/Footer.tsx | 2 ++
1 file changed, 2 insertions(+)
diff --git a/app/[leaflet_id]/Footer.tsx b/app/[leaflet_id]/Footer.tsx
index d51cff0d..a202d2ee 100644
--- a/app/[leaflet_id]/Footer.tsx
+++ b/app/[leaflet_id]/Footer.tsx
@@ -14,6 +14,7 @@ import { useLeafletPublicationData } from "components/PageSWRDataProvider";
import { useIdentityData } from "components/IdentityProvider";
import { useEntity } from "src/replicache";
import { block } from "sharp";
+import { PostSettings } from "components/PostSettings";
export function hasBlockToolbar(blockType: string | null | undefined) {
return (
@@ -64,6 +65,7 @@ export function LeafletFooter(props: { entityID: string }) {
+
) : (
--
2.51.2
From 42887e837060062581abca488709212b39c866ec Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Sun, 8 Feb 2026 02:45:45 -0500
Subject: [PATCH 14/51] Fix missing 'of' in Bluesky mentions copy (#265)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add the missing "of" between "list" and "Bluesky" in the "Show Mentions"
toggle description across both PostSettings and PostOptions components.
Changes:
- components/PostSettings.tsx: "Display a list Bluesky mentions..." → "Display a list of Bluesky mentions..."
- app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx: Same fix
https://claude.ai/code/session_01UmMsAY9kbwJy4p1nD6sCh8
Co-authored-by: Claude
---
app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx | 2 +-
components/PostSettings.tsx | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx b/app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx
index 70ae3d75..07c02452 100644
--- a/app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx
+++ b/app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx
@@ -105,7 +105,7 @@ export const PostOptions = (props: {
Show Mentions
- Display a list Bluesky mentions about your post
+ Display a list of Bluesky mentions about your post
diff --git a/components/PostSettings.tsx b/components/PostSettings.tsx
index d8a92a0b..a6ec7db8 100644
--- a/components/PostSettings.tsx
+++ b/components/PostSettings.tsx
@@ -74,7 +74,7 @@ export function PostSettings() {
Show Mentions
- Display a list Bluesky mentions about your post
+ Display a list of Bluesky mentions about your post
--
2.51.2
From a4586e5fdac165f5e3953fbc79f2321d16d5cc79 Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Mon, 9 Feb 2026 14:32:43 +0400
Subject: [PATCH 15/51] add inngest function to track bsky post likes
---
app/api/inngest/client.ts | 6 +++
app/api/inngest/functions/sync_bsky_likes.ts | 41 +++++++++++++++++++
app/api/inngest/route.tsx | 2 +
app/api/rpc/[command]/get_publication_data.ts | 1 +
.../dashboard/PublishedPostsLists.tsx | 1 +
appview/index.ts | 18 ++++++++
drizzle/schema.ts | 3 +-
supabase/database.types.ts | 3 ++
.../20260209000000_add_bsky_like_count.sql | 1 +
9 files changed, 75 insertions(+), 1 deletion(-)
create mode 100644 app/api/inngest/functions/sync_bsky_likes.ts
create mode 100644 supabase/migrations/20260209000000_add_bsky_like_count.sql
diff --git a/app/api/inngest/client.ts b/app/api/inngest/client.ts
index 4dceb80f..f648b39f 100644
--- a/app/api/inngest/client.ts
+++ b/app/api/inngest/client.ts
@@ -51,6 +51,12 @@ export type Events = {
documentUris?: string[];
};
};
+ "appview/sync-bsky-likes": {
+ data: {
+ document_uri: string;
+ bsky_post_uri: string;
+ };
+ };
"user/write-records-to-pds": {
data: {
did: string;
diff --git a/app/api/inngest/functions/sync_bsky_likes.ts b/app/api/inngest/functions/sync_bsky_likes.ts
new file mode 100644
index 00000000..9b67aab8
--- /dev/null
+++ b/app/api/inngest/functions/sync_bsky_likes.ts
@@ -0,0 +1,41 @@
+import { inngest } from "../client";
+import { supabaseServerClient } from "supabase/serverClient";
+import { AtpAgent } from "@atproto/api";
+
+const TOTAL_ITERATIONS = 144; // 36 hours at 15-minute intervals
+
+export const sync_bsky_likes = inngest.createFunction(
+ {
+ id: "sync_bsky_likes",
+ idempotency: "event.data.bsky_post_uri",
+ },
+ { event: "appview/sync-bsky-likes" },
+ async ({ event, step }) => {
+ const { document_uri, bsky_post_uri } = event.data;
+
+ const agent = new AtpAgent({ service: "https://public.api.bsky.app" });
+
+ const fetchAndUpdate = async () => {
+ const res = await agent.app.bsky.feed.getPosts({
+ uris: [bsky_post_uri],
+ });
+ const post = res.data.posts[0];
+ if (!post) return 0;
+ const likeCount = post.likeCount ?? 0;
+ await supabaseServerClient
+ .from("documents")
+ .update({ bsky_like_count: likeCount })
+ .eq("uri", document_uri);
+ return likeCount;
+ };
+
+ let likeCount = await step.run("sync-0", fetchAndUpdate);
+
+ for (let i = 1; i < TOTAL_ITERATIONS; i++) {
+ await step.sleep(`wait-${i}`, "15m");
+ likeCount = await step.run(`sync-${i}`, fetchAndUpdate);
+ }
+
+ return { likeCount };
+ },
+);
diff --git a/app/api/inngest/route.tsx b/app/api/inngest/route.tsx
index ce86472d..ea0f2f9e 100644
--- a/app/api/inngest/route.tsx
+++ b/app/api/inngest/route.tsx
@@ -13,6 +13,7 @@ import {
check_oauth_session,
} from "./functions/cleanup_expired_oauth_sessions";
import { write_records_to_pds } from "./functions/write_records_to_pds";
+import { sync_bsky_likes } from "./functions/sync_bsky_likes";
export const { GET, POST, PUT } = serve({
client: inngest,
@@ -28,5 +29,6 @@ export const { GET, POST, PUT } = serve({
cleanup_expired_oauth_sessions,
check_oauth_session,
write_records_to_pds,
+ sync_bsky_likes,
],
});
diff --git a/app/api/rpc/[command]/get_publication_data.ts b/app/api/rpc/[command]/get_publication_data.ts
index 09cc91f4..9fe23804 100644
--- a/app/api/rpc/[command]/get_publication_data.ts
+++ b/app/api/rpc/[command]/get_publication_data.ts
@@ -86,6 +86,7 @@ export const get_publication_data = makeRoute({
indexed_at: dip.documents.indexed_at,
sort_date: dip.documents.sort_date,
data: dip.documents.data,
+ bsky_like_count: dip.documents.bsky_like_count,
commentsCount: dip.documents.comments_on_documents[0]?.count || 0,
mentionsCount: dip.documents.document_mentions_in_bsky[0]?.count || 0,
recommendsCount:
diff --git a/app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx b/app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
index a7e4c630..29b4b8a8 100644
--- a/app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
+++ b/app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
@@ -112,6 +112,7 @@ function PublishedPostItem(props: {
indexed_at: doc.indexed_at,
sort_date: doc.sort_date,
data: doc.data,
+ bsky_like_count: doc.bsky_like_count ?? 0,
},
},
],
diff --git a/appview/index.ts b/appview/index.ts
index 139ea9fd..7319f549 100644
--- a/appview/index.ts
+++ b/appview/index.ts
@@ -109,6 +109,15 @@ async function handleEvent(evt: Event) {
data: record.value as Json,
});
if (docResult.error) console.log(docResult.error);
+ if (record.value.postRef?.uri) {
+ await inngest.send({
+ name: "appview/sync-bsky-likes",
+ data: {
+ document_uri: evt.uri.toString(),
+ bsky_post_uri: record.value.postRef.uri,
+ },
+ });
+ }
if (record.value.publication) {
let publicationURI = new AtUri(record.value.publication);
@@ -269,6 +278,15 @@ async function handleEvent(evt: Event) {
data: record.value as Json,
});
if (docResult.error) console.log(docResult.error);
+ if (record.value.bskyPostRef?.uri) {
+ await inngest.send({
+ name: "appview/sync-bsky-likes",
+ data: {
+ document_uri: evt.uri.toString(),
+ bsky_post_uri: record.value.bskyPostRef.uri,
+ },
+ });
+ }
// site.standard.document uses "site" field to reference the publication
// For documents in publications, site is an AT-URI (at://did:plc:xxx/site.standard.publication/rkey)
diff --git a/drizzle/schema.ts b/drizzle/schema.ts
index 65cd9add..05b5b5ad 100644
--- a/drizzle/schema.ts
+++ b/drizzle/schema.ts
@@ -1,4 +1,4 @@
-import { pgTable, pgEnum, text, jsonb, foreignKey, timestamp, boolean, uuid, index, bigint, unique, uniqueIndex, smallint, primaryKey } from "drizzle-orm/pg-core"
+import { pgTable, pgEnum, text, jsonb, foreignKey, timestamp, boolean, uuid, index, bigint, unique, uniqueIndex, smallint, primaryKey, integer } from "drizzle-orm/pg-core"
import { sql } from "drizzle-orm"
export const aal_level = pgEnum("aal_level", ['aal1', 'aal2', 'aal3'])
@@ -225,6 +225,7 @@ export const documents = pgTable("documents", {
uri: text("uri").primaryKey().notNull(),
data: jsonb("data").notNull(),
indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
+ bsky_like_count: integer("bsky_like_count").default(0).notNull(),
});
export const atp_poll_votes = pgTable("atp_poll_votes", {
diff --git a/supabase/database.types.ts b/supabase/database.types.ts
index 6153540b..1eb727c2 100644
--- a/supabase/database.types.ts
+++ b/supabase/database.types.ts
@@ -335,17 +335,20 @@ export type Database = {
}
documents: {
Row: {
+ bsky_like_count: number
data: Json
indexed_at: string
sort_date: string
uri: string
}
Insert: {
+ bsky_like_count?: number
data: Json
indexed_at?: string
uri: string
}
Update: {
+ bsky_like_count?: number
data?: Json
indexed_at?: string
uri?: string
diff --git a/supabase/migrations/20260209000000_add_bsky_like_count.sql b/supabase/migrations/20260209000000_add_bsky_like_count.sql
new file mode 100644
index 00000000..676476b0
--- /dev/null
+++ b/supabase/migrations/20260209000000_add_bsky_like_count.sql
@@ -0,0 +1 @@
+ALTER TABLE documents ADD COLUMN bsky_like_count integer NOT NULL DEFAULT 0;
--
2.51.2
From 89a5e06cfb4576b98ad25c74688e7e1d99cdd0fb Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Mon, 9 Feb 2026 15:35:45 +0400
Subject: [PATCH 16/51] don't sync bsky likes of brid.gy posts
---
app/api/inngest/functions/sync_bsky_likes.ts | 16 +++++++++++++++-
1 file changed, 15 insertions(+), 1 deletion(-)
diff --git a/app/api/inngest/functions/sync_bsky_likes.ts b/app/api/inngest/functions/sync_bsky_likes.ts
index 9b67aab8..69dfe3c6 100644
--- a/app/api/inngest/functions/sync_bsky_likes.ts
+++ b/app/api/inngest/functions/sync_bsky_likes.ts
@@ -1,6 +1,7 @@
import { inngest } from "../client";
import { supabaseServerClient } from "supabase/serverClient";
-import { AtpAgent } from "@atproto/api";
+import { AtpAgent, AtUri } from "@atproto/api";
+import { idResolver } from "app/(home-pages)/reader/idResolver";
const TOTAL_ITERATIONS = 144; // 36 hours at 15-minute intervals
@@ -13,6 +14,19 @@ export const sync_bsky_likes = inngest.createFunction(
async ({ event, step }) => {
const { document_uri, bsky_post_uri } = event.data;
+ const isBridgy = await step.run("check-bridgy", async () => {
+ const did = new AtUri(bsky_post_uri).host;
+ const doc = await idResolver.did.resolve(did);
+ const handle = doc?.alsoKnownAs
+ ?.find((a) => a.startsWith("at://"))
+ ?.replace("at://", "");
+ return handle?.includes("brid.gy") ?? false;
+ });
+
+ if (isBridgy) {
+ return { skipped: true, reason: "brid.gy post" };
+ }
+
const agent = new AtpAgent({ service: "https://public.api.bsky.app" });
const fetchAndUpdate = async () => {
--
2.51.2
From 764c811b667edc30b90e41f16a35ed4f7cc65a77 Mon Sep 17 00:00:00 2001
From: Henrique Dias
Date: Mon, 9 Feb 2026 18:47:35 +0100
Subject: [PATCH 17/51] fix: copy post link button
---
components/InteractionsPreview.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/components/InteractionsPreview.tsx b/components/InteractionsPreview.tsx
index 9f393f86..930e2b36 100644
--- a/components/InteractionsPreview.tsx
+++ b/components/InteractionsPreview.tsx
@@ -77,7 +77,7 @@ export const InteractionPreview = (props: {
let mouseY = e.clientY;
if (!props.postUrl) return;
- navigator.clipboard.writeText(`leaflet.pub${props.postUrl}`);
+ navigator.clipboard.writeText(props.postUrl);
smoker({
text: Copied Link! ,
--
2.51.2
From 1971c0e399e1e670b34b3c4781ca93756fefc80a Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Mon, 9 Feb 2026 22:34:43 +0400
Subject: [PATCH 18/51] add bsky auth view all permissions
---
app/api/oauth/[route]/oauth-metadata.ts | 2 +-
app/api/oauth/[route]/route.ts | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/app/api/oauth/[route]/oauth-metadata.ts b/app/api/oauth/[route]/oauth-metadata.ts
index 32ed3b35..e29dcb9b 100644
--- a/app/api/oauth/[route]/oauth-metadata.ts
+++ b/app/api/oauth/[route]/oauth-metadata.ts
@@ -8,7 +8,7 @@ const hostname =
: "https://leaflet.pub";
const scope =
- "atproto transition:generic transition:email include:pub.leaflet.authFullPermissions include:site.standard.authFull include:app.bsky.authCreatePosts blob:*/*";
+ "atproto transition:generic transition:email include:pub.leaflet.authFullPermissions include:site.standard.authFull include:app.bsky.authCreatePosts include:app.bsky.authViewAll blob:*/*";
const localconfig: OAuthClientMetadataInput = {
client_id: `http://localhost/?redirect_uri=${encodeURI(`http://127.0.0.1:3000/api/oauth/callback`)}&scope=${encodeURIComponent(scope)}`,
client_name: `Leaflet`,
diff --git a/app/api/oauth/[route]/route.ts b/app/api/oauth/[route]/route.ts
index 108e032b..38f8ebee 100644
--- a/app/api/oauth/[route]/route.ts
+++ b/app/api/oauth/[route]/route.ts
@@ -43,7 +43,7 @@ export async function GET(
const url = await client.authorize(handle || "https://bsky.social", {
scope:
- "atproto transition:email include:pub.leaflet.authFullPermissions include:site.standard.authFull include:app.bsky.authCreatePosts blob:*/*",
+ "atproto transition:email include:pub.leaflet.authFullPermissions include:site.standard.authFull include:app.bsky.authCreatePosts include:app.bsky.authViewAll blob:*/*",
signal: ac.signal,
state: JSON.stringify(state),
});
--
2.51.2
From 7bb38cc61bc41c0f391e4cd98909fcff608cb059 Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Mon, 9 Feb 2026 23:28:35 +0400
Subject: [PATCH 19/51] add aud to bsky view permissions
---
app/api/oauth/[route]/oauth-metadata.ts | 2 +-
app/api/oauth/[route]/route.ts | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/app/api/oauth/[route]/oauth-metadata.ts b/app/api/oauth/[route]/oauth-metadata.ts
index e29dcb9b..6af54950 100644
--- a/app/api/oauth/[route]/oauth-metadata.ts
+++ b/app/api/oauth/[route]/oauth-metadata.ts
@@ -8,7 +8,7 @@ const hostname =
: "https://leaflet.pub";
const scope =
- "atproto transition:generic transition:email include:pub.leaflet.authFullPermissions include:site.standard.authFull include:app.bsky.authCreatePosts include:app.bsky.authViewAll blob:*/*";
+ "atproto transition:generic transition:email include:pub.leaflet.authFullPermissions include:site.standard.authFull include:app.bsky.authCreatePosts include:app.bsky.authViewAll?aud=did:web:api.bsky.app%23bsky_appview blob:*/*";
const localconfig: OAuthClientMetadataInput = {
client_id: `http://localhost/?redirect_uri=${encodeURI(`http://127.0.0.1:3000/api/oauth/callback`)}&scope=${encodeURIComponent(scope)}`,
client_name: `Leaflet`,
diff --git a/app/api/oauth/[route]/route.ts b/app/api/oauth/[route]/route.ts
index 38f8ebee..9c7eb139 100644
--- a/app/api/oauth/[route]/route.ts
+++ b/app/api/oauth/[route]/route.ts
@@ -43,7 +43,7 @@ export async function GET(
const url = await client.authorize(handle || "https://bsky.social", {
scope:
- "atproto transition:email include:pub.leaflet.authFullPermissions include:site.standard.authFull include:app.bsky.authCreatePosts include:app.bsky.authViewAll blob:*/*",
+ "atproto transition:email include:pub.leaflet.authFullPermissions include:site.standard.authFull include:app.bsky.authCreatePosts include:app.bsky.authViewAll?aud=did:web:api.bsky.app%23bsky_appview blob:*/*",
signal: ac.signal,
state: JSON.stringify(state),
});
--
2.51.2
From c81be9ad3fd9e3c74966ec1d88667b89b6c6e20f Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Tue, 10 Feb 2026 00:16:55 +0400
Subject: [PATCH 20/51] add recommend notification
---
.../notifications/NotificationList.tsx | 4 ++
.../notifications/RecommendNotification.tsx | 48 ++++++++++++++
.../[rkey]/Interactions/recommendAction.ts | 21 +++++++
src/notifications.ts | 63 +++++++++++++++++--
4 files changed, 132 insertions(+), 4 deletions(-)
create mode 100644 app/(home-pages)/notifications/RecommendNotification.tsx
diff --git a/app/(home-pages)/notifications/NotificationList.tsx b/app/(home-pages)/notifications/NotificationList.tsx
index a0ec12ee..30852227 100644
--- a/app/(home-pages)/notifications/NotificationList.tsx
+++ b/app/(home-pages)/notifications/NotificationList.tsx
@@ -11,6 +11,7 @@ import { QuoteNotification } from "./QuoteNotification";
import { BskyPostEmbedNotification } from "./BskyPostEmbedNotification";
import { MentionNotification } from "./MentionNotification";
import { CommentMentionNotification } from "./CommentMentionNotification";
+import { RecommendNotification } from "./RecommendNotification";
export function NotificationList({
notifications,
@@ -58,6 +59,9 @@ export function NotificationList({
if (n.type === "comment_mention") {
return ;
}
+ if (n.type === "recommend") {
+ return ;
+ }
})}
diff --git a/app/(home-pages)/notifications/RecommendNotification.tsx b/app/(home-pages)/notifications/RecommendNotification.tsx
new file mode 100644
index 00000000..3abb49d3
--- /dev/null
+++ b/app/(home-pages)/notifications/RecommendNotification.tsx
@@ -0,0 +1,48 @@
+import { ContentLayout, Notification } from "./Notification";
+import { HydratedRecommendNotification } from "src/notifications";
+import { blobRefToSrc } from "src/utils/blobRefToSrc";
+import { AppBskyActorProfile } from "lexicons/api";
+import { Avatar } from "components/Avatar";
+import { AtUri } from "@atproto/api";
+import { RecommendTinyFilled } from "components/Icons/RecommendTiny";
+
+export const RecommendNotification = (
+ props: HydratedRecommendNotification,
+) => {
+ const profileRecord = props.recommendData?.identities?.bsky_profiles
+ ?.record as AppBskyActorProfile.Record;
+ const displayName =
+ profileRecord?.displayName ||
+ props.recommendData?.identities?.bsky_profiles?.handle ||
+ "Someone";
+ const docRecord = props.normalizedDocument;
+ const pubRecord = props.normalizedPublication;
+ const avatarSrc =
+ profileRecord?.avatar?.ref &&
+ blobRefToSrc(
+ profileRecord.avatar.ref,
+ props.recommendData?.recommender_did || "",
+ );
+
+ if (!docRecord) return null;
+
+ const docUri = new AtUri(props.document.uri);
+ const rkey = docUri.rkey;
+ const did = docUri.host;
+
+ const href = pubRecord ? `${pubRecord.url}/${rkey}` : `/p/${did}/${rkey}`;
+
+ return (
+
}
+ actionText={<>{displayName} recommended your post>}
+ content={
+
+ {null}
+
+ }
+ />
+ );
+};
diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/recommendAction.ts b/app/lish/[did]/[publication]/[rkey]/Interactions/recommendAction.ts
index d0295f26..c219720c 100644
--- a/app/lish/[did]/[publication]/[rkey]/Interactions/recommendAction.ts
+++ b/app/lish/[did]/[publication]/[rkey]/Interactions/recommendAction.ts
@@ -7,6 +7,11 @@ import { TID } from "@atproto/common";
import { AtUri, Un$Typed } from "@atproto/api";
import { supabaseServerClient } from "supabase/serverClient";
import { Json } from "supabase/database.types";
+import { v7 } from "uuid";
+import {
+ Notification,
+ pingIdentityToUpdateNotification,
+} from "src/notifications";
type RecommendResult =
| { success: true; uri: string }
@@ -68,6 +73,22 @@ export async function recommendAction(args: {
});
console.log(res);
+ // Notify the document owner
+ let documentOwner = new AtUri(args.document).host;
+ if (documentOwner !== credentialSession.did) {
+ let notification: Notification = {
+ id: v7(),
+ recipient: documentOwner,
+ data: {
+ type: "recommend",
+ document_uri: args.document,
+ recommend_uri: uri.toString(),
+ },
+ };
+ await supabaseServerClient.from("notifications").insert(notification);
+ await pingIdentityToUpdateNotification(documentOwner);
+ }
+
return {
success: true,
uri: uri.toString(),
diff --git a/src/notifications.ts b/src/notifications.ts
index f4353d2b..be5ae4bb 100644
--- a/src/notifications.ts
+++ b/src/notifications.ts
@@ -27,7 +27,8 @@ export type NotificationData =
| { type: "mention"; document_uri: string; mention_type: "document"; mentioned_uri: string }
| { type: "comment_mention"; comment_uri: string; mention_type: "did" }
| { type: "comment_mention"; comment_uri: string; mention_type: "publication"; mentioned_uri: string }
- | { type: "comment_mention"; comment_uri: string; mention_type: "document"; mentioned_uri: string };
+ | { type: "comment_mention"; comment_uri: string; mention_type: "document"; mentioned_uri: string }
+ | { type: "recommend"; document_uri: string; recommend_uri: string };
export type HydratedNotification =
| HydratedCommentNotification
@@ -35,22 +36,24 @@ export type HydratedNotification =
| HydratedQuoteNotification
| HydratedBskyPostEmbedNotification
| HydratedMentionNotification
- | HydratedCommentMentionNotification;
+ | HydratedCommentMentionNotification
+ | HydratedRecommendNotification;
export async function hydrateNotifications(
notifications: NotificationRow[],
): Promise
> {
// Call all hydrators in parallel
- const [commentNotifications, subscribeNotifications, quoteNotifications, bskyPostEmbedNotifications, mentionNotifications, commentMentionNotifications] = await Promise.all([
+ const [commentNotifications, subscribeNotifications, quoteNotifications, bskyPostEmbedNotifications, mentionNotifications, commentMentionNotifications, recommendNotifications] = await Promise.all([
hydrateCommentNotifications(notifications),
hydrateSubscribeNotifications(notifications),
hydrateQuoteNotifications(notifications),
hydrateBskyPostEmbedNotifications(notifications),
hydrateMentionNotifications(notifications),
hydrateCommentMentionNotifications(notifications),
+ hydrateRecommendNotifications(notifications),
]);
// Combine all hydrated notifications
- const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications, ...bskyPostEmbedNotifications, ...mentionNotifications, ...commentMentionNotifications];
+ const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications, ...bskyPostEmbedNotifications, ...mentionNotifications, ...commentMentionNotifications, ...recommendNotifications];
// Sort by created_at to maintain order
allHydrated.sort(
@@ -519,6 +522,58 @@ async function hydrateCommentMentionNotifications(notifications: NotificationRow
.filter((n) => n !== null);
}
+export type HydratedRecommendNotification = Awaited<
+ ReturnType
+>[0];
+
+async function hydrateRecommendNotifications(notifications: NotificationRow[]) {
+ const recommendNotifications = notifications.filter(
+ (n): n is NotificationRow & { data: ExtractNotificationType<"recommend"> } =>
+ (n.data as NotificationData)?.type === "recommend",
+ );
+
+ if (recommendNotifications.length === 0) {
+ return [];
+ }
+
+ // Fetch recommend data from the database
+ const recommendUris = recommendNotifications.map((n) => n.data.recommend_uri);
+ const documentUris = recommendNotifications.map((n) => n.data.document_uri);
+
+ const [{ data: recommends }, { data: documents }] = await Promise.all([
+ supabaseServerClient
+ .from("recommends_on_documents")
+ .select("*, identities(bsky_profiles(*))")
+ .in("uri", recommendUris),
+ supabaseServerClient
+ .from("documents")
+ .select("*, documents_in_publications(publications(*))")
+ .in("uri", documentUris),
+ ]);
+
+ return recommendNotifications
+ .map((notification) => {
+ const recommendData = recommends?.find((r) => r.uri === notification.data.recommend_uri);
+ const document = documents?.find((d) => d.uri === notification.data.document_uri);
+ if (!recommendData || !document) return null;
+ return {
+ id: notification.id,
+ recipient: notification.recipient,
+ created_at: notification.created_at,
+ type: "recommend" as const,
+ recommend_uri: notification.data.recommend_uri,
+ document_uri: notification.data.document_uri,
+ recommendData,
+ document,
+ normalizedDocument: normalizeDocumentRecord(document.data, document.uri),
+ normalizedPublication: normalizePublicationRecord(
+ document.documents_in_publications[0]?.publications?.record,
+ ),
+ };
+ })
+ .filter((n) => n !== null);
+}
+
export async function pingIdentityToUpdateNotification(did: string) {
let channel = supabaseServerClient.channel(`identity.atp_did:${did}`);
await channel.send({
--
2.51.2
From 856e0c8fa0829bf8b382775d1483f96233cc758e Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Tue, 10 Feb 2026 13:01:14 +0400
Subject: [PATCH 21/51] fix post links to standard site blogs
---
.../BskyPostEmbedNotification.tsx | 7 +--
.../CommentMentionNotification.tsx | 10 ++---
.../notifications/CommentNotication.tsx | 10 ++---
.../notifications/MentionNotification.tsx | 9 +---
.../notifications/QuoteNotification.tsx | 8 +---
.../notifications/RecommendNotification.tsx | 7 +--
.../notifications/ReplyNotification.tsx | 10 ++---
.../actions/ShareOptions/index.tsx | 16 ++++---
.../[command]/search_publication_documents.ts | 12 ++---
.../[publication]/[rkey]/getPostPageData.ts | 12 ++---
.../dashboard/PublishedPostsLists.tsx | 7 +--
app/lish/[did]/[publication]/generateFeed.ts | 6 ++-
app/lish/[did]/[publication]/page.tsx | 7 +--
app/lish/createPub/getPublicationURL.ts | 45 +++++++++++++++++++
components/PageSWRDataProvider.tsx | 28 +++++++++---
components/PostListing.tsx | 5 +--
16 files changed, 123 insertions(+), 76 deletions(-)
diff --git a/app/(home-pages)/notifications/BskyPostEmbedNotification.tsx b/app/(home-pages)/notifications/BskyPostEmbedNotification.tsx
index 2b433771..66cf0018 100644
--- a/app/(home-pages)/notifications/BskyPostEmbedNotification.tsx
+++ b/app/(home-pages)/notifications/BskyPostEmbedNotification.tsx
@@ -2,6 +2,7 @@ import { BlueskyTiny } from "components/Icons/BlueskyTiny";
import { ContentLayout, Notification } from "./Notification";
import { HydratedBskyPostEmbedNotification } from "src/notifications";
import { AtUri } from "@atproto/api";
+import { getDocumentURL } from "app/lish/createPub/getPublicationURL";
export const BskyPostEmbedNotification = (
props: HydratedBskyPostEmbedNotification,
@@ -11,11 +12,7 @@ export const BskyPostEmbedNotification = (
if (!docRecord) return null;
- const docUri = new AtUri(props.document.uri);
- const rkey = docUri.rkey;
- const did = docUri.host;
-
- const href = pubRecord ? `${pubRecord.url}/${rkey}` : `/p/${did}/${rkey}`;
+ const href = getDocumentURL(docRecord, props.document.uri, pubRecord);
const embedder = props.documentCreatorHandle
? `@${props.documentCreatorHandle}`
diff --git a/app/(home-pages)/notifications/CommentMentionNotification.tsx b/app/(home-pages)/notifications/CommentMentionNotification.tsx
index 10917d61..3bf1e63d 100644
--- a/app/(home-pages)/notifications/CommentMentionNotification.tsx
+++ b/app/(home-pages)/notifications/CommentMentionNotification.tsx
@@ -8,6 +8,7 @@ import {
Notification,
} from "./Notification";
import { AtUri } from "@atproto/api";
+import { getDocumentURL } from "app/lish/createPub/getPublicationURL";
export const CommentMentionNotification = (
props: HydratedCommentMentionNotification,
@@ -19,13 +20,10 @@ export const CommentMentionNotification = (
const profileRecord = props.commentData.bsky_profiles
?.record as AppBskyActorProfile.Record;
const pubRecord = props.normalizedPublication;
- const docUri = new AtUri(props.commentData.documents?.uri!);
- const rkey = docUri.rkey;
- const did = docUri.host;
- const href = pubRecord
- ? `${pubRecord.url}/${rkey}?interactionDrawer=comments`
- : `/p/${did}/${rkey}?interactionDrawer=comments`;
+ const href =
+ getDocumentURL(docRecord, props.commentData.documents?.uri!, pubRecord) +
+ "?interactionDrawer=comments";
const commenter = props.commenterHandle
? `@${props.commenterHandle}`
diff --git a/app/(home-pages)/notifications/CommentNotication.tsx b/app/(home-pages)/notifications/CommentNotication.tsx
index 500d2783..fde0e160 100644
--- a/app/(home-pages)/notifications/CommentNotication.tsx
+++ b/app/(home-pages)/notifications/CommentNotication.tsx
@@ -10,6 +10,7 @@ import {
Notification,
} from "./Notification";
import { AtUri } from "@atproto/api";
+import { getDocumentURL } from "app/lish/createPub/getPublicationURL";
export const CommentNotification = (props: HydratedCommentNotification) => {
const docRecord = props.normalizedDocument;
@@ -24,13 +25,10 @@ export const CommentNotification = (props: HydratedCommentNotification) => {
props.commentData.bsky_profiles?.handle ||
"Someone";
const pubRecord = props.normalizedPublication;
- const docUri = new AtUri(props.commentData.documents?.uri!);
- const rkey = docUri.rkey;
- const did = docUri.host;
- const href = pubRecord
- ? `${pubRecord.url}/${rkey}?interactionDrawer=comments`
- : `/p/${did}/${rkey}?interactionDrawer=comments`;
+ const href =
+ getDocumentURL(docRecord, props.commentData.documents?.uri!, pubRecord) +
+ "?interactionDrawer=comments";
return (
{
const docRecord = props.normalizedDocument;
@@ -9,13 +10,7 @@ export const MentionNotification = (props: HydratedMentionNotification) => {
if (!docRecord) return null;
- const docUri = new AtUri(props.document.uri);
- const rkey = docUri.rkey;
- const did = docUri.host;
-
- const href = pubRecord
- ? `${pubRecord.url}/${rkey}`
- : `/p/${did}/${rkey}`;
+ const href = getDocumentURL(docRecord, props.document.uri, pubRecord);
let actionText: React.ReactNode;
let mentionedItemName: string | undefined;
diff --git a/app/(home-pages)/notifications/QuoteNotification.tsx b/app/(home-pages)/notifications/QuoteNotification.tsx
index d6849205..6c7b3f16 100644
--- a/app/(home-pages)/notifications/QuoteNotification.tsx
+++ b/app/(home-pages)/notifications/QuoteNotification.tsx
@@ -3,6 +3,7 @@ import { ContentLayout, Notification } from "./Notification";
import { HydratedQuoteNotification } from "src/notifications";
import { AtUri } from "@atproto/api";
import { Avatar } from "components/Avatar";
+import { getDocumentURL } from "app/lish/createPub/getPublicationURL";
export const QuoteNotification = (props: HydratedQuoteNotification) => {
const postView = props.bskyPost.post_view as any;
@@ -13,14 +14,9 @@ export const QuoteNotification = (props: HydratedQuoteNotification) => {
if (!docRecord) return null;
- const docUri = new AtUri(props.document.uri);
- const rkey = docUri.rkey;
- const did = docUri.host;
const postText = postView.record?.text || "";
- const href = pubRecord
- ? `${pubRecord.url}/${rkey}`
- : `/p/${did}/${rkey}`;
+ const href = getDocumentURL(docRecord, props.document.uri, pubRecord);
return (
{
const docRecord = props.normalizedDocument;
@@ -32,14 +33,11 @@ export const ReplyNotification = (props: HydratedCommentNotification) => {
props.parentData?.bsky_profiles?.handle ||
"Someone";
- const docUri = new AtUri(props.commentData.documents?.uri!);
- const rkey = docUri.rkey;
- const did = docUri.host;
const pubRecord = props.normalizedPublication;
- const href = pubRecord
- ? `${pubRecord.url}/${rkey}?interactionDrawer=comments`
- : `/p/${did}/${rkey}?interactionDrawer=comments`;
+ const href =
+ getDocumentURL(docRecord, props.commentData.documents?.uri!, pubRecord) +
+ "?interactionDrawer=comments";
return (
(null);
useEffect(() => {
diff --git a/app/api/rpc/[command]/search_publication_documents.ts b/app/api/rpc/[command]/search_publication_documents.ts
index a58f6867..a52be5f6 100644
--- a/app/api/rpc/[command]/search_publication_documents.ts
+++ b/app/api/rpc/[command]/search_publication_documents.ts
@@ -2,7 +2,8 @@ import { AtUri } from "@atproto/api";
import { z } from "zod";
import { makeRoute } from "../lib";
import type { Env } from "./route";
-import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
+import { getDocumentURL } from "app/lish/createPub/getPublicationURL";
+import { normalizeDocumentRecord } from "src/utils/normalizeRecords";
export type SearchPublicationDocumentsReturnType = Awaited<
ReturnType<(typeof search_publication_documents)["handler"]>
@@ -37,13 +38,14 @@ export const search_publication_documents = makeRoute({
}
const result = documents.map((d) => {
- const docUri = new AtUri(d.documents.uri);
- const pubUrl = getPublicationURL(d.publications);
+ const normalizedDoc = normalizeDocumentRecord(d.documents.data, d.documents.uri);
return {
uri: d.documents.uri,
- title: (d.documents.data as { title?: string })?.title || "Untitled",
- url: `${pubUrl}/${docUri.rkey}`,
+ title: normalizedDoc?.title || (d.documents.data as { title?: string })?.title || "Untitled",
+ url: normalizedDoc
+ ? getDocumentURL(normalizedDoc, d.documents.uri, d.publications)
+ : `${d.documents.uri}`,
};
});
diff --git a/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts b/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
index 7f16418a..49eb500b 100644
--- a/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
+++ b/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
@@ -8,6 +8,7 @@ import {
} from "src/utils/normalizeRecords";
import { PubLeafletPublication, SiteStandardPublication } from "lexicons/api";
import { documentUriFilter } from "src/utils/uriHelpers";
+import { getDocumentURL } from "app/lish/createPub/getPublicationURL";
export async function getPostPageData(did: string, rkey: string) {
let { data: documents } = await supabaseServerClient
@@ -46,11 +47,12 @@ export async function getPostPageData(did: string, rkey: string) {
);
// Fetch constellation backlinks for mentions
- let aturi = new AtUri(document.uri);
- const postUrl = normalizedPublication
- ? `${normalizedPublication.url}/${aturi.rkey}`
- : `https://leaflet.pub/p/${aturi.host}/${aturi.rkey}`;
- const constellationBacklinks = await getConstellationBacklinks(postUrl);
+ const postUrl = getDocumentURL(normalizedDocument, document.uri, normalizedPublication);
+ // Constellation needs an absolute URL
+ const absolutePostUrl = postUrl.startsWith("/")
+ ? `https://leaflet.pub${postUrl}`
+ : postUrl;
+ const constellationBacklinks = await getConstellationBacklinks(absolutePostUrl);
// Deduplicate constellation backlinks (same post could appear in both links and embeds)
const uniqueBacklinks = Array.from(
diff --git a/app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx b/app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
index 29b4b8a8..a3e25db9 100644
--- a/app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
+++ b/app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
@@ -9,7 +9,7 @@ import {
} from "./PublicationSWRProvider";
import { Fragment } from "react";
import { useParams } from "next/navigation";
-import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
+import { getPublicationURL, getDocumentURL } from "app/lish/createPub/getPublicationURL";
import { SpeedyLink } from "components/SpeedyLink";
import { InteractionPreview } from "components/InteractionsPreview";
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
@@ -71,6 +71,7 @@ function PublishedPostItem(props: {
const leaflet = publication.leaflets_in_publications.find(
(l) => l.doc === doc.uri,
);
+ const docUrl = getDocumentURL(doc.record, doc.uri, publication);
return (
@@ -87,7 +88,7 @@ function PublishedPostItem(props: {
{doc.record.title}
@@ -144,7 +145,7 @@ function PublishedPostItem(props: {
showComments={pubRecord?.preferences?.showComments !== false}
showMentions={pubRecord?.preferences?.showMentions !== false}
showRecommends={pubRecord?.preferences?.showRecommends !== false}
- postUrl={`${getPublicationURL(publication)}/${uri.rkey}`}
+ postUrl={docUrl}
/>
diff --git a/app/lish/[did]/[publication]/generateFeed.ts b/app/lish/[did]/[publication]/generateFeed.ts
index e12aa75f..383f9461 100644
--- a/app/lish/[did]/[publication]/generateFeed.ts
+++ b/app/lish/[did]/[publication]/generateFeed.ts
@@ -11,6 +11,7 @@ import {
hasLeafletContent,
} from "src/utils/normalizeRecords";
import { publicationNameOrUriFilter } from "src/utils/uriHelpers";
+import { getDocumentURL } from "app/lish/createPub/getPublicationURL";
export async function generateFeed(
did: string,
@@ -84,12 +85,13 @@ export async function generateFeed(
}
}
+ const docUrl = getDocumentURL(record, doc.documents.uri, pubRecord);
feed.addItem({
title: record.title,
description: record.description,
date: record.publishedAt ? new Date(record.publishedAt) : new Date(),
- id: `${pubRecord.url}/${rkey}`,
- link: `${pubRecord.url}/${rkey}`,
+ id: docUrl,
+ link: docUrl,
content: chunks.join(""),
});
}),
diff --git a/app/lish/[did]/[publication]/page.tsx b/app/lish/[did]/[publication]/page.tsx
index 457f38b2..6d6e2e31 100644
--- a/app/lish/[did]/[publication]/page.tsx
+++ b/app/lish/[did]/[publication]/page.tsx
@@ -1,6 +1,6 @@
import { supabaseServerClient } from "supabase/serverClient";
import { AtUri } from "@atproto/syntax";
-import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
+import { getPublicationURL, getDocumentURL } from "app/lish/createPub/getPublicationURL";
import { BskyAgent } from "@atproto/api";
import { publicationNameOrUriFilter } from "src/utils/uriHelpers";
import { SubscribeWithBluesky } from "app/lish/Subscribe";
@@ -135,11 +135,12 @@ export default async function Publication(props: {
doc.documents.recommends_on_documents?.[0]?.count || 0;
let tags = doc_record.tags || [];
+ const docUrl = getDocumentURL(doc_record, doc.documents.uri, publication);
return (
{doc_record.title}
@@ -168,7 +169,7 @@ export default async function Publication(props: {
recommendsCount={recommends}
documentUri={doc.documents.uri}
tags={tags}
- postUrl={`${getPublicationURL(publication)}/${uri.rkey}`}
+ postUrl={docUrl}
showComments={
record?.preferences?.showComments !== false
}
diff --git a/app/lish/createPub/getPublicationURL.ts b/app/lish/createPub/getPublicationURL.ts
index 613bef1b..3f864599 100644
--- a/app/lish/createPub/getPublicationURL.ts
+++ b/app/lish/createPub/getPublicationURL.ts
@@ -5,6 +5,8 @@ import { Json } from "supabase/database.types";
import {
normalizePublicationRecord,
isLeafletPublication,
+ hasLeafletContent,
+ type NormalizedDocument,
type NormalizedPublication,
} from "src/utils/normalizeRecords";
@@ -44,3 +46,46 @@ export function getBasePublicationURL(pub: PublicationInput): string {
const name = aturi.rkey || normalized?.name;
return `/lish/${aturi.host}/${encodeURIComponent(name || "")}`;
}
+
+/**
+ * Gets the full URL for a document.
+ * Always appends the document's path property.
+ * For non-leaflet documents (content.$type !== "pub.leaflet.content"),
+ * always uses the full publication site URL, not internal /lish/ URLs.
+ */
+export function getDocumentURL(
+ doc: NormalizedDocument,
+ docUri: string,
+ publication?: PublicationInput | NormalizedPublication | null,
+): string {
+ const path = doc.path || "/" + new AtUri(docUri).rkey;
+ const aturi = new AtUri(docUri);
+
+ const isNormalized =
+ !!publication &&
+ (publication as NormalizedPublication).$type === "site.standard.publication";
+ const normPub = isNormalized
+ ? (publication as NormalizedPublication)
+ : publication
+ ? normalizePublicationRecord((publication as PublicationInput).record)
+ : null;
+ const pubInput = isNormalized ? null : (publication as PublicationInput | null);
+
+ // Non-leaflet documents always use the full publication site URL
+ if (doc.content && !hasLeafletContent(doc) && normPub?.url) {
+ return normPub.url + path;
+ }
+
+ // For leaflet documents, use getPublicationURL (may return /lish/ internal paths)
+ if (pubInput) {
+ return getPublicationURL(pubInput) + path;
+ }
+
+ // When we only have a normalized publication, use its URL directly
+ if (normPub?.url) {
+ return normPub.url + path;
+ }
+
+ // Standalone document fallback
+ return `/p/${aturi.host}${path}`;
+}
diff --git a/components/PageSWRDataProvider.tsx b/components/PageSWRDataProvider.tsx
index 227c17e9..189a4e43 100644
--- a/components/PageSWRDataProvider.tsx
+++ b/components/PageSWRDataProvider.tsx
@@ -8,7 +8,7 @@ import { getPollData } from "actions/pollActions";
import type { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data";
import { createContext, useContext, useMemo } from "react";
import { getPublicationMetadataFromLeafletData } from "src/utils/getPublicationMetadataFromLeafletData";
-import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
+import { getPublicationURL, getDocumentURL } from "app/lish/createPub/getPublicationURL";
import { AtUri } from "@atproto/syntax";
import {
normalizeDocumentRecord,
@@ -119,13 +119,27 @@ export function useLeafletPublicationStatus() {
// Compute the full post URL for sharing
let postShareLink: string | undefined;
if (publishedInPublication?.publications && publishedInPublication.documents) {
- // Published in a publication - use publication URL + document rkey
- const docUri = new AtUri(publishedInPublication.documents.uri);
- postShareLink = `${getPublicationURL(publishedInPublication.publications)}/${docUri.rkey}`;
+ const normalizedDoc = normalizeDocumentRecord(
+ publishedInPublication.documents.data,
+ publishedInPublication.documents.uri,
+ );
+ if (normalizedDoc) {
+ postShareLink = getDocumentURL(
+ normalizedDoc,
+ publishedInPublication.documents.uri,
+ publishedInPublication.publications,
+ );
+ }
} else if (publishedStandalone?.document) {
- // Standalone published post - use /p/{did}/{rkey} format
- const docUri = new AtUri(publishedStandalone.document);
- postShareLink = `/p/${docUri.host}/${docUri.rkey}`;
+ const normalizedDoc = publishedStandalone.documents
+ ? normalizeDocumentRecord(publishedStandalone.documents.data, publishedStandalone.document)
+ : null;
+ if (normalizedDoc) {
+ postShareLink = getDocumentURL(normalizedDoc, publishedStandalone.document);
+ } else {
+ const docUri = new AtUri(publishedStandalone.document);
+ postShareLink = `/p/${docUri.host}/${docUri.rkey}`;
+ }
}
return {
diff --git a/components/PostListing.tsx b/components/PostListing.tsx
index 32bd9bd4..cee5cd5a 100644
--- a/components/PostListing.tsx
+++ b/components/PostListing.tsx
@@ -18,6 +18,7 @@ import Link from "next/link";
import { InteractionPreview } from "./InteractionsPreview";
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
import { mergePreferences } from "src/utils/mergePreferences";
+import { getDocumentURL } from "app/lish/createPub/getPublicationURL";
export const PostListing = (props: Post) => {
let pubRecord = props.publication?.pubRecord as
@@ -60,9 +61,7 @@ export const PostListing = (props: Post) => {
let tags = (postRecord?.tags as string[] | undefined) || [];
// For standalone posts, link directly to the document
- let postHref = props.publication
- ? `${props.publication.href}/${postUri.rkey}`
- : `/p/${postUri.host}/${postUri.rkey}`;
+ let postHref = getDocumentURL(postRecord, props.documents.uri, pubRecord);
return (
--
2.51.2
From e023d527a637797a9daf04cc1e2df77e2f8a9bf1 Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Tue, 10 Feb 2026 13:23:51 +0400
Subject: [PATCH 22/51] don't getpreferences on subscribe
---
app/lish/subscribeToPublication.ts | 23 ++++++++++-------------
1 file changed, 10 insertions(+), 13 deletions(-)
diff --git a/app/lish/subscribeToPublication.ts b/app/lish/subscribeToPublication.ts
index e321361d..ed712efe 100644
--- a/app/lish/subscribeToPublication.ts
+++ b/app/lish/subscribeToPublication.ts
@@ -3,10 +3,7 @@
import { AtpBaseClient } from "lexicons/api";
import { AppBskyActorDefs, Agent as BskyAgent } from "@atproto/api";
import { getIdentityData } from "actions/getIdentityData";
-import {
- restoreOAuthSession,
- OAuthSessionError,
-} from "src/atproto-oauth";
+import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth";
import { TID } from "@atproto/common";
import { supabaseServerClient } from "supabase/serverClient";
import { revalidatePath } from "next/cache";
@@ -79,8 +76,7 @@ export async function subscribeToPublication(
}
let bsky = new BskyAgent(credentialSession);
- let [prefs, profile, resolveDid] = await Promise.all([
- bsky.app.bsky.actor.getPreferences(),
+ let [profile, resolveDid] = await Promise.all([
bsky.app.bsky.actor.profile
.get({
repo: credentialSession.did!,
@@ -96,13 +92,10 @@ export async function subscribeToPublication(
handle: resolveDid?.alsoKnownAs?.[0]?.slice(5),
});
}
- let savedFeeds = prefs.data.preferences.find(
- (pref) => pref.$type === "app.bsky.actor.defs#savedFeedsPrefV2",
- ) as AppBskyActorDefs.SavedFeedsPrefV2;
revalidatePath("/lish/[did]/[publication]", "layout");
return {
success: true,
- hasFeed: !!savedFeeds.items.find((feed) => feed.value === leafletFeedURI),
+ hasFeed: true,
};
}
@@ -111,7 +104,7 @@ type UnsubscribeResult =
| { success: false; error: OAuthSessionError };
export async function unsubscribeToPublication(
- publication: string
+ publication: string,
): Promise {
let identity = await getIdentityData();
if (!identity || !identity.atp_did) {
@@ -144,8 +137,12 @@ export async function unsubscribeToPublication(
// Delete from both collections (old and new schema) - one or both may exist
let rkey = new AtUri(existingSubscription.uri).rkey;
await Promise.all([
- agent.pub.leaflet.graph.subscription.delete({ repo: credentialSession.did!, rkey }).catch(() => {}),
- agent.site.standard.graph.subscription.delete({ repo: credentialSession.did!, rkey }).catch(() => {}),
+ agent.pub.leaflet.graph.subscription
+ .delete({ repo: credentialSession.did!, rkey })
+ .catch(() => {}),
+ agent.site.standard.graph.subscription
+ .delete({ repo: credentialSession.did!, rkey })
+ .catch(() => {}),
]);
await supabaseServerClient
--
2.51.2
From 3ccb429f1b08799df585438be427195cf975325e Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Tue, 10 Feb 2026 17:04:12 +0400
Subject: [PATCH 23/51] add recommend column to documents
---
.../20260210000000_add_recommend_count.sql | 23 +++++++++++++++++++
1 file changed, 23 insertions(+)
create mode 100644 supabase/migrations/20260210000000_add_recommend_count.sql
diff --git a/supabase/migrations/20260210000000_add_recommend_count.sql b/supabase/migrations/20260210000000_add_recommend_count.sql
new file mode 100644
index 00000000..f0dd1160
--- /dev/null
+++ b/supabase/migrations/20260210000000_add_recommend_count.sql
@@ -0,0 +1,23 @@
+ALTER TABLE documents ADD COLUMN recommend_count integer NOT NULL DEFAULT 0;
+
+UPDATE documents d
+SET recommend_count = (
+ SELECT COUNT(*) FROM recommends_on_documents r WHERE r.document = d.uri
+);
+
+CREATE OR REPLACE FUNCTION update_recommend_count() RETURNS trigger AS $$
+BEGIN
+ IF TG_OP = 'INSERT' THEN
+ UPDATE documents SET recommend_count = recommend_count + 1
+ WHERE uri = NEW.document;
+ ELSIF TG_OP = 'DELETE' THEN
+ UPDATE documents SET recommend_count = recommend_count - 1
+ WHERE uri = OLD.document;
+ END IF;
+ RETURN NULL;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER trg_recommend_count
+AFTER INSERT OR DELETE ON recommends_on_documents
+FOR EACH ROW EXECUTE FUNCTION update_recommend_count();
--
2.51.2
From 1021011dc090d8a4b74cad59342326afa77445f1 Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Tue, 10 Feb 2026 21:44:21 +0400
Subject: [PATCH 24/51] check permissions on retractions
---
src/replicache/cachedServerMutationContext.ts | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/src/replicache/cachedServerMutationContext.ts b/src/replicache/cachedServerMutationContext.ts
index 322f773e..cad2eb64 100644
--- a/src/replicache/cachedServerMutationContext.ts
+++ b/src/replicache/cachedServerMutationContext.ts
@@ -155,6 +155,20 @@ export function cachedServerMutationContext(
});
},
async retractFact(factID) {
+ let cachedFact = writeCache.find(
+ (f) => f.type === "put" && f.fact.id === factID,
+ );
+ let entity: string | undefined;
+ if (cachedFact && cachedFact.type === "put") {
+ entity = cachedFact.fact.entity;
+ } else {
+ let [row] = await tx
+ .select({ entity: facts.entity })
+ .from(facts)
+ .where(driz.eq(facts.id, factID));
+ entity = row?.entity;
+ }
+ if (!entity || !(await this.checkPermission(entity))) return;
writeCache = writeCache.filter((f) => f.fact.id !== factID);
writeCache.push({ type: "del", fact: { id: factID } });
},
--
2.51.2
From 27b896242aa84b1d3de24c74cafc1ca77691b6dc Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Tue, 10 Feb 2026 21:50:45 +0400
Subject: [PATCH 25/51] don't show block options if no write permissions
---
components/Blocks/Block.tsx | 1 +
1 file changed, 1 insertion(+)
diff --git a/components/Blocks/Block.tsx b/components/Blocks/Block.tsx
index dbf1e61c..33ff933a 100644
--- a/components/Blocks/Block.tsx
+++ b/components/Blocks/Block.tsx
@@ -419,6 +419,7 @@ const NonTextBlockOptions = (props: {
if (focusedEntity?.entityType === "page") return;
if (isMultiselected) return;
+ if (!entity_set.permissions.write) return null;
return (
Date: Tue, 10 Feb 2026 22:14:23 +0400
Subject: [PATCH 26/51] handle pasting bluesky posts
---
components/Blocks/TextBlock/useHandlePaste.ts | 21 ++++++++++++++++++-
src/utils/getBlocksAsHTML.tsx | 11 +++++++++-
2 files changed, 30 insertions(+), 2 deletions(-)
diff --git a/components/Blocks/TextBlock/useHandlePaste.ts b/components/Blocks/TextBlock/useHandlePaste.ts
index 1e50bc5d..3e165a67 100644
--- a/components/Blocks/TextBlock/useHandlePaste.ts
+++ b/components/Blocks/TextBlock/useHandlePaste.ts
@@ -396,6 +396,24 @@ const createBlockFromHTML = (
]);
}
+ if (child.tagName === "DIV" && child.getAttribute("data-bluesky-post")) {
+ let postData = child.getAttribute("data-bluesky-post");
+ if (postData) {
+ rep.mutate.assertFact([
+ {
+ entity: entityID,
+ attribute: "block/type",
+ data: { type: "block-type-union", value: "bluesky-post" },
+ },
+ {
+ entity: entityID,
+ attribute: "block/bluesky-post",
+ data: { type: "bluesky-post", value: JSON.parse(postData) },
+ },
+ ]);
+ }
+ }
+
if (child.tagName === "DIV" && child.getAttribute("data-entityid")) {
let oldEntityID = child.getAttribute("data-entityid") as string;
let factsData = child.getAttribute("data-facts");
@@ -593,7 +611,8 @@ function flattenHTMLToTextBlocks(element: HTMLElement): HTMLElement[] {
"HR",
].includes(elementNode.tagName) ||
elementNode.getAttribute("data-entityid") ||
- elementNode.getAttribute("data-tex")
+ elementNode.getAttribute("data-tex") ||
+ elementNode.getAttribute("data-bluesky-post")
) {
htmlBlocks.push(elementNode);
} else {
diff --git a/src/utils/getBlocksAsHTML.tsx b/src/utils/getBlocksAsHTML.tsx
index 98bf380a..d9ac5b73 100644
--- a/src/utils/getBlocksAsHTML.tsx
+++ b/src/utils/getBlocksAsHTML.tsx
@@ -79,7 +79,16 @@ const BlockTypeToHTML: {
mailbox: async () => null,
poll: async () => null,
embed: async () => null,
- "bluesky-post": async () => null,
+ "bluesky-post": async (b, tx) => {
+ let [post] = await scanIndex(tx).eav(b.value, "block/bluesky-post");
+ if (!post) return null;
+ return (
+
+ );
+ },
math: async (b, tx, a) => {
let [math] = await scanIndex(tx).eav(b.value, "block/math");
const html = Katex.renderToString(math?.data.value || "", {
--
2.51.2
From f94d0da04728872b981230b270a447ba7d1b1fd3 Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Wed, 11 Feb 2026 10:51:04 +0400
Subject: [PATCH 27/51] add indexed col to documents and update inngest
---
app/api/inngest/client.ts | 4 +--
...sky_likes.ts => sync_document_metadata.ts} | 30 +++++++++++------
app/api/inngest/route.tsx | 4 +--
appview/index.ts | 32 ++++++++-----------
supabase/database.types.ts | 8 +++++
...0_add_indexed_column_and_ranking_index.sql | 6 ++++
6 files changed, 52 insertions(+), 32 deletions(-)
rename app/api/inngest/functions/{sync_bsky_likes.ts => sync_document_metadata.ts} (62%)
create mode 100644 supabase/migrations/20260210100000_add_indexed_column_and_ranking_index.sql
diff --git a/app/api/inngest/client.ts b/app/api/inngest/client.ts
index f648b39f..ffce505c 100644
--- a/app/api/inngest/client.ts
+++ b/app/api/inngest/client.ts
@@ -51,10 +51,10 @@ export type Events = {
documentUris?: string[];
};
};
- "appview/sync-bsky-likes": {
+ "appview/sync-document-metadata": {
data: {
document_uri: string;
- bsky_post_uri: string;
+ bsky_post_uri?: string;
};
};
"user/write-records-to-pds": {
diff --git a/app/api/inngest/functions/sync_bsky_likes.ts b/app/api/inngest/functions/sync_document_metadata.ts
similarity index 62%
rename from app/api/inngest/functions/sync_bsky_likes.ts
rename to app/api/inngest/functions/sync_document_metadata.ts
index 69dfe3c6..d2836a2a 100644
--- a/app/api/inngest/functions/sync_bsky_likes.ts
+++ b/app/api/inngest/functions/sync_document_metadata.ts
@@ -5,26 +5,36 @@ import { idResolver } from "app/(home-pages)/reader/idResolver";
const TOTAL_ITERATIONS = 144; // 36 hours at 15-minute intervals
-export const sync_bsky_likes = inngest.createFunction(
+export const sync_document_metadata = inngest.createFunction(
{
- id: "sync_bsky_likes",
- idempotency: "event.data.bsky_post_uri",
+ id: "sync_document_metadata",
+ idempotency: "event.data.document_uri",
},
- { event: "appview/sync-bsky-likes" },
+ { event: "appview/sync-document-metadata" },
async ({ event, step }) => {
const { document_uri, bsky_post_uri } = event.data;
- const isBridgy = await step.run("check-bridgy", async () => {
- const did = new AtUri(bsky_post_uri).host;
+ const did = new AtUri(document_uri).host;
+
+ const handleResult = await step.run("resolve-handle", async () => {
const doc = await idResolver.did.resolve(did);
const handle = doc?.alsoKnownAs
?.find((a) => a.startsWith("at://"))
?.replace("at://", "");
- return handle?.includes("brid.gy") ?? false;
+ return { handle: handle ?? null, isBridgy: handle?.includes("brid.gy") ?? false };
});
- if (isBridgy) {
- return { skipped: true, reason: "brid.gy post" };
+ if (handleResult.isBridgy) {
+ await step.run("set-unindexed", async () => {
+ await supabaseServerClient
+ .from("documents")
+ .update({ indexed: false })
+ .eq("uri", document_uri);
+ });
+ }
+
+ if (!bsky_post_uri || handleResult.isBridgy) {
+ return { handle: handleResult.handle };
}
const agent = new AtpAgent({ service: "https://public.api.bsky.app" });
@@ -50,6 +60,6 @@ export const sync_bsky_likes = inngest.createFunction(
likeCount = await step.run(`sync-${i}`, fetchAndUpdate);
}
- return { likeCount };
+ return { likeCount, handle: handleResult.handle };
},
);
diff --git a/app/api/inngest/route.tsx b/app/api/inngest/route.tsx
index ea0f2f9e..a74f1d98 100644
--- a/app/api/inngest/route.tsx
+++ b/app/api/inngest/route.tsx
@@ -13,7 +13,7 @@ import {
check_oauth_session,
} from "./functions/cleanup_expired_oauth_sessions";
import { write_records_to_pds } from "./functions/write_records_to_pds";
-import { sync_bsky_likes } from "./functions/sync_bsky_likes";
+import { sync_document_metadata } from "./functions/sync_document_metadata";
export const { GET, POST, PUT } = serve({
client: inngest,
@@ -29,6 +29,6 @@ export const { GET, POST, PUT } = serve({
cleanup_expired_oauth_sessions,
check_oauth_session,
write_records_to_pds,
- sync_bsky_likes,
+ sync_document_metadata,
],
});
diff --git a/appview/index.ts b/appview/index.ts
index 7319f549..31ead5fb 100644
--- a/appview/index.ts
+++ b/appview/index.ts
@@ -109,15 +109,13 @@ async function handleEvent(evt: Event) {
data: record.value as Json,
});
if (docResult.error) console.log(docResult.error);
- if (record.value.postRef?.uri) {
- await inngest.send({
- name: "appview/sync-bsky-likes",
- data: {
- document_uri: evt.uri.toString(),
- bsky_post_uri: record.value.postRef.uri,
- },
- });
- }
+ await inngest.send({
+ name: "appview/sync-document-metadata",
+ data: {
+ document_uri: evt.uri.toString(),
+ bsky_post_uri: record.value.postRef?.uri,
+ },
+ });
if (record.value.publication) {
let publicationURI = new AtUri(record.value.publication);
@@ -278,15 +276,13 @@ async function handleEvent(evt: Event) {
data: record.value as Json,
});
if (docResult.error) console.log(docResult.error);
- if (record.value.bskyPostRef?.uri) {
- await inngest.send({
- name: "appview/sync-bsky-likes",
- data: {
- document_uri: evt.uri.toString(),
- bsky_post_uri: record.value.bskyPostRef.uri,
- },
- });
- }
+ await inngest.send({
+ name: "appview/sync-document-metadata",
+ data: {
+ document_uri: evt.uri.toString(),
+ bsky_post_uri: record.value.bskyPostRef?.uri,
+ },
+ });
// site.standard.document uses "site" field to reference the publication
// For documents in publications, site is an AT-URI (at://did:plc:xxx/site.standard.publication/rkey)
diff --git a/supabase/database.types.ts b/supabase/database.types.ts
index 1eb727c2..c4f53bde 100644
--- a/supabase/database.types.ts
+++ b/supabase/database.types.ts
@@ -337,20 +337,28 @@ export type Database = {
Row: {
bsky_like_count: number
data: Json
+ indexed: boolean
indexed_at: string
+ recommend_count: number
sort_date: string
uri: string
}
Insert: {
bsky_like_count?: number
data: Json
+ indexed?: boolean
indexed_at?: string
+ recommend_count?: number
+ sort_date?: string
uri: string
}
Update: {
bsky_like_count?: number
data?: Json
+ indexed?: boolean
indexed_at?: string
+ recommend_count?: number
+ sort_date?: string
uri?: string
}
Relationships: []
diff --git a/supabase/migrations/20260210100000_add_indexed_column_and_ranking_index.sql b/supabase/migrations/20260210100000_add_indexed_column_and_ranking_index.sql
new file mode 100644
index 00000000..562f3b18
--- /dev/null
+++ b/supabase/migrations/20260210100000_add_indexed_column_and_ranking_index.sql
@@ -0,0 +1,6 @@
+ALTER TABLE documents ADD COLUMN indexed boolean NOT NULL DEFAULT true;
+
+CREATE INDEX idx_documents_ranking
+ON documents (sort_date DESC)
+INCLUDE (uri, bsky_like_count, recommend_count)
+WHERE indexed = true;
--
2.51.2
From 1a7a36b5ac5ea04aca0c4a58f47a3fdbd7e23ff9 Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Wed, 11 Feb 2026 11:06:46 +0400
Subject: [PATCH 28/51] fix type error
---
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx | 2 ++
1 file changed, 2 insertions(+)
diff --git a/app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx b/app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
index a3e25db9..1de03e91 100644
--- a/app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
+++ b/app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
@@ -114,6 +114,8 @@ function PublishedPostItem(props: {
sort_date: doc.sort_date,
data: doc.data,
bsky_like_count: doc.bsky_like_count ?? 0,
+ indexed: true,
+ recommend_count: doc.recommendsCount ?? 0,
},
},
],
--
2.51.2
From 1af1ce6f916bd0192b814f11864f1216ed0ac649 Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Wed, 11 Feb 2026 13:21:18 +0400
Subject: [PATCH 29/51] filter out posts from feeds
---
feeds/index.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/feeds/index.ts b/feeds/index.ts
index 279f5d7c..a30e229f 100644
--- a/feeds/index.ts
+++ b/feeds/index.ts
@@ -115,6 +115,7 @@ app.get("/xrpc/app.bsky.feed.getFeedSkeleton", async (c) => {
);
}
query = query
+ .eq("indexed", true)
.or("data->postRef.not.is.null,data->bskyPostRef.not.is.null")
.order("sort_date", { ascending: false })
.order("uri", { ascending: false })
--
2.51.2
From 44da33e46d65a5604a97cb35fc95c436fbd18cf4 Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Wed, 11 Feb 2026 14:13:31 +0400
Subject: [PATCH 30/51] explicitly set if documents are indexed after checking
pds host
---
.../functions/sync_document_metadata.ts | 19 ++++++++++---------
1 file changed, 10 insertions(+), 9 deletions(-)
diff --git a/app/api/inngest/functions/sync_document_metadata.ts b/app/api/inngest/functions/sync_document_metadata.ts
index d2836a2a..35acb38d 100644
--- a/app/api/inngest/functions/sync_document_metadata.ts
+++ b/app/api/inngest/functions/sync_document_metadata.ts
@@ -21,17 +21,18 @@ export const sync_document_metadata = inngest.createFunction(
const handle = doc?.alsoKnownAs
?.find((a) => a.startsWith("at://"))
?.replace("at://", "");
- return { handle: handle ?? null, isBridgy: handle?.includes("brid.gy") ?? false };
+ const isBridgy = !!doc?.service?.find(
+ (s) => s.serviceEndpoint === "https://atproto.brid.gy",
+ );
+ return { handle: handle ?? null, isBridgy };
});
- if (handleResult.isBridgy) {
- await step.run("set-unindexed", async () => {
- await supabaseServerClient
- .from("documents")
- .update({ indexed: false })
- .eq("uri", document_uri);
- });
- }
+ await step.run("set-indexed", async () => {
+ await supabaseServerClient
+ .from("documents")
+ .update({ indexed: !handleResult.isBridgy })
+ .eq("uri", document_uri);
+ });
if (!bsky_post_uri || handleResult.isBridgy) {
return { handle: handleResult.handle };
--
2.51.2
From fc660a92f04333949277b5e541815c4c75b312cc Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Wed, 11 Feb 2026 14:23:42 +0400
Subject: [PATCH 31/51] indexed false by default
---
appview/index.ts | 2 ++
1 file changed, 2 insertions(+)
diff --git a/appview/index.ts b/appview/index.ts
index 31ead5fb..6c4c1548 100644
--- a/appview/index.ts
+++ b/appview/index.ts
@@ -107,6 +107,7 @@ async function handleEvent(evt: Event) {
let docResult = await supabase.from("documents").upsert({
uri: evt.uri.toString(),
data: record.value as Json,
+ indexed: false,
});
if (docResult.error) console.log(docResult.error);
await inngest.send({
@@ -274,6 +275,7 @@ async function handleEvent(evt: Event) {
let docResult = await supabase.from("documents").upsert({
uri: evt.uri.toString(),
data: record.value as Json,
+ indexed: false,
});
if (docResult.error) console.log(docResult.error);
await inngest.send({
--
2.51.2
From ffb44ee2c5658377096de934ac383cc0a5beb553 Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Wed, 11 Feb 2026 16:17:12 +0400
Subject: [PATCH 32/51] return if no handle in sync document metadata
---
app/api/inngest/functions/sync_document_metadata.ts | 2 ++
1 file changed, 2 insertions(+)
diff --git a/app/api/inngest/functions/sync_document_metadata.ts b/app/api/inngest/functions/sync_document_metadata.ts
index 35acb38d..cab26e98 100644
--- a/app/api/inngest/functions/sync_document_metadata.ts
+++ b/app/api/inngest/functions/sync_document_metadata.ts
@@ -21,11 +21,13 @@ export const sync_document_metadata = inngest.createFunction(
const handle = doc?.alsoKnownAs
?.find((a) => a.startsWith("at://"))
?.replace("at://", "");
+ if (!doc) return null;
const isBridgy = !!doc?.service?.find(
(s) => s.serviceEndpoint === "https://atproto.brid.gy",
);
return { handle: handle ?? null, isBridgy };
});
+ if (!handleResult) return { error: "No Handle" };
await step.run("set-indexed", async () => {
await supabaseServerClient
--
2.51.2
From 9c5c5c6b9ff8bc85f808e031cdaf2ed437dde7b5 Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Wed, 11 Feb 2026 16:17:42 +0400
Subject: [PATCH 33/51] add doc to inngest return
---
app/api/inngest/functions/sync_document_metadata.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/api/inngest/functions/sync_document_metadata.ts b/app/api/inngest/functions/sync_document_metadata.ts
index cab26e98..79b95de7 100644
--- a/app/api/inngest/functions/sync_document_metadata.ts
+++ b/app/api/inngest/functions/sync_document_metadata.ts
@@ -25,7 +25,7 @@ export const sync_document_metadata = inngest.createFunction(
const isBridgy = !!doc?.service?.find(
(s) => s.serviceEndpoint === "https://atproto.brid.gy",
);
- return { handle: handle ?? null, isBridgy };
+ return { handle: handle ?? null, isBridgy, doc };
});
if (!handleResult) return { error: "No Handle" };
--
2.51.2
From 5ba035764042b0475f7785df35a3e040e18f7519 Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Wed, 11 Feb 2026 16:23:40 +0400
Subject: [PATCH 34/51] handle trailing brid.gy /
---
app/api/inngest/functions/sync_document_metadata.ts | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/app/api/inngest/functions/sync_document_metadata.ts b/app/api/inngest/functions/sync_document_metadata.ts
index 79b95de7..8dc0c775 100644
--- a/app/api/inngest/functions/sync_document_metadata.ts
+++ b/app/api/inngest/functions/sync_document_metadata.ts
@@ -23,7 +23,9 @@ export const sync_document_metadata = inngest.createFunction(
?.replace("at://", "");
if (!doc) return null;
const isBridgy = !!doc?.service?.find(
- (s) => s.serviceEndpoint === "https://atproto.brid.gy",
+ (s) =>
+ typeof s.serviceEndpoint === "string" &&
+ s.serviceEndpoint.includes("atproto.brid.gy"),
);
return { handle: handle ?? null, isBridgy, doc };
});
--
2.51.2
From b48c51c8497284b859304447d1fc761a9745685a Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Wed, 11 Feb 2026 21:28:08 +0400
Subject: [PATCH 35/51] sort rss feeds properly
---
app/lish/[did]/[publication]/generateFeed.ts | 82 +++++++++++---------
1 file changed, 46 insertions(+), 36 deletions(-)
diff --git a/app/lish/[did]/[publication]/generateFeed.ts b/app/lish/[did]/[publication]/generateFeed.ts
index 383f9461..e5b6de11 100644
--- a/app/lish/[did]/[publication]/generateFeed.ts
+++ b/app/lish/[did]/[publication]/generateFeed.ts
@@ -54,47 +54,57 @@ export async function generateFeed(
});
await Promise.all(
- publication.documents_in_publications.map(async (doc) => {
- if (!doc.documents) return;
- const record = normalizeDocumentRecord(
- doc.documents?.data,
- doc.documents?.uri,
- );
- const uri = new AtUri(doc.documents?.uri);
- const rkey = uri.rkey;
- if (!record) return;
+ publication.documents_in_publications
+ .sort((a, b) => {
+ const dateA = a.documents?.sort_date
+ ? new Date(a.documents.sort_date).getTime()
+ : 0;
+ const dateB = b.documents?.sort_date
+ ? new Date(b.documents.sort_date).getTime()
+ : 0;
+ return dateB - dateA; // Sort in descending order (newest first)
+ })
+ .map(async (doc) => {
+ if (!doc.documents) return;
+ const record = normalizeDocumentRecord(
+ doc.documents?.data,
+ doc.documents?.uri,
+ );
+ const uri = new AtUri(doc.documents?.uri);
+ const rkey = uri.rkey;
+ if (!record) return;
- let blocks: PubLeafletPagesLinearDocument.Block[] = [];
- if (hasLeafletContent(record) && record.content.pages[0]) {
- const firstPage = record.content.pages[0];
- if (PubLeafletPagesLinearDocument.isMain(firstPage)) {
- blocks = firstPage.blocks || [];
+ let blocks: PubLeafletPagesLinearDocument.Block[] = [];
+ if (hasLeafletContent(record) && record.content.pages[0]) {
+ const firstPage = record.content.pages[0];
+ if (PubLeafletPagesLinearDocument.isMain(firstPage)) {
+ blocks = firstPage.blocks || [];
+ }
}
- }
- const stream = await renderToReadableStream(
- createElement(StaticPostContent, { blocks, did: uri.host }),
- );
- const reader = stream.getReader();
- const chunks = [];
+ const stream = await renderToReadableStream(
+ createElement(StaticPostContent, { blocks, did: uri.host }),
+ );
+ const reader = stream.getReader();
+ const chunks = [];
- let done, value;
- while (!done) {
- ({ done, value } = await reader.read());
- if (value) {
- chunks.push(new TextDecoder().decode(value));
+ let done, value;
+ while (!done) {
+ ({ done, value } = await reader.read());
+ if (value) {
+ chunks.push(new TextDecoder().decode(value));
+ }
}
- }
- const docUrl = getDocumentURL(record, doc.documents.uri, pubRecord);
- feed.addItem({
- title: record.title,
- description: record.description,
- date: record.publishedAt ? new Date(record.publishedAt) : new Date(),
- id: docUrl,
- link: docUrl,
- content: chunks.join(""),
- });
- }),
+ const docUrl = getDocumentURL(record, doc.documents.uri, pubRecord);
+ feed.addItem({
+ title: record.title,
+ description: record.description,
+ date: record.publishedAt ? new Date(record.publishedAt) : new Date(),
+ id: docUrl,
+ link: docUrl,
+ content: chunks.join(""),
+ });
+ }),
);
return feed;
--
2.51.2
From 5aaf7df819a607c99f64d18a973f0beaac855a52 Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Wed, 11 Feb 2026 21:34:10 +0400
Subject: [PATCH 36/51] fix feed ordering
---
app/lish/[did]/[publication]/generateFeed.ts | 97 ++++++++++----------
1 file changed, 47 insertions(+), 50 deletions(-)
diff --git a/app/lish/[did]/[publication]/generateFeed.ts b/app/lish/[did]/[publication]/generateFeed.ts
index e5b6de11..94227ea0 100644
--- a/app/lish/[did]/[publication]/generateFeed.ts
+++ b/app/lish/[did]/[publication]/generateFeed.ts
@@ -53,59 +53,56 @@ export async function generateFeed(
},
});
- await Promise.all(
- publication.documents_in_publications
- .sort((a, b) => {
- const dateA = a.documents?.sort_date
- ? new Date(a.documents.sort_date).getTime()
- : 0;
- const dateB = b.documents?.sort_date
- ? new Date(b.documents.sort_date).getTime()
- : 0;
- return dateB - dateA; // Sort in descending order (newest first)
- })
- .map(async (doc) => {
- if (!doc.documents) return;
- const record = normalizeDocumentRecord(
- doc.documents?.data,
- doc.documents?.uri,
- );
- const uri = new AtUri(doc.documents?.uri);
- const rkey = uri.rkey;
- if (!record) return;
+ let docs = publication.documents_in_publications.sort((a, b) => {
+ const dateA = a.documents?.sort_date
+ ? new Date(a.documents.sort_date).getTime()
+ : 0;
+ const dateB = b.documents?.sort_date
+ ? new Date(b.documents.sort_date).getTime()
+ : 0;
+ return dateB - dateA; // Sort in descending order (newest first)
+ });
+ for (const doc of docs) {
+ if (!doc.documents) continue;
+ const record = normalizeDocumentRecord(
+ doc.documents?.data,
+ doc.documents?.uri,
+ );
+ const uri = new AtUri(doc.documents?.uri);
+ const rkey = uri.rkey;
+ if (!record) continue;
- let blocks: PubLeafletPagesLinearDocument.Block[] = [];
- if (hasLeafletContent(record) && record.content.pages[0]) {
- const firstPage = record.content.pages[0];
- if (PubLeafletPagesLinearDocument.isMain(firstPage)) {
- blocks = firstPage.blocks || [];
- }
- }
- const stream = await renderToReadableStream(
- createElement(StaticPostContent, { blocks, did: uri.host }),
- );
- const reader = stream.getReader();
- const chunks = [];
+ let blocks: PubLeafletPagesLinearDocument.Block[] = [];
+ if (hasLeafletContent(record) && record.content.pages[0]) {
+ const firstPage = record.content.pages[0];
+ if (PubLeafletPagesLinearDocument.isMain(firstPage)) {
+ blocks = firstPage.blocks || [];
+ }
+ }
+ const stream = await renderToReadableStream(
+ createElement(StaticPostContent, { blocks, did: uri.host }),
+ );
+ const reader = stream.getReader();
+ const chunks = [];
- let done, value;
- while (!done) {
- ({ done, value } = await reader.read());
- if (value) {
- chunks.push(new TextDecoder().decode(value));
- }
- }
+ let done, value;
+ while (!done) {
+ ({ done, value } = await reader.read());
+ if (value) {
+ chunks.push(new TextDecoder().decode(value));
+ }
+ }
- const docUrl = getDocumentURL(record, doc.documents.uri, pubRecord);
- feed.addItem({
- title: record.title,
- description: record.description,
- date: record.publishedAt ? new Date(record.publishedAt) : new Date(),
- id: docUrl,
- link: docUrl,
- content: chunks.join(""),
- });
- }),
- );
+ const docUrl = getDocumentURL(record, doc.documents.uri, pubRecord);
+ feed.addItem({
+ title: record.title,
+ description: record.description,
+ date: record.publishedAt ? new Date(record.publishedAt) : new Date(),
+ id: docUrl,
+ link: docUrl,
+ content: chunks.join(""),
+ });
+ }
return feed;
}
--
2.51.2
From 5a7a4d3269a06b0b9b8607d161f3c7b0c1d091c7 Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Wed, 11 Feb 2026 21:46:37 +0400
Subject: [PATCH 37/51] check if doc path has leading / and add if needed
---
app/lish/createPub/getPublicationURL.ts | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
diff --git a/app/lish/createPub/getPublicationURL.ts b/app/lish/createPub/getPublicationURL.ts
index 3f864599..cfb39d3f 100644
--- a/app/lish/createPub/getPublicationURL.ts
+++ b/app/lish/createPub/getPublicationURL.ts
@@ -58,18 +58,22 @@ export function getDocumentURL(
docUri: string,
publication?: PublicationInput | NormalizedPublication | null,
): string {
- const path = doc.path || "/" + new AtUri(docUri).rkey;
+ let path = doc.path || "/" + new AtUri(docUri).rkey;
+ if (path[0] !== "/") path = "/" + path;
const aturi = new AtUri(docUri);
const isNormalized =
!!publication &&
- (publication as NormalizedPublication).$type === "site.standard.publication";
+ (publication as NormalizedPublication).$type ===
+ "site.standard.publication";
const normPub = isNormalized
? (publication as NormalizedPublication)
: publication
? normalizePublicationRecord((publication as PublicationInput).record)
: null;
- const pubInput = isNormalized ? null : (publication as PublicationInput | null);
+ const pubInput = isNormalized
+ ? null
+ : (publication as PublicationInput | null);
// Non-leaflet documents always use the full publication site URL
if (doc.content && !hasLeafletContent(doc) && normPub?.url) {
--
2.51.2
From c460c609d3e2c17146302eb3c21ecae0fe29d9ae Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Wed, 11 Feb 2026 21:56:40 +0400
Subject: [PATCH 38/51] log when setting indexed
---
app/api/inngest/functions/sync_document_metadata.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/api/inngest/functions/sync_document_metadata.ts b/app/api/inngest/functions/sync_document_metadata.ts
index 8dc0c775..1c091192 100644
--- a/app/api/inngest/functions/sync_document_metadata.ts
+++ b/app/api/inngest/functions/sync_document_metadata.ts
@@ -32,7 +32,7 @@ export const sync_document_metadata = inngest.createFunction(
if (!handleResult) return { error: "No Handle" };
await step.run("set-indexed", async () => {
- await supabaseServerClient
+ return await supabaseServerClient
.from("documents")
.update({ indexed: !handleResult.isBridgy })
.eq("uri", document_uri);
--
2.51.2
From 67ea66e7a3ba57fef2364710a5ae0042ad071ebe Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Wed, 11 Feb 2026 22:02:54 +0400
Subject: [PATCH 39/51] log data from update indexed step
---
app/api/inngest/functions/sync_document_metadata.ts | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/app/api/inngest/functions/sync_document_metadata.ts b/app/api/inngest/functions/sync_document_metadata.ts
index 1c091192..ee4e6b86 100644
--- a/app/api/inngest/functions/sync_document_metadata.ts
+++ b/app/api/inngest/functions/sync_document_metadata.ts
@@ -35,7 +35,8 @@ export const sync_document_metadata = inngest.createFunction(
return await supabaseServerClient
.from("documents")
.update({ indexed: !handleResult.isBridgy })
- .eq("uri", document_uri);
+ .eq("uri", document_uri)
+ .select();
});
if (!bsky_post_uri || handleResult.isBridgy) {
--
2.51.2
From 471ed160881f9cf827b374fb511f6fd3c71c21e2 Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Wed, 11 Feb 2026 22:48:22 +0400
Subject: [PATCH 40/51] default to indexed false and manually set on leaflet
publish
---
actions/publishToPublication.ts | 1 +
appview/index.ts | 2 --
supabase/migrations/20260211000000_indexed_default_false.sql | 1 +
3 files changed, 2 insertions(+), 2 deletions(-)
create mode 100644 supabase/migrations/20260211000000_indexed_default_false.sql
diff --git a/actions/publishToPublication.ts b/actions/publishToPublication.ts
index a1a93592..70aa3d9d 100644
--- a/actions/publishToPublication.ts
+++ b/actions/publishToPublication.ts
@@ -300,6 +300,7 @@ export async function publishToPublication({
await supabaseServerClient.from("documents").upsert({
uri: result.uri,
data: record as unknown as Json,
+ indexed: true,
});
if (publication_uri) {
diff --git a/appview/index.ts b/appview/index.ts
index 6c4c1548..31ead5fb 100644
--- a/appview/index.ts
+++ b/appview/index.ts
@@ -107,7 +107,6 @@ async function handleEvent(evt: Event) {
let docResult = await supabase.from("documents").upsert({
uri: evt.uri.toString(),
data: record.value as Json,
- indexed: false,
});
if (docResult.error) console.log(docResult.error);
await inngest.send({
@@ -275,7 +274,6 @@ async function handleEvent(evt: Event) {
let docResult = await supabase.from("documents").upsert({
uri: evt.uri.toString(),
data: record.value as Json,
- indexed: false,
});
if (docResult.error) console.log(docResult.error);
await inngest.send({
diff --git a/supabase/migrations/20260211000000_indexed_default_false.sql b/supabase/migrations/20260211000000_indexed_default_false.sql
new file mode 100644
index 00000000..09031c3a
--- /dev/null
+++ b/supabase/migrations/20260211000000_indexed_default_false.sql
@@ -0,0 +1 @@
+ALTER TABLE documents ALTER COLUMN indexed SET DEFAULT false;
--
2.51.2
From 5c1da06c43b099a4836bef01e460585552edc851 Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Fri, 13 Feb 2026 09:59:56 +0400
Subject: [PATCH 41/51] remove commit claude local settings
---
.claude/settings.local.json | 15 ---------------
1 file changed, 15 deletions(-)
delete mode 100644 .claude/settings.local.json
diff --git a/.claude/settings.local.json b/.claude/settings.local.json
deleted file mode 100644
index 9371392c..00000000
--- a/.claude/settings.local.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
- "permissions": {
- "allow": [
- "mcp__acp__Edit",
- "mcp__acp__Write",
- "mcp__acp__Bash",
- "mcp__primitive__say_hello",
- "mcp__primitive__pending_delegations",
- "mcp__primitive__claim_delegation",
- "mcp__primitive__tasks_update",
- "mcp__primitive__contexts_update",
- "mcp__primitive__contexts_list"
- ]
- }
-}
--
2.51.2
From 19c7b0af3767686a8d25be6956d1037dfec698d2 Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Fri, 13 Feb 2026 10:09:37 +0400
Subject: [PATCH 42/51] prevent description from being overridden when blank
---
app/lish/createPub/updatePublication.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/lish/createPub/updatePublication.ts b/app/lish/createPub/updatePublication.ts
index 350b3f88..77e1a191 100644
--- a/app/lish/createPub/updatePublication.ts
+++ b/app/lish/createPub/updatePublication.ts
@@ -275,7 +275,7 @@ export async function updatePublication({
return buildRecord(normalizedPub, existingBasePath, publicationType, {
name,
- description,
+ ...(description !== undefined && { description }),
icon: iconBlob,
preferences,
});
--
2.51.2
From 10133c257cfbf8ad47aa3baf99b7c40f04f231aa Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Fri, 13 Feb 2026 15:47:22 -0500
Subject: [PATCH 43/51] Add proper reader experience and rework nav (#269)
* moved around some stuff for our navigation
* little tweak
* messing around with navigation on mobile
* Added a page title to the home layout
* added pagetitle to looseleaf and pub, added shortcut if you only have
one pub or looseleaf
* some icons
* quick little adjustments
* moved action buttons on mobile to page title area, publist commented out
for now
* added the publicaions nav to the mobile footer
* add states for logged out
* stray console log eradication
* some shifting around of things
* update the homeempty layout
* move publicationbuttons into separate compoennt, added state to handle
jsut one pub or looseleaf
* rename small prop in PubIcon to tiny
* adding tabs to Reader
* oops forgot the new files
* updated postListing design
* add a drawer for comments and mentions in reader
* new files
* remove features not in scope
* adjust width of ActionButon in desktop
* added indication that post is on non-leaflet page is applicable
* repostion interaction drawer close button
* small fixes per feedback
* add inngest function to track bsky post likes
* don't sync bsky likes of brid.gy posts
* add bsky auth view all permissions
* add aud to bsky view permissions
* add recommend notification
* fix post links to standard site blogs
* don't getpreferences on subscribe
* add recommend column to documents
* check permissions on retractions
* don't show block options if no write permissions
* handle pasting bluesky posts
* fix: copy post link button
* add indexed col to documents and update inngest
* fix type error
* add hot feed
* adding profile button to nav, removing things where necessary
* Remove accidentally committed files from bbc6c135
Remove scratch/, scripts/, database.sql, appview/test.ts,
CoordDebugger.tsx, search_loose_leafs.ts, supabase migration,
and tmp_standard-site-migration.md that were unintentionally
included in the previous commit.
Co-Authored-By: Claude Opus 4.6
* added profile settings to nav
* WIP suspense reader pages
* add loading.tsx for route level suspense boundary
* render what's hot for logged out users and don't render subs
* highlight what's hot tab if default
* persist nav state on reader/home
* remove .claude/settings.local.json
* wire up interactions
* remove publications and fix external url logic
* don't keep previous data in interactions sidebar in feed
* fix empty subs state
* fix share links in postlisting
* remove help text
* fix cover image positioning
---------
Co-authored-by: celine
Co-authored-by: Henrique Dias
Co-authored-by: Claude Opus 4.6
---
app/(home-pages)/discover/SortButtons.tsx | 97 ------
.../discover/SortedPublicationList.tsx | 195 ------------
app/(home-pages)/discover/getPublications.ts | 133 --------
app/(home-pages)/discover/page.tsx | 53 ----
.../home/Actions/AccountSettings.tsx | 122 +-------
app/(home-pages)/home/Actions/Actions.tsx | 8 +-
.../home/Actions/CreateNewButton.tsx | 3 +-
app/(home-pages)/home/HomeEmpty/HomeEmpty.tsx | 134 ++++----
.../home/HomeEmpty/WelcomeToLeafletIllo.tsx | 4 +-
app/(home-pages)/home/HomeLayout.tsx | 13 +-
app/(home-pages)/layout.tsx | 3 +
.../looseleafs/LooseleafsLayout.tsx | 1 +
.../p/[didOrHandle]/PostsContent.tsx | 2 +-
.../p/[didOrHandle]/ProfileHeader.tsx | 6 +-
.../p/[didOrHandle]/ProfileLayout.tsx | 4 +-
.../p/[didOrHandle]/ProfileTabs.tsx | 2 +-
.../[didOrHandle]}/PubListing.tsx | 30 +-
.../comments/CommentsContent.tsx | 2 +-
app/(home-pages)/p/[didOrHandle]/layout.tsx | 5 +-
.../subscriptions/SubscriptionsContent.tsx | 4 +-
app/(home-pages)/page.tsx | 31 ++
app/(home-pages)/reader/FeedSkeleton.tsx | 13 +
app/(home-pages)/reader/GlobalContent.tsx | 49 +++
.../{ReaderContent.tsx => InboxContent.tsx} | 61 ++--
.../reader/InteractionDrawers.tsx | 108 +++++++
app/(home-pages)/reader/NewContent.tsx | 93 ++++++
app/(home-pages)/reader/PreviewDrawer.tsx | 0
.../reader/ReaderMentionsContent.tsx | 72 +++++
.../reader/SubscriptionsContent.tsx | 105 -------
app/(home-pages)/reader/enrichPost.ts | 88 ++++++
app/(home-pages)/reader/getHotFeed.ts | 100 ++++++
app/(home-pages)/reader/getNewFeed.ts | 54 ++++
app/(home-pages)/reader/getReaderFeed.ts | 51 +--
app/(home-pages)/reader/getSubscriptions.ts | 10 +-
app/(home-pages)/reader/hot/page.tsx | 7 +
app/(home-pages)/reader/layout.tsx | 79 +++++
app/(home-pages)/reader/loading.tsx | 4 +
app/(home-pages)/reader/new/page.tsx | 7 +
app/(home-pages)/reader/page.tsx | 45 +--
app/[leaflet_id]/Footer.tsx | 22 +-
.../publish/BskyPostEditorProsemirror.tsx | 2 -
.../[command]/get_document_interactions.ts | 90 ++++++
app/api/rpc/[command]/get_hot_feed.ts | 17 +
app/api/rpc/[command]/route.ts | 4 +
app/api/update-nav-state/route.ts | 16 +
app/lish/Subscribe.tsx | 56 +++-
.../[rkey]/Interactions/Comments/index.tsx | 32 +-
.../[rkey]/Interactions/InteractionDrawer.tsx | 52 +++-
.../[rkey]/Interactions/Quotes.tsx | 10 +-
.../[did]/[publication]/[rkey]/ThreadPage.tsx | 1 -
.../[publication]/[rkey]/getPostPageData.ts | 4 -
.../[did]/[publication]/dashboard/Actions.tsx | 1 +
.../[publication]/dashboard/DraftList.tsx | 10 +-
.../dashboard/NewDraftButton.tsx | 26 +-
.../dashboard/PublicationDashboard.tsx | 1 +
.../dashboard/PublicationSWRProvider.tsx | 6 +-
.../settings/PublicationSettings.tsx | 1 +
app/route.ts | 11 -
components/ActionBar/ActionButton.tsx | 20 +-
components/ActionBar/DesktopNavigation.tsx | 66 ++++
components/ActionBar/Footer.tsx | 2 +-
components/ActionBar/MobileNavigation.tsx | 63 ++++
components/ActionBar/Navigation.tsx | 179 -----------
components/ActionBar/NavigationButtons.tsx | 101 ++++++
components/ActionBar/ProfileButton.tsx | 61 ++++
.../ActionBar/PublicationNavigation.tsx | 148 +++++++++
components/ActionBar/Publications.tsx | 29 +-
components/Blocks/BaseTextareaBlock.tsx | 1 -
components/Icons/HomeTiny.tsx | 21 ++
components/Icons/LooseleafTiny.tsx | 19 ++
components/Icons/MoreOptionsVerticalTiny.tsx | 1 +
components/Icons/NotificationSmall.tsx | 37 +--
components/Icons/RSSTiny.tsx | 18 ++
components/Icons/ReaderSmall.tsx | 7 +-
components/Icons/ShareTiny.tsx | 19 ++
components/Icons/TagSmall.tsx | 19 ++
components/Icons/WriterSmall.tsx | 19 ++
components/InteractionsPreview.tsx | 2 +-
components/LoginButton.tsx | 6 +-
components/NavStateTracker.tsx | 27 ++
components/PageHeader.tsx | 2 +-
components/PageLayouts/DashboardLayout.tsx | 49 ++-
components/Pages/useHasBackgroundImage.ts | 5 +
components/Popover/index.tsx | 2 +-
components/PostListing.tsx | 294 +++++++++++++-----
components/Tab.tsx | 4 +
components/Tags.tsx | 1 -
components/ThemeManager/ThemeProvider.tsx | 41 ++-
src/replicache/mutations.ts | 1 -
src/useSelectedPostState.ts | 16 +
src/utils/useRecordFromDid.ts | 12 +
91 files changed, 2070 insertions(+), 1382 deletions(-)
delete mode 100644 app/(home-pages)/discover/SortButtons.tsx
delete mode 100644 app/(home-pages)/discover/SortedPublicationList.tsx
delete mode 100644 app/(home-pages)/discover/getPublications.ts
delete mode 100644 app/(home-pages)/discover/page.tsx
rename app/(home-pages)/{discover => p/[didOrHandle]}/PubListing.tsx (71%)
create mode 100644 app/(home-pages)/page.tsx
create mode 100644 app/(home-pages)/reader/FeedSkeleton.tsx
create mode 100644 app/(home-pages)/reader/GlobalContent.tsx
rename app/(home-pages)/reader/{ReaderContent.tsx => InboxContent.tsx} (61%)
create mode 100644 app/(home-pages)/reader/InteractionDrawers.tsx
create mode 100644 app/(home-pages)/reader/NewContent.tsx
create mode 100644 app/(home-pages)/reader/PreviewDrawer.tsx
create mode 100644 app/(home-pages)/reader/ReaderMentionsContent.tsx
delete mode 100644 app/(home-pages)/reader/SubscriptionsContent.tsx
create mode 100644 app/(home-pages)/reader/enrichPost.ts
create mode 100644 app/(home-pages)/reader/getHotFeed.ts
create mode 100644 app/(home-pages)/reader/getNewFeed.ts
create mode 100644 app/(home-pages)/reader/hot/page.tsx
create mode 100644 app/(home-pages)/reader/layout.tsx
create mode 100644 app/(home-pages)/reader/loading.tsx
create mode 100644 app/(home-pages)/reader/new/page.tsx
create mode 100644 app/api/rpc/[command]/get_document_interactions.ts
create mode 100644 app/api/rpc/[command]/get_hot_feed.ts
create mode 100644 app/api/update-nav-state/route.ts
delete mode 100644 app/route.ts
create mode 100644 components/ActionBar/DesktopNavigation.tsx
create mode 100644 components/ActionBar/MobileNavigation.tsx
delete mode 100644 components/ActionBar/Navigation.tsx
create mode 100644 components/ActionBar/NavigationButtons.tsx
create mode 100644 components/ActionBar/ProfileButton.tsx
create mode 100644 components/ActionBar/PublicationNavigation.tsx
create mode 100644 components/Icons/HomeTiny.tsx
create mode 100644 components/Icons/LooseleafTiny.tsx
create mode 100644 components/Icons/RSSTiny.tsx
create mode 100644 components/Icons/ShareTiny.tsx
create mode 100644 components/Icons/TagSmall.tsx
create mode 100644 components/Icons/WriterSmall.tsx
create mode 100644 components/NavStateTracker.tsx
create mode 100644 components/Pages/useHasBackgroundImage.ts
create mode 100644 src/useSelectedPostState.ts
create mode 100644 src/utils/useRecordFromDid.ts
diff --git a/app/(home-pages)/discover/SortButtons.tsx b/app/(home-pages)/discover/SortButtons.tsx
deleted file mode 100644
index 3b90228d..00000000
--- a/app/(home-pages)/discover/SortButtons.tsx
+++ /dev/null
@@ -1,97 +0,0 @@
-"use client";
-import Link from "next/link";
-import { useState } from "react";
-import { theme } from "tailwind.config";
-
-export default function SortButtons(props: { order: string }) {
- const [selected, setSelected] = useState<"recentlyUpdated" | "popular">(
- "recentlyUpdated",
- );
-
- return (
-
-
-
- Recently Updated
-
-
-
-
- Popular
-
-
- );
-}
-
-const SortButton = (props: {
- children: React.ReactNode;
- selected: boolean;
-}) => {
- return (
-
-
- {props.children}
-
- {props.selected && (
- <>
-
-
-
-
-
-
-
-
-
- >
- )}
-
- );
-};
-
-const GlitterBig = () => {
- return (
-
-
-
- );
-};
-
-const GlitterSmall = () => {
- return (
-
-
-
- );
-};
diff --git a/app/(home-pages)/discover/SortedPublicationList.tsx b/app/(home-pages)/discover/SortedPublicationList.tsx
deleted file mode 100644
index d72a3e14..00000000
--- a/app/(home-pages)/discover/SortedPublicationList.tsx
+++ /dev/null
@@ -1,195 +0,0 @@
-"use client";
-import Link from "next/link";
-import { useState, useEffect, useRef } from "react";
-import { theme } from "tailwind.config";
-import { PubListing } from "./PubListing";
-import useSWRInfinite from "swr/infinite";
-import { getPublications, type Cursor, type Publication } from "./getPublications";
-
-export function SortedPublicationList(props: {
- publications: Publication[];
- order: string;
- nextCursor: Cursor | null;
-}) {
- let [order, setOrder] = useState(props.order);
-
- const getKey = (
- pageIndex: number,
- previousPageData: { publications: Publication[]; nextCursor: Cursor | null } | null,
- ) => {
- // Reached the end
- if (previousPageData && !previousPageData.nextCursor) return null;
-
- // First page, we don't have previousPageData
- if (pageIndex === 0) return ["discover-publications", order, null] as const;
-
- // Add the cursor to the key
- return ["discover-publications", order, previousPageData?.nextCursor] as const;
- };
-
- const { data, error, size, setSize, isValidating } = useSWRInfinite(
- getKey,
- ([_, orderValue, cursor]) => {
- const orderParam = orderValue === "popular" ? "popular" : "recentlyUpdated";
- return getPublications(orderParam, cursor);
- },
- {
- fallbackData: order === props.order
- ? [{ publications: props.publications, nextCursor: props.nextCursor }]
- : undefined,
- revalidateFirstPage: false,
- },
- );
-
- const loadMoreRef = useRef(null);
-
- // Set up intersection observer to load more when trigger element is visible
- useEffect(() => {
- const observer = new IntersectionObserver(
- (entries) => {
- if (entries[0].isIntersecting && !isValidating) {
- const hasMore = data && data[data.length - 1]?.nextCursor;
- if (hasMore) {
- setSize(size + 1);
- }
- }
- },
- { threshold: 0.1 },
- );
-
- if (loadMoreRef.current) {
- observer.observe(loadMoreRef.current);
- }
-
- return () => observer.disconnect();
- }, [data, size, setSize, isValidating]);
-
- const allPublications = data ? data.flatMap((page) => page.publications) : [];
-
- return (
-
-
{
- const url = new URL(window.location.href);
- url.searchParams.set("order", o);
- window.history.pushState({}, "", url);
- setOrder(o);
- }}
- />
-
- {allPublications.map((pub) => (
-
- ))}
- {/* Trigger element for loading more publications */}
-
- {isValidating && (
-
- Loading more publications...
-
- )}
-
-
- );
-}
-
-export default function SortButtons(props: {
- order: string;
- setOrder: (order: string) => void;
-}) {
- const [selected, setSelected] = useState<"recentlyUpdated" | "popular">(
- "recentlyUpdated",
- );
-
- return (
-
- props.setOrder("recentlyUpdated")}
- >
- Recently Updated
-
-
- props.setOrder("popular")}
- >
- Popular
-
-
- );
-}
-
-const SortButton = (props: {
- children: React.ReactNode;
- onClick: () => void;
- selected: boolean;
-}) => {
- return (
-
-
- {props.children}
-
- {props.selected && (
- <>
-
-
-
-
-
-
-
-
-
- >
- )}
-
- );
-};
-
-const GlitterBig = () => {
- return (
-
-
-
- );
-};
-
-const GlitterSmall = () => {
- return (
-
-
-
- );
-};
diff --git a/app/(home-pages)/discover/getPublications.ts b/app/(home-pages)/discover/getPublications.ts
deleted file mode 100644
index aa633c1a..00000000
--- a/app/(home-pages)/discover/getPublications.ts
+++ /dev/null
@@ -1,133 +0,0 @@
-"use server";
-
-import { supabaseServerClient } from "supabase/serverClient";
-import {
- normalizePublicationRow,
- hasValidPublication,
-} from "src/utils/normalizeRecords";
-import { deduplicateByUri } from "src/utils/deduplicateRecords";
-
-export type Cursor = {
- sort_date?: string;
- count?: number;
- uri: string;
-};
-
-export type Publication = Awaited<
- ReturnType
->["publications"][number];
-
-export async function getPublications(
- order: "recentlyUpdated" | "popular" = "recentlyUpdated",
- cursor?: Cursor | null,
-): Promise<{ publications: any[]; nextCursor: Cursor | null }> {
- const limit = 25;
-
- // Fetch all publications with their most recent document
- let { data: publications, error } = await supabaseServerClient
- .from("publications")
- .select(
- "*, documents_in_publications(*, documents(*)), publication_subscriptions(count)",
- )
- .or(
- "record->preferences->showInDiscover.is.null,record->preferences->>showInDiscover.eq.true",
- )
- .order("documents(sort_date)", {
- referencedTable: "documents_in_publications",
- ascending: false,
- })
- .limit(1, { referencedTable: "documents_in_publications" });
-
- if (error) {
- console.error("Error fetching publications:", error);
- return { publications: [], nextCursor: null };
- }
-
- // Deduplicate records that may exist under both pub.leaflet and site.standard namespaces
- const dedupedPublications = deduplicateByUri(publications || []);
-
- // Filter out publications without documents
- const allPubs = dedupedPublications.filter(
- (pub) => pub.documents_in_publications.length > 0,
- );
-
- // Sort on the server
- allPubs.sort((a, b) => {
- if (order === "popular") {
- const aCount = a.publication_subscriptions[0]?.count || 0;
- const bCount = b.publication_subscriptions[0]?.count || 0;
- if (bCount !== aCount) {
- return bCount - aCount;
- }
- // Secondary sort by uri for stability
- return b.uri.localeCompare(a.uri);
- } else {
- // recentlyUpdated
- const aDate = new Date(
- a.documents_in_publications[0]?.documents?.sort_date || 0,
- ).getTime();
- const bDate = new Date(
- b.documents_in_publications[0]?.documents?.sort_date || 0,
- ).getTime();
- if (bDate !== aDate) {
- return bDate - aDate;
- }
- // Secondary sort by uri for stability
- return b.uri.localeCompare(a.uri);
- }
- });
-
- // Find cursor position and slice
- let startIndex = 0;
- if (cursor) {
- startIndex = allPubs.findIndex((pub) => {
- if (order === "popular") {
- const pubCount = pub.publication_subscriptions[0]?.count || 0;
- // Find first pub after cursor
- return (
- pubCount < (cursor.count || 0) ||
- (pubCount === cursor.count && pub.uri < cursor.uri)
- );
- } else {
- const pubDate = pub.documents_in_publications[0]?.documents?.sort_date || "";
- // Find first pub after cursor
- return (
- pubDate < (cursor.sort_date || "") ||
- (pubDate === cursor.sort_date && pub.uri < cursor.uri)
- );
- }
- });
- // If not found, we're at the end
- if (startIndex === -1) {
- return { publications: [], nextCursor: null };
- }
- }
-
- // Get the page
- const page = allPubs.slice(startIndex, startIndex + limit);
-
- // Normalize publication records
- const normalizedPage = page
- .map(normalizePublicationRow)
- .filter(hasValidPublication);
-
- // Create next cursor based on last item in normalizedPage
- const lastItem = normalizedPage[normalizedPage.length - 1];
- const nextCursor =
- normalizedPage.length > 0 && startIndex + limit < allPubs.length
- ? order === "recentlyUpdated"
- ? {
- sort_date: lastItem.documents_in_publications[0]?.documents?.sort_date,
- uri: lastItem.uri,
- }
- : {
- count: lastItem.publication_subscriptions[0]?.count || 0,
- uri: lastItem.uri,
- }
- : null;
-
- return {
- publications: normalizedPage,
- nextCursor,
- };
-}
diff --git a/app/(home-pages)/discover/page.tsx b/app/(home-pages)/discover/page.tsx
deleted file mode 100644
index facf7c12..00000000
--- a/app/(home-pages)/discover/page.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import Link from "next/link";
-import { SortedPublicationList } from "./SortedPublicationList";
-import { Metadata } from "next";
-import { DashboardLayout } from "components/PageLayouts/DashboardLayout";
-import { getPublications } from "./getPublications";
-
-export const metadata: Metadata = {
- title: "Leaflet Discover",
- description: "Explore publications on Leaflet ✨ Or make your own!",
-};
-
-export default async function Discover(props: {
- searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
-}) {
- let order = ((await props.searchParams).order as string) || "recentlyUpdated";
-
- return (
- ,
- },
- }}
- />
- );
-}
-
-const DiscoverContent = async (props: { order: string }) => {
- const orderValue = props.order === "popular" ? "popular" : "recentlyUpdated";
- let { publications, nextCursor } = await getPublications(orderValue);
-
- return (
-
-
-
Discover
-
- Explore publications on Leaflet ✨ Or{" "}
- make your own!
-
-
-
-
- );
-};
diff --git a/app/(home-pages)/home/Actions/AccountSettings.tsx b/app/(home-pages)/home/Actions/AccountSettings.tsx
index 67df4f37..c3cb0043 100644
--- a/app/(home-pages)/home/Actions/AccountSettings.tsx
+++ b/app/(home-pages)/home/Actions/AccountSettings.tsx
@@ -1,134 +1,24 @@
"use client";
import { ActionButton } from "components/ActionBar/ActionButton";
-import { mutate } from "swr";
-import { AccountSmall } from "components/Icons/AccountSmall";
-import { LogoutSmall } from "components/Icons/LogoutSmall";
import { Popover } from "components/Popover";
-import { ArrowRightTiny } from "components/Icons/ArrowRightTiny";
-import { SpeedyLink } from "components/SpeedyLink";
-import { GoBackSmall } from "components/Icons/GoBackSmall";
-import { useState } from "react";
import { ThemeSetterContent } from "components/ThemeManager/ThemeSetter";
import { useIsMobile } from "src/hooks/isMobile";
+import { PaintSmall } from "components/Icons/PaintSmall";
-export const AccountSettings = (props: { entityID: string }) => {
- let [state, setState] = useState<"menu" | "general" | "theme">("menu");
+export const AccountTheme = (props: { entityID: string }) => {
let isMobile = useIsMobile();
return (
setState("menu")}
side={isMobile ? "top" : "right"}
align={isMobile ? "center" : "start"}
- className={`max-w-xs w-[1000px] ${state === "theme" && "bg-white!"}`}
- trigger={ label="Settings" />}
+ className={`w-xs bg-white!`}
+ arrowFill="bg-white"
+ trigger={ label="Theme" />}
>
- {state === "general" ? (
- setState("menu")} />
- ) : state === "theme" ? (
- setState("menu")}
- />
- ) : (
-
- )}
-
- );
-};
-
-const SettingsMenu = (props: {
- state: "menu" | "general" | "theme";
- setState: (s: typeof props.state) => void;
-}) => {
- let menuItemClassName =
- "menuItem -mx-[8px] text-left flex items-center justify-between hover:no-underline!";
-
- return (
-
-
-
{
- props.setState("general");
- }}
- >
- General
-
-
-
props.setState("theme")}
- >
- Account Theme
-
-
-
- );
-};
-
-const GeneralSettings = (props: { backToMenu: () => void }) => {
- return (
-
-
props.backToMenu()}
- />
-
- {
- await fetch("/api/auth/logout");
- mutate("identity", null);
- }}
- >
-
- Logout
-
-
- );
-};
-const AccountThemeSettings = (props: {
- entityID: string;
- backToMenu: () => void;
-}) => {
- return (
-
-
props.backToMenu()}
- />
-
- );
-};
-export const AccountSettingsHeader = (props: {
- state: "menu" | "general" | "theme";
- backToMenuAction?: () => void;
-}) => {
- return (
-
- {props.state === "menu"
- ? "Settings"
- : props.state === "general"
- ? "General"
- : props.state === "theme"
- ? "Account Theme"
- : ""}
- {props.backToMenuAction && (
- {
- props.backToMenuAction && props.backToMenuAction();
- }}
- >
-
-
- )}
-
+
);
};
diff --git a/app/(home-pages)/home/Actions/Actions.tsx b/app/(home-pages)/home/Actions/Actions.tsx
index 20852f4d..050f7f16 100644
--- a/app/(home-pages)/home/Actions/Actions.tsx
+++ b/app/(home-pages)/home/Actions/Actions.tsx
@@ -2,7 +2,7 @@
import { ThemePopover } from "components/ThemeManager/ThemeSetter";
import { CreateNewLeafletButton } from "./CreateNewButton";
import { HelpButton } from "app/[leaflet_id]/actions/HelpButton";
-import { AccountSettings } from "./AccountSettings";
+import { AccountTheme } from "./AccountSettings";
import { useIdentityData } from "components/IdentityProvider";
import { useReplicache } from "src/replicache";
import { LoginActionButton } from "components/LoginButton";
@@ -13,11 +13,7 @@ export const Actions = () => {
return (
<>
- {identity ? (
-
- ) : (
-
- )}
+ {identity && }
>
);
};
diff --git a/app/(home-pages)/home/Actions/CreateNewButton.tsx b/app/(home-pages)/home/Actions/CreateNewButton.tsx
index 417c5dcd..454ae7ca 100644
--- a/app/(home-pages)/home/Actions/CreateNewButton.tsx
+++ b/app/(home-pages)/home/Actions/CreateNewButton.tsx
@@ -26,8 +26,9 @@ export const CreateNewLeafletButton = (props: {}) => {
+ icon=
label="New"
+ smallOnMobile
/>
}
>
diff --git a/app/(home-pages)/home/HomeEmpty/HomeEmpty.tsx b/app/(home-pages)/home/HomeEmpty/HomeEmpty.tsx
index ecabbabc..517e2580 100644
--- a/app/(home-pages)/home/HomeEmpty/HomeEmpty.tsx
+++ b/app/(home-pages)/home/HomeEmpty/HomeEmpty.tsx
@@ -1,107 +1,83 @@
"use client";
-
import { PubListEmptyIllo } from "components/ActionBar/Publications";
-import { ButtonPrimary } from "components/Buttons";
-import { AddSmall } from "components/Icons/AddSmall";
-import { Link } from "react-aria-components";
-import { DiscoverIllo } from "./DiscoverIllo";
+import { ButtonPrimary, ButtonSecondary } from "components/Buttons";
import { WelcomeToLeafletIllo } from "./WelcomeToLeafletIllo";
-import { DiscoverSmall } from "components/Icons/DiscoverSmall";
-import { PublishSmall } from "components/Icons/PublishSmall";
import { createNewLeaflet } from "actions/createNewLeaflet";
import { useIsMobile } from "src/hooks/isMobile";
+import { SpeedyLink } from "components/SpeedyLink";
export function HomeEmptyState() {
- let isMobile = useIsMobile();
return (
-
-
-
-
-
-
-
Leaflet
- {/*
A platform for social publishing. */}
-
- Write and share delightful documents!
-
-
{
- let openNewLeaflet = (id: string) => {
- if (isMobile) {
- window.location.href = `/${id}?focusFirstBlock`;
- } else {
- window.open(`/${id}?focusFirstBlock`, "_blank");
- }
- };
-
- let id = await createNewLeaflet({
- pageType: "doc",
- redirectUser: false,
- });
- openNewLeaflet(id);
- }}
- >
- Write a Doc!
-
-
-
-
-
+
-
-
- Right now docs and publications are separate. Soon you'll be able to add
- docs to pubs!
+
+
);
}
-export const PublicationBanner = (props: { small?: boolean }) => {
+let bannerStyles =
+ "flex flex-row sm:flex-col py-4 px-4 sm:items-center items-start gap-4";
+
+const PublicationBanner = () => {
return (
-
- {props.small ? (
-
- ) : (
+
+
- )}
-
-
- Start a Publication
- {" "}
- and blog in the Atmosphere
+
+
Create a Publication!
+
+ You can decide to share or publish it later.
+
+
+ Create a Publication
+
+
);
};
-export const DiscoverBanner = (props: { small?: boolean }) => {
+const DocBanner = () => {
+ let isMobile = useIsMobile();
+
return (
-
- {props.small ? (
-
- ) : (
-
-
+
+
+
+
+
+
+
Just write something
+
+ You can decide to share or publish it later.
- )}
-
-
- Explore Publications
- {" "}
- on art, tech, games, music & more!
+ {
+ let openNewLeaflet = (id: string) => {
+ if (isMobile) {
+ window.location.href = `/${id}?focusFirstBlock`;
+ } else {
+ window.open(`/${id}?focusFirstBlock`, "_blank");
+ }
+ };
+
+ let id = await createNewLeaflet({
+ pageType: "doc",
+ redirectUser: false,
+ });
+ openNewLeaflet(id);
+ }}
+ >
+ New Doc!
+
);
diff --git a/app/(home-pages)/home/HomeEmpty/WelcomeToLeafletIllo.tsx b/app/(home-pages)/home/HomeEmpty/WelcomeToLeafletIllo.tsx
index 289d8355..d88c55a5 100644
--- a/app/(home-pages)/home/HomeEmpty/WelcomeToLeafletIllo.tsx
+++ b/app/(home-pages)/home/HomeEmpty/WelcomeToLeafletIllo.tsx
@@ -3,8 +3,8 @@ import { theme } from "tailwind.config";
export const WelcomeToLeafletIllo = () => {
return (
);
};
@@ -170,10 +167,6 @@ export function HomeLeafletList(props: {
showPreview
/>
-
- {leaflets.filter((l) => !!l.token.leaflets_in_publications).length ===
- 0 &&
}
-
>
);
}
@@ -204,7 +197,7 @@ export function LeafletList(props: {
className={`
leafletList
w-full
- ${display === "grid" ? "grid auto-rows-max md:grid-cols-4 sm:grid-cols-3 grid-cols-2 gap-y-4 gap-x-4 sm:gap-x-6 sm:gap-y-5 grow" : "flex flex-col gap-2 pt-2"} `}
+ ${display === "grid" ? "grid auto-rows-max md:grid-cols-4 sm:grid-cols-3 grid-cols-2 gap-y-4 gap-x-4 sm:gap-x-6 sm:gap-y-5 grow" : "flex flex-col gap-2"} `}
>
{props.leaflets.map(({ token: leaflet, added_at, archived }, index) => (
+
{props.children}
>
);
@@ -34,6 +36,7 @@ export default async function HomePagesLayout(props: {
>
+
{props.children}
diff --git a/app/(home-pages)/looseleafs/LooseleafsLayout.tsx b/app/(home-pages)/looseleafs/LooseleafsLayout.tsx
index 5f0c12b3..c389d6e4 100644
--- a/app/(home-pages)/looseleafs/LooseleafsLayout.tsx
+++ b/app/(home-pages)/looseleafs/LooseleafsLayout.tsx
@@ -47,6 +47,7 @@ export const LooseleafsLayout = (props: {
),
},
}}
+ pageTitle="Looseleafs"
/>
);
};
diff --git a/app/(home-pages)/p/[didOrHandle]/PostsContent.tsx b/app/(home-pages)/p/[didOrHandle]/PostsContent.tsx
index eb841838..7bd67c3c 100644
--- a/app/(home-pages)/p/[didOrHandle]/PostsContent.tsx
+++ b/app/(home-pages)/p/[didOrHandle]/PostsContent.tsx
@@ -68,7 +68,7 @@ export const ProfilePostsContent = (props: {
}
return (
-
+
{allPosts.map((post) => (
))}
diff --git a/app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx b/app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx
index 3fb2b0fd..f99934c7 100644
--- a/app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx
+++ b/app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx
@@ -42,8 +42,6 @@ export const ProfileHeader = (props: {
@{props.profile.handle}
);
- console.log(props.profile);
-
return (
-
+
{props.publications.map((p) => (
diff --git a/app/(home-pages)/p/[didOrHandle]/ProfileLayout.tsx b/app/(home-pages)/p/[didOrHandle]/ProfileLayout.tsx
index a298dec3..15e598d0 100644
--- a/app/(home-pages)/p/[didOrHandle]/ProfileLayout.tsx
+++ b/app/(home-pages)/p/[didOrHandle]/ProfileLayout.tsx
@@ -11,10 +11,10 @@ export function ProfileLayout(props: { children: React.ReactNode }) {
${
cardBorderHidden
? ""
- : "overflow-y-scroll h-full border border-border-light rounded-lg bg-bg-page"
+ : "overflow-y-scroll h-full border border-border-light rounded-lg bg-bg-page px-3 sm:px-4"
}
max-w-prose mx-auto w-full
- flex flex-col
+ flex flex-col pb-3
text-center
`}
>
diff --git a/app/(home-pages)/p/[didOrHandle]/ProfileTabs.tsx b/app/(home-pages)/p/[didOrHandle]/ProfileTabs.tsx
index c5b9e3a2..bb058749 100644
--- a/app/(home-pages)/p/[didOrHandle]/ProfileTabs.tsx
+++ b/app/(home-pages)/p/[didOrHandle]/ProfileTabs.tsx
@@ -41,7 +41,7 @@ export const ProfileTabs = (props: { didOrHandle: string }) => {
const bgColor = cardBorderHidden ? "var(--bg-leaflet)" : "var(--bg-page)";
return (
-
+
{
+export const PubListing = (props: PublicationSubscription) => {
let record = props.record;
let theme = usePubTheme(record?.theme);
let backgroundImage = record?.theme?.backgroundImage?.image?.ref
@@ -28,8 +25,7 @@ export const PubListing = (
if (!record) return null;
return (
-
+
@@ -51,11 +48,11 @@ export const PubListing = (
{record.name}
{record.description && (
-
+
{record.description}
)}
-
+
{props.authorProfile?.handle}
@@ -67,8 +64,17 @@ export const PubListing = (
)}
+
+
+
-
+
);
};
diff --git a/app/(home-pages)/p/[didOrHandle]/comments/CommentsContent.tsx b/app/(home-pages)/p/[didOrHandle]/comments/CommentsContent.tsx
index b7a9a350..a2a3f474 100644
--- a/app/(home-pages)/p/[didOrHandle]/comments/CommentsContent.tsx
+++ b/app/(home-pages)/p/[didOrHandle]/comments/CommentsContent.tsx
@@ -85,7 +85,7 @@ export const ProfileCommentsContent = (props: {
}
return (
-
+
{allComments.map((comment) => (
))}
diff --git a/app/(home-pages)/p/[didOrHandle]/layout.tsx b/app/(home-pages)/p/[didOrHandle]/layout.tsx
index 4108fe28..eb9f3531 100644
--- a/app/(home-pages)/p/[didOrHandle]/layout.tsx
+++ b/app/(home-pages)/p/[didOrHandle]/layout.tsx
@@ -82,6 +82,7 @@ export default async function ProfilePageLayout(props: {
id="profile"
defaultTab="default"
currentPage="profile"
+ profileDid={did}
actions={null}
tabs={{
default: {
@@ -93,9 +94,7 @@ export default async function ProfilePageLayout(props: {
publications={publications || []}
/>
-
- {props.children}
-
+ <>{props.children}>
),
},
diff --git a/app/(home-pages)/p/[didOrHandle]/subscriptions/SubscriptionsContent.tsx b/app/(home-pages)/p/[didOrHandle]/subscriptions/SubscriptionsContent.tsx
index 9e5e4ca8..49818ecd 100644
--- a/app/(home-pages)/p/[didOrHandle]/subscriptions/SubscriptionsContent.tsx
+++ b/app/(home-pages)/p/[didOrHandle]/subscriptions/SubscriptionsContent.tsx
@@ -2,7 +2,7 @@
import { useEffect, useRef } from "react";
import useSWRInfinite from "swr/infinite";
-import { PubListing } from "app/(home-pages)/discover/PubListing";
+import { PubListing } from "app/(home-pages)/p/[didOrHandle]/PubListing";
import {
getSubscriptions,
type PublicationSubscription,
@@ -82,7 +82,7 @@ export const ProfileSubscriptionsContent = (props: {
return (
-
+
{allSubscriptions.map((sub) => (
))}
diff --git a/app/(home-pages)/page.tsx b/app/(home-pages)/page.tsx
new file mode 100644
index 00000000..036e9562
--- /dev/null
+++ b/app/(home-pages)/page.tsx
@@ -0,0 +1,31 @@
+import { cookies } from "next/headers";
+import ReaderLayout from "./reader/layout";
+import ReaderPage from "./reader/page";
+import HomePage from "./home/page";
+
+export default async function RootPage() {
+ const cookieStore = await cookies();
+ const hasAuth =
+ cookieStore.has("auth_token") ||
+ cookieStore.has("external_auth_token");
+
+ if (!hasAuth) {
+ return (
+
+
+
+ );
+ }
+
+ const navState = cookieStore.get("nav-state")?.value;
+
+ if (navState === "reader") {
+ return (
+
+
+
+ );
+ }
+
+ return
;
+}
diff --git a/app/(home-pages)/reader/FeedSkeleton.tsx b/app/(home-pages)/reader/FeedSkeleton.tsx
new file mode 100644
index 00000000..2541b869
--- /dev/null
+++ b/app/(home-pages)/reader/FeedSkeleton.tsx
@@ -0,0 +1,13 @@
+export function FeedSkeleton() {
+ return (
+
+ {[...Array(3)].map((_, i) => (
+
+ ))}
+
+ );
+}
diff --git a/app/(home-pages)/reader/GlobalContent.tsx b/app/(home-pages)/reader/GlobalContent.tsx
new file mode 100644
index 00000000..53cf2462
--- /dev/null
+++ b/app/(home-pages)/reader/GlobalContent.tsx
@@ -0,0 +1,49 @@
+"use client";
+import { use } from "react";
+import useSWR from "swr";
+import { callRPC } from "app/api/rpc/client";
+import { PostListing } from "components/PostListing";
+import type { Post } from "./getReaderFeed";
+import {
+ DesktopInteractionPreviewDrawer,
+ MobileInteractionPreviewDrawer,
+} from "./InteractionDrawers";
+
+export const GlobalContent = (props: {
+ promise: Promise<{ posts: Post[] }>;
+}) => {
+ const initialData = use(props.promise);
+
+ const { data } = useSWR(
+ "hot_feed",
+ async () => {
+ const res = await callRPC("get_hot_feed", {});
+ return res as unknown as { posts: Post[] };
+ },
+ {
+ fallbackData: { posts: initialData.posts },
+ },
+ );
+
+ const posts = data?.posts ?? [];
+
+ if (posts.length === 0) {
+ return (
+
+ Nothing trending right now. Check back soon!
+
+ );
+ }
+
+ return (
+
+
+ {posts.map((p) => (
+
+ ))}
+
+
+
+
+ );
+};
diff --git a/app/(home-pages)/reader/ReaderContent.tsx b/app/(home-pages)/reader/InboxContent.tsx
similarity index 61%
rename from app/(home-pages)/reader/ReaderContent.tsx
rename to app/(home-pages)/reader/InboxContent.tsx
index e73b3df9..791d4827 100644
--- a/app/(home-pages)/reader/ReaderContent.tsx
+++ b/app/(home-pages)/reader/InboxContent.tsx
@@ -1,4 +1,5 @@
"use client";
+import { use } from "react";
import { ButtonPrimary } from "components/Buttons";
import { DiscoverSmall } from "components/Icons/DiscoverSmall";
import type { Cursor, Post } from "./getReaderFeed";
@@ -7,11 +8,17 @@ import { getReaderFeed } from "./getReaderFeed";
import { useEffect, useRef } from "react";
import Link from "next/link";
import { PostListing } from "components/PostListing";
+import { useHasBackgroundImage } from "components/Pages/useHasBackgroundImage";
+import {
+ DesktopInteractionPreviewDrawer,
+ MobileInteractionPreviewDrawer,
+} from "./InteractionDrawers";
-export const ReaderContent = (props: {
- posts: Post[];
- nextCursor: Cursor | null;
+export const InboxContent = (props: {
+ promise: Promise<{ posts: Post[]; nextCursor: Cursor | null }>;
}) => {
+ const { posts, nextCursor } = use(props.promise);
+
const getKey = (
pageIndex: number,
previousPageData: {
@@ -33,7 +40,7 @@ export const ReaderContent = (props: {
getKey,
([_, cursor]) => getReaderFeed(cursor),
{
- fallbackData: [{ posts: props.posts, nextCursor: props.nextCursor }],
+ fallbackData: [{ posts, nextCursor }],
revalidateFirstPage: false,
},
);
@@ -63,24 +70,36 @@ export const ReaderContent = (props: {
const allPosts = data ? data.flatMap((page) => page.posts) : [];
+ const sortedPosts = allPosts.sort(
+ (a, b) =>
+ new Date(b.documents.data?.publishedAt || 0).getTime() -
+ new Date(a.documents.data?.publishedAt || 0).getTime(),
+ );
+
if (allPosts.length === 0 && !isValidating) return
;
+ let hasBackgroundImage = useHasBackgroundImage();
+
return (
-
- {allPosts.map((p) => (
-
- ))}
- {/* Trigger element for loading more posts */}
-
- {isValidating && (
-
- Loading more posts...
-
- )}
+
+
+ {sortedPosts.map((p) => (
+
+ ))}
+ {/* Trigger element for loading more posts */}
+
+ {isValidating && (
+
+ Loading more posts...
+
+ )}
+
+
+
);
};
@@ -90,9 +109,9 @@ export const ReaderEmpty = () => {
Nothing to read yet…
Subscribe to publications and find their posts here!
-
+
- Discover Publications
+ See what posts people are reading!
diff --git a/app/(home-pages)/reader/InteractionDrawers.tsx b/app/(home-pages)/reader/InteractionDrawers.tsx
new file mode 100644
index 00000000..0d083e3a
--- /dev/null
+++ b/app/(home-pages)/reader/InteractionDrawers.tsx
@@ -0,0 +1,108 @@
+"use client";
+import { ButtonPrimary } from "components/Buttons";
+import {
+ SelectedPostListing,
+ useSelectedPostListing,
+} from "src/useSelectedPostState";
+import { CommentsDrawerContent } from "app/lish/[did]/[publication]/[rkey]/Interactions/Comments";
+import { CloseTiny } from "components/Icons/CloseTiny";
+import { SpeedyLink } from "components/SpeedyLink";
+import { GoToArrow } from "components/Icons/GoToArrow";
+import { DotLoader } from "components/utils/DotLoader";
+import { ReaderMentionsContent } from "./ReaderMentionsContent";
+import { callRPC } from "app/api/rpc/client";
+import useSWR from "swr";
+import { getDocumentURL } from "app/lish/createPub/getPublicationURL";
+
+export const MobileInteractionPreviewDrawer = () => {
+ let selectedPost = useSelectedPostListing((s) => s.selectedPostListing);
+
+ return (
+
+ );
+};
+
+export const DesktopInteractionPreviewDrawer = () => {
+ let selectedPost = useSelectedPostListing((s) => s.selectedPostListing);
+
+ return (
+
+ );
+};
+
+const PreviewDrawerContent = (props: {
+ selectedPost: SelectedPostListing | null;
+}) => {
+ const documentUri = props.selectedPost?.document_uri || null;
+ const drawer = props.selectedPost?.drawer || null;
+
+ const { data, isLoading } = useSWR(
+ documentUri ? ["get_document_interactions", documentUri] : null,
+ async () => {
+ const res = await callRPC("get_document_interactions", {
+ document_uri: documentUri!,
+ });
+ return res;
+ },
+ { keepPreviousData: false },
+ );
+
+ if (!props.selectedPost || !props.selectedPost.document) return null;
+
+ const postUrl = getDocumentURL(
+ props.selectedPost.document,
+ props.selectedPost.document_uri,
+ );
+
+ const drawerTitle =
+ drawer === "quotes"
+ ? `Mentions of ${props.selectedPost.document.title}`
+ : `Comments for ${props.selectedPost.document.title}`;
+
+ return (
+ <>
+
+
{drawerTitle}
+
+ useSelectedPostListing.getState().setSelectedPostListing(null)
+ }
+ >
+
+
+
+
+
+ See Full Post
+
+
+ {isLoading ? (
+
+ loading
+
+
+ ) : drawer === "quotes" ? (
+
+
+
+ ) : (
+
+ )}
+ >
+ );
+};
diff --git a/app/(home-pages)/reader/NewContent.tsx b/app/(home-pages)/reader/NewContent.tsx
new file mode 100644
index 00000000..972a9313
--- /dev/null
+++ b/app/(home-pages)/reader/NewContent.tsx
@@ -0,0 +1,93 @@
+"use client";
+
+import { use } from "react";
+import type { Cursor, Post } from "./getReaderFeed";
+import useSWRInfinite from "swr/infinite";
+import { getNewFeed } from "./getNewFeed";
+import { useEffect, useRef } from "react";
+import { PostListing } from "components/PostListing";
+import {
+ DesktopInteractionPreviewDrawer,
+ MobileInteractionPreviewDrawer,
+} from "./InteractionDrawers";
+
+export const NewContent = (props: {
+ promise: Promise<{ posts: Post[]; nextCursor: Cursor | null }>;
+}) => {
+ const { posts, nextCursor } = use(props.promise);
+
+ const getKey = (
+ pageIndex: number,
+ previousPageData: {
+ posts: Post[];
+ nextCursor: Cursor | null;
+ } | null,
+ ) => {
+ if (previousPageData && !previousPageData.nextCursor) return null;
+ if (pageIndex === 0) return ["new-feed", null] as const;
+ return ["new-feed", previousPageData?.nextCursor] as const;
+ };
+
+ const { data, size, setSize, isValidating } = useSWRInfinite(
+ getKey,
+ ([_, cursor]) => getNewFeed(cursor),
+ {
+ fallbackData: [{ posts, nextCursor }],
+ revalidateFirstPage: false,
+ },
+ );
+
+ const loadMoreRef = useRef
(null);
+
+ useEffect(() => {
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries[0].isIntersecting && !isValidating) {
+ const hasMore = data && data[data.length - 1]?.nextCursor;
+ if (hasMore) {
+ setSize(size + 1);
+ }
+ }
+ },
+ { threshold: 0.1 },
+ );
+
+ if (loadMoreRef.current) {
+ observer.observe(loadMoreRef.current);
+ }
+
+ return () => observer.disconnect();
+ }, [data, size, setSize, isValidating]);
+
+ const allPosts = data ? data.flatMap((page) => page.posts) : [];
+
+ if (allPosts.length === 0 && !isValidating) {
+ return (
+
+ No posts yet. Check back soon!
+
+ );
+ }
+
+ return (
+
+
+ {allPosts.map((p) => (
+
+ ))}
+
+ {isValidating && (
+
+ Loading more posts...
+
+ )}
+
+
+
+
+ );
+};
diff --git a/app/(home-pages)/reader/PreviewDrawer.tsx b/app/(home-pages)/reader/PreviewDrawer.tsx
new file mode 100644
index 00000000..e69de29b
diff --git a/app/(home-pages)/reader/ReaderMentionsContent.tsx b/app/(home-pages)/reader/ReaderMentionsContent.tsx
new file mode 100644
index 00000000..0b1dd2f8
--- /dev/null
+++ b/app/(home-pages)/reader/ReaderMentionsContent.tsx
@@ -0,0 +1,72 @@
+"use client";
+import useSWR from "swr";
+import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs";
+import { BskyPostContent } from "app/lish/[did]/[publication]/[rkey]/BskyPostContent";
+import { DotLoader } from "components/utils/DotLoader";
+
+async function fetchBskyPosts(uris: string[]): Promise {
+ const params = new URLSearchParams({
+ uris: JSON.stringify(uris),
+ });
+ const response = await fetch(`/api/bsky/hydrate?${params.toString()}`);
+ if (!response.ok) throw new Error("Failed to fetch Bluesky posts");
+ return response.json();
+}
+
+export function ReaderMentionsContent(props: {
+ quotesAndMentions: { uri: string; link?: string }[];
+}) {
+ const uris = props.quotesAndMentions.map((q) => q.uri);
+ const key = uris.length > 0
+ ? `/api/bsky/hydrate?${new URLSearchParams({ uris: JSON.stringify(uris) }).toString()}`
+ : null;
+
+ const { data: bskyPosts, isLoading } = useSWR(key, () =>
+ fetchBskyPosts(uris),
+ );
+
+ if (props.quotesAndMentions.length === 0) {
+ return (
+
+ );
+ }
+
+ if (isLoading) {
+ return (
+
+ loading
+
+
+ );
+ }
+
+ const postViewMap = new Map();
+ bskyPosts?.forEach((pv) => postViewMap.set(pv.uri, pv));
+
+ return (
+
+ {props.quotesAndMentions.map((q, index) => {
+ const post = postViewMap.get(q.uri);
+ if (!post) return null;
+ return (
+
+
+ {index < props.quotesAndMentions.length - 1 && (
+
+ )}
+
+ );
+ })}
+
+ );
+}
diff --git a/app/(home-pages)/reader/SubscriptionsContent.tsx b/app/(home-pages)/reader/SubscriptionsContent.tsx
deleted file mode 100644
index 9645822f..00000000
--- a/app/(home-pages)/reader/SubscriptionsContent.tsx
+++ /dev/null
@@ -1,105 +0,0 @@
-"use client";
-import { PubListing } from "app/(home-pages)/discover/PubListing";
-import { ButtonPrimary } from "components/Buttons";
-import { DiscoverSmall } from "components/Icons/DiscoverSmall";
-import { Json } from "supabase/database.types";
-import { PublicationSubscription, getSubscriptions } from "./getSubscriptions";
-import useSWRInfinite from "swr/infinite";
-import { useEffect, useRef } from "react";
-import { Cursor } from "./getReaderFeed";
-import Link from "next/link";
-
-export const SubscriptionsContent = (props: {
- publications: PublicationSubscription[];
- nextCursor: Cursor | null;
-}) => {
- const getKey = (
- pageIndex: number,
- previousPageData: {
- subscriptions: PublicationSubscription[];
- nextCursor: Cursor | null;
- } | null,
- ) => {
- // Reached the end
- if (previousPageData && !previousPageData.nextCursor) return null;
-
- // First page, we don't have previousPageData
- if (pageIndex === 0) return ["subscriptions", null] as const;
-
- // Add the cursor to the key
- return ["subscriptions", previousPageData?.nextCursor] as const;
- };
-
- const { data, error, size, setSize, isValidating } = useSWRInfinite(
- getKey,
- ([_, cursor]) => getSubscriptions(null, cursor),
- {
- fallbackData: [
- { subscriptions: props.publications, nextCursor: props.nextCursor },
- ],
- revalidateFirstPage: false,
- },
- );
-
- const loadMoreRef = useRef(null);
-
- // Set up intersection observer to load more when trigger element is visible
- useEffect(() => {
- const observer = new IntersectionObserver(
- (entries) => {
- if (entries[0].isIntersecting && !isValidating) {
- const hasMore = data && data[data.length - 1]?.nextCursor;
- if (hasMore) {
- setSize(size + 1);
- }
- }
- },
- { threshold: 0.1 },
- );
-
- if (loadMoreRef.current) {
- observer.observe(loadMoreRef.current);
- }
-
- return () => observer.disconnect();
- }, [data, size, setSize, isValidating]);
-
- const allPublications = data
- ? data.flatMap((page) => page.subscriptions)
- : [];
-
- if (allPublications.length === 0 && !isValidating)
- return ;
-
- return (
-
-
- {allPublications?.map((p, index) =>
)}
-
- {/* Trigger element for loading more subscriptions */}
-
- {isValidating && (
-
- Loading more subscriptions...
-
- )}
-
- );
-};
-
-export const SubscriptionsEmpty = () => {
- return (
-
- You haven't subscribed to any publications yet!
-
-
- Discover Publications
-
-
-
- );
-};
diff --git a/app/(home-pages)/reader/enrichPost.ts b/app/(home-pages)/reader/enrichPost.ts
new file mode 100644
index 00000000..de71f468
--- /dev/null
+++ b/app/(home-pages)/reader/enrichPost.ts
@@ -0,0 +1,88 @@
+import { AtUri } from "@atproto/api";
+import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
+import { getConstellationBacklinks } from "app/lish/[did]/[publication]/[rkey]/getPostPageData";
+import { getDocumentURL } from "app/lish/createPub/getPublicationURL";
+import {
+ normalizeDocumentRecord,
+ normalizePublicationRecord,
+} from "src/utils/normalizeRecords";
+import { idResolver } from "./idResolver";
+import type { Post } from "./getReaderFeed";
+
+type RawDocument = {
+ data: unknown;
+ uri: string;
+ sort_date: string;
+ comments_on_documents: { count: number }[];
+ document_mentions_in_bsky: { count: number }[];
+ recommends_on_documents: { count: number }[];
+ documents_in_publications: {
+ publications: {
+ uri: string;
+ record: unknown;
+ name: string | null;
+ [key: string]: unknown;
+ } | null;
+ }[];
+};
+
+export async function enrichDocumentToPost(
+ doc: RawDocument,
+): Promise {
+ const pub = doc.documents_in_publications?.[0]?.publications;
+ const uri = new AtUri(doc.uri);
+ const handle = await idResolver.did.resolve(uri.host);
+
+ const normalizedData = normalizeDocumentRecord(doc.data, doc.uri);
+ if (!normalizedData) return null;
+
+ const normalizedPubRecord = pub
+ ? normalizePublicationRecord(pub.record)
+ : null;
+
+ const mentionsCount = await getAccurateMentionsCount(
+ normalizedData,
+ doc.uri,
+ normalizedPubRecord,
+ doc.document_mentions_in_bsky?.[0]?.count || 0,
+ );
+
+ return {
+ publication: pub
+ ? {
+ href: getPublicationURL(pub),
+ pubRecord: normalizedPubRecord,
+ uri: pub.uri || "",
+ }
+ : undefined,
+ author: handle?.alsoKnownAs?.[0]
+ ? `@${handle.alsoKnownAs[0].slice(5)}`
+ : null,
+ documents: {
+ comments_on_documents: doc.comments_on_documents,
+ document_mentions_in_bsky: doc.document_mentions_in_bsky,
+ recommends_on_documents: doc.recommends_on_documents,
+ mentionsCount,
+ data: normalizedData,
+ uri: doc.uri,
+ sort_date: doc.sort_date,
+ },
+ };
+}
+
+async function getAccurateMentionsCount(
+ normalizedData: NonNullable>,
+ docUri: string,
+ normalizedPubRecord: ReturnType,
+ dbMentionsCount: number,
+): Promise {
+ const postUrl = getDocumentURL(normalizedData, docUri, normalizedPubRecord);
+ const absoluteUrl = postUrl.startsWith("/")
+ ? `https://leaflet.pub${postUrl}`
+ : postUrl;
+ const constellationBacklinks = await getConstellationBacklinks(absoluteUrl);
+ const uniqueBacklinkCount = new Set(
+ constellationBacklinks.map((b) => b.uri),
+ ).size;
+ return dbMentionsCount + uniqueBacklinkCount;
+}
diff --git a/app/(home-pages)/reader/getHotFeed.ts b/app/(home-pages)/reader/getHotFeed.ts
new file mode 100644
index 00000000..ada1e68a
--- /dev/null
+++ b/app/(home-pages)/reader/getHotFeed.ts
@@ -0,0 +1,100 @@
+"use server";
+
+import { drizzle } from "drizzle-orm/node-postgres";
+import { sql } from "drizzle-orm";
+import { pool } from "supabase/pool";
+import Client from "ioredis";
+import { AtUri } from "@atproto/api";
+import { supabaseServerClient } from "supabase/serverClient";
+import { enrichDocumentToPost } from "./enrichPost";
+import type { Post } from "./getReaderFeed";
+
+let redisClient: Client | null = null;
+if (process.env.REDIS_URL && process.env.NODE_ENV === "production") {
+ redisClient = new Client(process.env.REDIS_URL);
+}
+
+const CACHE_KEY = "hot_feed_v1";
+const CACHE_TTL = 300; // 5 minutes
+
+export async function getHotFeed(): Promise<{ posts: Post[] }> {
+ // Check Redis cache
+ if (redisClient) {
+ const cached = await redisClient.get(CACHE_KEY);
+ if (cached) {
+ return JSON.parse(cached) as { posts: Post[] };
+ }
+ }
+
+ // Run ranked SQL query to get top 50 URIs
+ const client = await pool.connect();
+ const db = drizzle(client);
+
+ let uris: string[];
+ try {
+ const ranked = await db.execute(sql`
+ SELECT uri
+ FROM documents
+ WHERE indexed = true
+ AND sort_date > now() - interval '7 days'
+ ORDER BY
+ (bsky_like_count + recommend_count * 5)::numeric
+ / power(extract(epoch from (now() - sort_date)) / 3600 + 2, 1.5) DESC
+ LIMIT 50
+ `);
+ uris = ranked.rows.map((row: any) => row.uri as string);
+ } finally {
+ client.release();
+ }
+
+ if (uris.length === 0) {
+ return { posts: [] };
+ }
+
+ // Batch-fetch documents with publication joins and interaction counts
+ const { data: documents } = await supabaseServerClient
+ .from("documents")
+ .select(
+ `*,
+ comments_on_documents(count),
+ document_mentions_in_bsky(count),
+ recommends_on_documents(count),
+ documents_in_publications(publications(*))`,
+ )
+ .in("uri", uris);
+
+ // Build lookup map for enrichment
+ const docMap = new Map((documents || []).map((d) => [d.uri, d]));
+
+ // Process in ranked order, deduplicating by identity key (DID/rkey)
+ const seen = new Set();
+ const orderedDocs: NonNullable[number][] = [];
+ for (const uri of uris) {
+ try {
+ const parsed = new AtUri(uri);
+ const identityKey = `${parsed.host}/${parsed.rkey}`;
+ if (seen.has(identityKey)) continue;
+ seen.add(identityKey);
+ } catch {
+ // invalid URI, skip dedup check
+ }
+ const doc = docMap.get(uri);
+ if (doc) orderedDocs.push(doc);
+ }
+
+ // Enrich into Post[]
+ const posts = (
+ await Promise.all(
+ orderedDocs.map((doc) => enrichDocumentToPost(doc as any)),
+ )
+ ).filter((post): post is Post => post !== null);
+
+ const response = { posts };
+
+ // Cache in Redis
+ if (redisClient) {
+ await redisClient.setex(CACHE_KEY, CACHE_TTL, JSON.stringify(response));
+ }
+
+ return response;
+}
diff --git a/app/(home-pages)/reader/getNewFeed.ts b/app/(home-pages)/reader/getNewFeed.ts
new file mode 100644
index 00000000..cbbe54ff
--- /dev/null
+++ b/app/(home-pages)/reader/getNewFeed.ts
@@ -0,0 +1,54 @@
+"use server";
+
+import { supabaseServerClient } from "supabase/serverClient";
+import { deduplicateByUriOrdered } from "src/utils/deduplicateRecords";
+import { enrichDocumentToPost } from "./enrichPost";
+import type { Cursor, Post } from "./getReaderFeed";
+
+export async function getNewFeed(
+ cursor?: Cursor | null,
+): Promise<{ posts: Post[]; nextCursor: Cursor | null }> {
+ let query = supabaseServerClient
+ .from("documents")
+ .select(
+ `*,
+ comments_on_documents(count),
+ document_mentions_in_bsky(count),
+ recommends_on_documents(count),
+ documents_in_publications!inner(publications!inner(*))`,
+ )
+ .or(
+ "record->preferences->showInDiscover.is.null,record->preferences->>showInDiscover.eq.true",
+ { referencedTable: "documents_in_publications.publications" },
+ )
+ .order("sort_date", { ascending: false })
+ .order("uri", { ascending: false })
+ .limit(25);
+
+ if (cursor) {
+ query = query.or(
+ `sort_date.lt.${cursor.timestamp},and(sort_date.eq.${cursor.timestamp},uri.lt.${cursor.uri})`,
+ );
+ }
+
+ let { data: rawFeed, error } = await query;
+
+ const feed = deduplicateByUriOrdered(rawFeed || []);
+
+ let posts = (
+ await Promise.all(feed.map((post) => enrichDocumentToPost(post as any)))
+ ).filter((post): post is Post => post !== null);
+
+ const nextCursor =
+ posts.length > 0
+ ? {
+ timestamp: posts[posts.length - 1].documents.sort_date,
+ uri: posts[posts.length - 1].documents.uri,
+ }
+ : null;
+
+ return {
+ posts,
+ nextCursor,
+ };
+}
diff --git a/app/(home-pages)/reader/getReaderFeed.ts b/app/(home-pages)/reader/getReaderFeed.ts
index 4ff16591..cf2703b3 100644
--- a/app/(home-pages)/reader/getReaderFeed.ts
+++ b/app/(home-pages)/reader/getReaderFeed.ts
@@ -1,20 +1,13 @@
"use server";
import { getIdentityData } from "actions/getIdentityData";
-import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
import { supabaseServerClient } from "supabase/serverClient";
-import { IdResolver } from "@atproto/identity";
-import type { DidCache, CacheResult, DidDocument } from "@atproto/identity";
-import Client from "ioredis";
-import { AtUri } from "@atproto/api";
-import { idResolver } from "./idResolver";
-import {
- normalizeDocumentRecord,
- normalizePublicationRecord,
- type NormalizedDocument,
- type NormalizedPublication,
+import type {
+ NormalizedDocument,
+ NormalizedPublication,
} from "src/utils/normalizeRecords";
import { deduplicateByUriOrdered } from "src/utils/deduplicateRecords";
+import { enrichDocumentToPost } from "./enrichPost";
export type Cursor = {
timestamp: string;
@@ -53,40 +46,9 @@ export async function getReaderFeed(
const feed = deduplicateByUriOrdered(rawFeed || []);
let posts = (
- await Promise.all(
- feed.map(async (post) => {
- let pub = post.documents_in_publications[0].publications!;
- let uri = new AtUri(post.uri);
- let handle = await idResolver.did.resolve(uri.host);
-
- // Normalize records - filter out unrecognized formats
- const normalizedData = normalizeDocumentRecord(post.data, post.uri);
- if (!normalizedData) return null;
-
- const normalizedPubRecord = normalizePublicationRecord(pub?.record);
-
- let p: Post = {
- publication: {
- href: getPublicationURL(pub),
- pubRecord: normalizedPubRecord,
- uri: pub?.uri || "",
- },
- author: handle?.alsoKnownAs?.[0]
- ? `@${handle.alsoKnownAs[0].slice(5)}`
- : null,
- documents: {
- comments_on_documents: post.comments_on_documents,
- document_mentions_in_bsky: post.document_mentions_in_bsky,
- recommends_on_documents: post.recommends_on_documents,
- data: normalizedData,
- uri: post.uri,
- sort_date: post.sort_date,
- },
- };
- return p;
- }) || [],
- )
+ await Promise.all(feed.map((post) => enrichDocumentToPost(post as any)))
).filter((post): post is Post => post !== null);
+
const nextCursor =
posts.length > 0
? {
@@ -115,5 +77,6 @@ export type Post = {
comments_on_documents: { count: number }[] | undefined;
document_mentions_in_bsky: { count: number }[] | undefined;
recommends_on_documents: { count: number }[] | undefined;
+ mentionsCount?: number;
};
};
diff --git a/app/(home-pages)/reader/getSubscriptions.ts b/app/(home-pages)/reader/getSubscriptions.ts
index d1b3e6ff..b474c19a 100644
--- a/app/(home-pages)/reader/getSubscriptions.ts
+++ b/app/(home-pages)/reader/getSubscriptions.ts
@@ -29,7 +29,9 @@ export async function getSubscriptions(
let query = supabaseServerClient
.from("publication_subscriptions")
- .select(`*, publications(*, documents_in_publications(*, documents(*)))`)
+ .select(
+ `*, publications(*, publication_subscriptions(*), documents_in_publications(*, documents(*)))`,
+ )
.order(`created_at`, { ascending: false })
.order(`uri`, { ascending: false })
.order("documents(sort_date)", {
@@ -51,7 +53,7 @@ export async function getSubscriptions(
await Promise.all(
pubs?.map(async (pub) => {
const normalizedRecord = normalizePublicationRecord(
- pub.publications?.record
+ pub.publications?.record,
);
if (!normalizedRecord) return null;
let id = await idResolver.did.resolve(pub.publications?.identity_did!);
@@ -62,10 +64,9 @@ export async function getSubscriptions(
? { handle: `@${id.alsoKnownAs[0].slice(5)}` }
: undefined,
} as PublicationSubscription;
- }) || []
+ }) || [],
)
).filter((sub): sub is PublicationSubscription => sub !== null);
-
const nextCursor =
pubs && pubs.length > 0
? {
@@ -83,6 +84,7 @@ export async function getSubscriptions(
export type PublicationSubscription = {
authorProfile?: { handle: string };
record: NormalizedPublication;
+ publication_subscriptions: { identity: string }[];
uri: string;
documents_in_publications: {
documents: { data?: Json; sort_date: string } | null;
diff --git a/app/(home-pages)/reader/hot/page.tsx b/app/(home-pages)/reader/hot/page.tsx
new file mode 100644
index 00000000..3e0430b2
--- /dev/null
+++ b/app/(home-pages)/reader/hot/page.tsx
@@ -0,0 +1,7 @@
+import { getHotFeed } from "../getHotFeed";
+import { GlobalContent } from "../GlobalContent";
+
+export default async function HotPage() {
+ const feedPromise = getHotFeed();
+ return ;
+}
diff --git a/app/(home-pages)/reader/layout.tsx b/app/(home-pages)/reader/layout.tsx
new file mode 100644
index 00000000..4af166d9
--- /dev/null
+++ b/app/(home-pages)/reader/layout.tsx
@@ -0,0 +1,79 @@
+"use client";
+
+import { usePathname } from "next/navigation";
+import Link from "next/link";
+import { Header } from "components/PageHeader";
+import { Footer } from "components/ActionBar/Footer";
+import { DesktopNavigation } from "components/ActionBar/DesktopNavigation";
+import { MobileNavigation } from "components/ActionBar/MobileNavigation";
+import { MediaContents } from "components/Media";
+import { DashboardIdContext } from "components/PageLayouts/DashboardLayout";
+import { useIdentityData } from "components/IdentityProvider";
+
+const allTabs = [
+ { name: "Subs", href: "/reader", requiresAuth: true },
+ { name: "What's Hot", href: "/reader/hot", requiresAuth: false },
+ { name: "New", href: "/reader/new", requiresAuth: false },
+];
+
+export default function ReaderLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const pathname = usePathname();
+ const { identity } = useIdentityData();
+ const isLoggedIn = !!identity?.atp_did;
+ const tabs = allTabs.filter((tab) => !tab.requiresAuth || isLoggedIn);
+
+ const isActive = (href: string) => {
+ if (href === "/reader")
+ return pathname === "/reader" || pathname === "/";
+ if (
+ href === "/reader/hot" &&
+ !isLoggedIn &&
+ (pathname === "/reader" || pathname === "/")
+ )
+ return true;
+ return pathname.startsWith(href);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {tabs.map((tab) => (
+
+
+ {tab.name}
+
+
+ ))}
+
+
+
+ {children}
+
+
+
+
+ );
+}
diff --git a/app/(home-pages)/reader/loading.tsx b/app/(home-pages)/reader/loading.tsx
new file mode 100644
index 00000000..06773957
--- /dev/null
+++ b/app/(home-pages)/reader/loading.tsx
@@ -0,0 +1,4 @@
+import { FeedSkeleton } from "./FeedSkeleton";
+export default function Loading() {
+ return ;
+}
diff --git a/app/(home-pages)/reader/new/page.tsx b/app/(home-pages)/reader/new/page.tsx
new file mode 100644
index 00000000..c857832f
--- /dev/null
+++ b/app/(home-pages)/reader/new/page.tsx
@@ -0,0 +1,7 @@
+import { getNewFeed } from "../getNewFeed";
+import { NewContent } from "../NewContent";
+
+export default async function NewPage() {
+ const feedPromise = getNewFeed();
+ return ;
+}
diff --git a/app/(home-pages)/reader/page.tsx b/app/(home-pages)/reader/page.tsx
index 3372c83c..3e5349fe 100644
--- a/app/(home-pages)/reader/page.tsx
+++ b/app/(home-pages)/reader/page.tsx
@@ -1,37 +1,16 @@
import { getIdentityData } from "actions/getIdentityData";
-
-import { DashboardLayout } from "components/PageLayouts/DashboardLayout";
-import { ReaderContent } from "./ReaderContent";
-import { SubscriptionsContent } from "./SubscriptionsContent";
import { getReaderFeed } from "./getReaderFeed";
-import { getSubscriptions } from "./getSubscriptions";
+import { getHotFeed } from "./getHotFeed";
+import { InboxContent } from "./InboxContent";
+import { GlobalContent } from "./GlobalContent";
+
+export default async function Reader() {
+ let identityData = await getIdentityData();
+ if (!identityData?.atp_did) {
+ const feedPromise = getHotFeed();
+ return ;
+ }
-export default async function Reader(props: {}) {
- let posts = await getReaderFeed();
- let publications = await getSubscriptions();
- return (
-
- ),
- },
- Subscriptions: {
- controls: null,
- content: (
-
- ),
- },
- }}
- />
- );
+ const feedPromise = getReaderFeed();
+ return ;
}
diff --git a/app/[leaflet_id]/Footer.tsx b/app/[leaflet_id]/Footer.tsx
index a202d2ee..ad063f02 100644
--- a/app/[leaflet_id]/Footer.tsx
+++ b/app/[leaflet_id]/Footer.tsx
@@ -1,6 +1,6 @@
"use client";
import { useUIState } from "src/useUIState";
-import { Footer as ActionFooter } from "components/ActionBar/Footer";
+import { Footer } from "components/ActionBar/Footer";
import { Media } from "components/Media";
import { ThemePopover } from "components/ThemeManager/ThemeSetter";
import { Toolbar } from "components/Toolbar";
@@ -36,7 +36,10 @@ export function LeafletFooter(props: { entityID: string }) {
.value;
return (
-
+
{focusedBlock &&
focusedBlock.entityType == "block" &&
hasBlockToolbar(blockType) &&
@@ -54,7 +57,7 @@ export function LeafletFooter(props: { entityID: string }) {
/>
) : entity_set.permissions.write ? (
-
+
{pub?.publications &&
identity?.atp_did &&
pub.publications.identity_did === identity.atp_did ? (
@@ -62,12 +65,13 @@ export function LeafletFooter(props: { entityID: string }) {
) : (
)}
-
-
-
-
-
-
+
+
) : (
diff --git a/app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx b/app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx
index 02985291..8e029198 100644
--- a/app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx
+++ b/app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx
@@ -410,7 +410,6 @@ export const addMentionToEditor = (
range: { from: number; to: number },
view: EditorView,
) => {
- console.log("view", view);
if (!view) return;
const { from, to } = range;
const tr = view.state.tr;
@@ -437,7 +436,6 @@ export const addMentionToEditor = (
});
tr.insert(from, atMentionNode);
}
- console.log("yo", mention);
// Add a space after the mention
tr.insertText(" ", from + 1);
diff --git a/app/api/rpc/[command]/get_document_interactions.ts b/app/api/rpc/[command]/get_document_interactions.ts
new file mode 100644
index 00000000..da5bf774
--- /dev/null
+++ b/app/api/rpc/[command]/get_document_interactions.ts
@@ -0,0 +1,90 @@
+import { z } from "zod";
+import { makeRoute } from "../lib";
+import type { Env } from "./route";
+import { getConstellationBacklinks } from "app/lish/[did]/[publication]/[rkey]/getPostPageData";
+import { getDocumentURL } from "app/lish/createPub/getPublicationURL";
+import {
+ normalizeDocumentRecord,
+ normalizePublicationRecord,
+} from "src/utils/normalizeRecords";
+
+export const get_document_interactions = makeRoute({
+ route: "get_document_interactions",
+ input: z.object({
+ document_uri: z.string(),
+ }),
+ handler: async (
+ { document_uri },
+ { supabase }: Pick
,
+ ) => {
+ let { data: document } = await supabase
+ .from("documents")
+ .select(
+ `
+ data,
+ uri,
+ comments_on_documents(*, bsky_profiles(*)),
+ document_mentions_in_bsky(*),
+ documents_in_publications(publications(*))
+ `,
+ )
+ .eq("uri", document_uri)
+ .limit(1)
+ .single();
+
+ if (!document) {
+ return { comments: [], quotesAndMentions: [], totalMentionsCount: 0 };
+ }
+
+ const normalizedData = normalizeDocumentRecord(
+ document.data,
+ document.uri,
+ );
+
+ const pub = document.documents_in_publications?.[0]?.publications;
+ const normalizedPubRecord = pub
+ ? normalizePublicationRecord(pub.record)
+ : null;
+
+ // Compute document URL for constellation lookup
+ let absoluteUrl = "";
+ if (normalizedData) {
+ const postUrl = getDocumentURL(
+ normalizedData,
+ document.uri,
+ normalizedPubRecord,
+ );
+ absoluteUrl = postUrl.startsWith("/")
+ ? `https://leaflet.pub${postUrl}`
+ : postUrl;
+ }
+
+ // Fetch constellation backlinks
+ const constellationBacklinks = absoluteUrl
+ ? await getConstellationBacklinks(absoluteUrl)
+ : [];
+
+ // Deduplicate constellation backlinks internally
+ const uniqueBacklinks = Array.from(
+ new Map(constellationBacklinks.map((b) => [b.uri, b])).values(),
+ );
+
+ // Combine DB mentions and constellation backlinks, deduplicating by URI
+ const dbMentionUris = new Set(
+ document.document_mentions_in_bsky.map((m) => m.uri),
+ );
+ const quotesAndMentions: { uri: string; link?: string }[] = [
+ ...document.document_mentions_in_bsky.map((m) => ({
+ uri: m.uri,
+ link: m.link,
+ })),
+ ...uniqueBacklinks.filter((b) => !dbMentionUris.has(b.uri)),
+ ];
+
+ return {
+ comments: document.comments_on_documents,
+ quotesAndMentions,
+ totalMentionsCount: quotesAndMentions.length,
+ };
+ },
+});
diff --git a/app/api/rpc/[command]/get_hot_feed.ts b/app/api/rpc/[command]/get_hot_feed.ts
new file mode 100644
index 00000000..c7811086
--- /dev/null
+++ b/app/api/rpc/[command]/get_hot_feed.ts
@@ -0,0 +1,17 @@
+import { z } from "zod";
+import { makeRoute } from "../lib";
+import type { Env } from "./route";
+import { getHotFeed } from "app/(home-pages)/reader/getHotFeed";
+import type { Post } from "app/(home-pages)/reader/getReaderFeed";
+
+export type GetHotFeedReturnType = Awaited<
+ ReturnType<(typeof get_hot_feed)["handler"]>
+>;
+
+export const get_hot_feed = makeRoute({
+ route: "get_hot_feed",
+ input: z.object({}),
+ handler: async ({}, {}: Pick) => {
+ return await getHotFeed();
+ },
+});
diff --git a/app/api/rpc/[command]/route.ts b/app/api/rpc/[command]/route.ts
index c6ba128e..ebb2af14 100644
--- a/app/api/rpc/[command]/route.ts
+++ b/app/api/rpc/[command]/route.ts
@@ -15,6 +15,8 @@ import { search_publication_names } from "./search_publication_names";
import { search_publication_documents } from "./search_publication_documents";
import { get_profile_data } from "./get_profile_data";
import { get_user_recommendations } from "./get_user_recommendations";
+import { get_hot_feed } from "./get_hot_feed";
+import { get_document_interactions } from "./get_document_interactions";
let supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_API_URL as string,
@@ -43,6 +45,8 @@ let Routes = [
search_publication_documents,
get_profile_data,
get_user_recommendations,
+ get_hot_feed,
+ get_document_interactions,
];
export async function POST(
req: Request,
diff --git a/app/api/update-nav-state/route.ts b/app/api/update-nav-state/route.ts
new file mode 100644
index 00000000..3e496e82
--- /dev/null
+++ b/app/api/update-nav-state/route.ts
@@ -0,0 +1,16 @@
+import { NextRequest, NextResponse } from "next/server";
+
+export async function POST(request: NextRequest) {
+ const { state } = (await request.json()) as { state: string };
+ if (state !== "home" && state !== "reader") {
+ return NextResponse.json({ error: "Invalid state" }, { status: 400 });
+ }
+
+ const response = NextResponse.json({ ok: true });
+ response.cookies.set("nav-state", state, {
+ path: "/",
+ sameSite: "lax",
+ maxAge: 60 * 60 * 24 * 365,
+ });
+ return response;
+}
diff --git a/app/lish/Subscribe.tsx b/app/lish/Subscribe.tsx
index eb2e1411..7c443496 100644
--- a/app/lish/Subscribe.tsx
+++ b/app/lish/Subscribe.tsx
@@ -24,8 +24,10 @@ import { useSearchParams } from "next/navigation";
import LoginForm from "app/login/LoginForm";
import { RSSSmall } from "components/Icons/RSSSmall";
import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError";
+import { RSSTiny } from "components/Icons/RSSTiny";
export const SubscribeWithBluesky = (props: {
+ compact?: boolean;
pubName: string;
pub_uri: string;
base_url: string;
@@ -36,8 +38,12 @@ export const SubscribeWithBluesky = (props: {
let [successModalOpen, setSuccessModalOpen] = useState(
!!searchParams.has("showSubscribeSuccess"),
);
+ let [localSubscribeState, setLocalSubscribeState] = useState<
+ "subscribed" | "unsubscribed"
+ >("subscribed");
let subscribed =
identity?.atp_did &&
+ localSubscribeState !== "unsubscribed" &&
props.subscribers.find((s) => s.identity === identity.atp_did);
if (successModalOpen)
@@ -48,12 +54,19 @@ export const SubscribeWithBluesky = (props: {
/>
);
if (subscribed) {
- return ;
+ return (
+ setLocalSubscribeState("unsubscribed")}
+ />
+ );
}
return (
setLocalSubscribeState("subscribed")}
+ compact={props.compact}
pub_uri={props.pub_uri}
setSuccessModalOpen={setSuccessModalOpen}
/>
@@ -63,7 +76,11 @@ export const SubscribeWithBluesky = (props: {
target="_blank"
aria-label="Subscribe to RSS"
>
-
+ {props.compact ? (
+
+ ) : (
+
+ )}
@@ -74,6 +91,8 @@ export const ManageSubscription = (props: {
pub_uri: string;
subscribers: { identity: string }[];
base_url: string;
+ compact?: boolean;
+ onUnsubscribe?: () => void;
}) => {
let toaster = useToaster();
let [hasFeed] = useState(false);
@@ -83,16 +102,21 @@ export const ManageSubscription = (props: {
content: "You unsubscribed.",
type: "success",
});
+ props.onUnsubscribe?.();
}, null);
return (
+
Manage Subscription
}
>
-
+
Update Options
{!hasFeed && (
@@ -102,7 +126,7 @@ export const ManageSubscription = (props: {
className=" place-self-center"
>
- View Bluesky Custom Feed
+ Bluesky Custom Feed
)}
@@ -121,8 +145,12 @@ export const ManageSubscription = (props: {
@@ -133,6 +161,8 @@ export const ManageSubscription = (props: {
let BlueskySubscribeButton = (props: {
pub_uri: string;
setSuccessModalOpen: (open: boolean) => void;
+ compact?: boolean;
+ setLocalSubscribeState: () => void;
}) => {
let { identity } = useIdentityData();
let toaster = useToaster();
@@ -155,6 +185,7 @@ let BlueskySubscribeButton = (props: {
props.setSuccessModalOpen(true);
}
toaster({ content:
You're Subscribed!
, type: "success" });
+ props.setLocalSubscribeState();
}, null);
let [isClient, setIsClient] = useState(false);
@@ -166,8 +197,12 @@ let BlueskySubscribeButton = (props: {
return (
+
Subscribe with Bluesky
}
@@ -190,7 +225,10 @@ let BlueskySubscribeButton = (props: {
action={subscribe}
className="place-self-center flex flex-row gap-1"
>
-
+
{subscribePending ? (
) : (
diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
index 34608196..65a72f7a 100644
--- a/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
+++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
@@ -29,6 +29,7 @@ export function CommentsDrawerContent(props: {
document_uri: string;
comments: Comment[];
pageId?: string;
+ noCommentBox?: boolean;
}) {
let { identity } = useIdentityData();
let { localComments } = useInteractionState(props.document_uri);
@@ -55,26 +56,19 @@ export function CommentsDrawerContent(props: {
id={"commentsDrawer"}
className="flex flex-col gap-2 relative text-sm text-secondary"
>
-
-
Comments
-
- setInteractionState(props.document_uri, { drawerOpen: false })
- }
- >
-
-
-
- {identity?.atp_did ? (
-
- ) : (
-
- Connect a Bluesky account to comment
-
-
+ {!props.noCommentBox && (
+ <>
+ {identity?.atp_did ? (
+
+ ) : (
+
+ Connect a Bluesky account to comment
+
+
+ )}
+
+ >
)}
-
{comments
.sort((a, b) => {
diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx
index b690925b..fe4de395 100644
--- a/app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx
+++ b/app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx
@@ -1,12 +1,17 @@
"use client";
import { Media } from "components/Media";
import { MentionsDrawerContent } from "./Quotes";
-import { InteractionState, useInteractionState } from "./Interactions";
+import {
+ InteractionState,
+ setInteractionState,
+ useInteractionState,
+} from "./Interactions";
import { Json } from "supabase/database.types";
import { Comment, CommentsDrawerContent } from "./Comments";
import { useSearchParams } from "next/navigation";
import { SandwichSpacer } from "components/LeafletLayout";
import { decodeQuotePosition } from "../quotePosition";
+import { CloseTiny } from "components/Icons/CloseTiny";
export const InteractionDrawer = (props: {
showPageBackground: boolean | undefined;
@@ -39,19 +44,44 @@ export const InteractionDrawer = (props: {
{drawer.drawer === "quotes" ? (
-
+ <>
+
+ setInteractionState(props.document_uri, { drawerOpen: false })
+ }
+ >
+
+
+
+ >
) : (
-
+ <>
+
+
Comments
+
+ setInteractionState(props.document_uri, {
+ drawerOpen: false,
+ })
+ }
+ >
+
+
+
+
+ >
)}
diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx
index a1190b32..1c39cde9 100644
--- a/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx
+++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx
@@ -86,13 +86,7 @@ export const MentionsDrawerContent = (props: {
});
return (
-
-
setInteractionState(document_uri, { drawerOpen: false })}
- >
-
-
+ <>
{props.quotesAndMentions.length === 0 ? (
no quotes yet!
@@ -160,7 +154,7 @@ export const MentionsDrawerContent = (props: {
)}
)}
-
+ >
);
};
diff --git a/app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx b/app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx
index e8951d4f..180987bb 100644
--- a/app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx
+++ b/app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx
@@ -298,7 +298,6 @@ const ReplyPost = (props: {
e.stopPropagation();
props.toggleCollapsed(parentPostUri);
- console.log("reply clicked");
}}
/>
>
diff --git a/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts b/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
index 49eb500b..c4d4a818 100644
--- a/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
+++ b/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
@@ -3,8 +3,6 @@ import { AtUri } from "@atproto/syntax";
import {
normalizeDocumentRecord,
normalizePublicationRecord,
- type NormalizedDocument,
- type NormalizedPublication,
} from "src/utils/normalizeRecords";
import { PubLeafletPublication, SiteStandardPublication } from "lexicons/api";
import { documentUriFilter } from "src/utils/uriHelpers";
@@ -145,8 +143,6 @@ export async function getPostPageData(did: string, rkey: string) {
publication_subscriptions: rawPub.publication_subscriptions || [],
}
: null;
-
- // Get recommends count from the aggregated query result
const recommendsCount = document.recommends_on_documents?.[0]?.count ?? 0;
return {
diff --git a/app/lish/[did]/[publication]/dashboard/Actions.tsx b/app/lish/[did]/[publication]/dashboard/Actions.tsx
index ba945f35..92a7b9de 100644
--- a/app/lish/[did]/[publication]/dashboard/Actions.tsx
+++ b/app/lish/[did]/[publication]/dashboard/Actions.tsx
@@ -38,6 +38,7 @@ function PublicationShareButton() {
icon=
label="Share"
onClick={() => {}}
+ smallOnMobile
/>
}
>
diff --git a/app/lish/[did]/[publication]/dashboard/DraftList.tsx b/app/lish/[did]/[publication]/dashboard/DraftList.tsx
index 5ef24543..49ff4514 100644
--- a/app/lish/[did]/[publication]/dashboard/DraftList.tsx
+++ b/app/lish/[did]/[publication]/dashboard/DraftList.tsx
@@ -1,6 +1,5 @@
"use client";
-import { NewDraftSecondaryButton } from "./NewDraftButton";
import React from "react";
import {
usePublicationData,
@@ -22,12 +21,7 @@ export function DraftList(props: {
if (!normalizedPubRecord) return null;
return (
-
-
-
+
);
}
diff --git a/app/lish/[did]/[publication]/dashboard/NewDraftButton.tsx b/app/lish/[did]/[publication]/dashboard/NewDraftButton.tsx
index aedf8c79..a24652ba 100644
--- a/app/lish/[did]/[publication]/dashboard/NewDraftButton.tsx
+++ b/app/lish/[did]/[publication]/dashboard/NewDraftButton.tsx
@@ -16,29 +16,9 @@ export function NewDraftActionButton(props: { publication: string }) {
let newLeaflet = await createPublicationDraft(props.publication);
router.push(`/${newLeaflet}`);
}}
- icon=
- label="New"
+ icon=
+ smallOnMobile
+ label="Draft"
/>
);
}
-
-export function NewDraftSecondaryButton(props: {
- publication: string;
- fullWidth?: boolean;
-}) {
- let router = useRouter();
-
- return (
-
{
- let newLeaflet = await createPublicationDraft(props.publication);
- router.push(`/${newLeaflet}`);
- }}
- >
-
- New Draft
-
- );
-}
diff --git a/app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx b/app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx
index 184f4e25..0821c232 100644
--- a/app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx
+++ b/app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx
@@ -76,6 +76,7 @@ export default function PublicationDashboard({
actions={
}
currentPage="pub"
publication={publication.uri}
+ pageTitle={record.name}
/>
);
}
diff --git a/app/lish/[did]/[publication]/dashboard/PublicationSWRProvider.tsx b/app/lish/[did]/[publication]/dashboard/PublicationSWRProvider.tsx
index 4ba0fd87..65217a34 100644
--- a/app/lish/[did]/[publication]/dashboard/PublicationSWRProvider.tsx
+++ b/app/lish/[did]/[publication]/dashboard/PublicationSWRProvider.tsx
@@ -12,7 +12,8 @@ import {
// Derive all types from the RPC return type
export type PublicationData = GetPublicationDataReturnType["result"];
-export type PublishedDocument = NonNullable
["documents"][number];
+export type PublishedDocument =
+ NonNullable["documents"][number];
export type PublicationDraft = NonNullable["drafts"][number];
const PublicationContext = createContext({ name: "", did: "" });
@@ -24,7 +25,6 @@ export function PublicationSWRDataProvider(props: {
}) {
let key = `publication-data-${props.publication_did}-${props.publication_rkey}`;
useEffect(() => {
- console.log("UPDATING");
mutate(key, props.publication_data);
}, [props.publication_data]);
return (
@@ -64,7 +64,7 @@ export function useNormalizedPublicationRecord(): NormalizedPublication | null {
const { data } = usePublicationData();
return useMemo(
() => normalizePublicationRecord(data?.publication?.record),
- [data?.publication?.record]
+ [data?.publication?.record],
);
}
diff --git a/app/lish/[did]/[publication]/dashboard/settings/PublicationSettings.tsx b/app/lish/[did]/[publication]/dashboard/settings/PublicationSettings.tsx
index 0b85e548..92f72613 100644
--- a/app/lish/[did]/[publication]/dashboard/settings/PublicationSettings.tsx
+++ b/app/lish/[did]/[publication]/dashboard/settings/PublicationSettings.tsx
@@ -34,6 +34,7 @@ export function PublicationSettingsButton(props: { publication: string }) {
id="pub-settings-button"
icon=
label="Settings"
+ smallOnMobile
/>
}
>
diff --git a/app/route.ts b/app/route.ts
deleted file mode 100644
index 9bc9116d..00000000
--- a/app/route.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { createNewLeaflet } from "actions/createNewLeaflet";
-import { cookies } from "next/headers";
-import { redirect } from "next/navigation";
-
-export const preferredRegion = ["sfo1"];
-export const dynamic = "force-dynamic";
-export const fetchCache = "force-no-store";
-
-export async function GET() {
- redirect("/home");
-}
diff --git a/components/ActionBar/ActionButton.tsx b/components/ActionBar/ActionButton.tsx
index d093d8ff..6dc14129 100644
--- a/components/ActionBar/ActionButton.tsx
+++ b/components/ActionBar/ActionButton.tsx
@@ -19,6 +19,7 @@ export const ActionButton = forwardRef<
className?: string;
subtext?: string;
labelOnMobile?: boolean;
+ smallOnMobile?: boolean;
z?: boolean;
}
>((_props, ref) => {
@@ -30,6 +31,7 @@ export const ActionButton = forwardRef<
secondary,
nav,
labelOnMobile,
+ smallOnMobile,
subtext,
className,
...buttonProps
@@ -55,24 +57,26 @@ export const ActionButton = forwardRef<
className={`
actionButton relative font-bold
rounded-md border
- flex gap-2 items-start sm:justify-start justify-center
- p-1 sm:mx-0
- ${showLabelOnMobile && !secondary ? "w-full" : "sm:w-full w-max"}
+ flex gap-2 items-center justify-start
+ sm:w-full sm:max-w-full p-1
+ w-max
+ ${smallOnMobile && "sm:text-base text-sm py-0! sm:py-1! sm:h-fit h-6"}
${
primary
- ? "bg-accent-1 border-accent-1 text-accent-2 transparent-outline sm:hover:outline-accent-contrast focus:outline-accent-1 outline-offset-1 mx-1 first:ml-0"
+ ? "bg-accent-1 border-accent-1 text-accent-2 transparent-outline sm:hover:outline-accent-contrast focus:outline-accent-1 outline-offset-1 "
: secondary
- ? " bg-bg-page border-accent-contrast text-accent-contrast transparent-outline focus:outline-accent-contrast sm:hover:outline-accent-contrast outline-offset-1 mx-1 first:ml-0"
+ ? " bg-bg-page border-accent-contrast text-accent-contrast transparent-outline focus:outline-accent-contrast sm:hover:outline-accent-contrast outline-offset-1"
: nav
- ? "border-transparent text-secondary sm:hover:border-border justify-start!"
+ ? "border-transparent text-secondary sm:hover:border-border justify-start! max-w-full"
: "border-transparent text-accent-contrast sm:hover:border-accent-contrast"
}
${className}
`}
>
- {icon}
+ {icon}
{label}
{subtext && (
diff --git a/components/ActionBar/DesktopNavigation.tsx b/components/ActionBar/DesktopNavigation.tsx
new file mode 100644
index 00000000..65201200
--- /dev/null
+++ b/components/ActionBar/DesktopNavigation.tsx
@@ -0,0 +1,66 @@
+import { useIdentityData } from "components/IdentityProvider";
+import {
+ navPages,
+ HomeButton,
+ ReaderButton,
+ NotificationButton,
+ WriterButton,
+} from "./NavigationButtons";
+import { PublicationButtons } from "./Publications";
+import { Sidebar } from "./Sidebar";
+import { LoginActionButton, LoginButton } from "components/LoginButton";
+import { ProfileButton } from "./ProfileButton";
+
+export const DesktopNavigation = (props: {
+ currentPage: navPages;
+ publication?: string;
+}) => {
+ let { identity } = useIdentityData();
+ let thisPublication = identity?.publications?.find(
+ (pub) => pub.uri === props.publication,
+ );
+
+ let currentlyWriter =
+ props.currentPage === "home" ||
+ props.currentPage === "looseleafs" ||
+ props.currentPage === "pub";
+ return (
+
+
+ {identity?.atp_did ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+
+
+
+
+ {currentlyWriter && (
+ <>
+
+
+ >
+ )}
+
+
+ );
+};
diff --git a/components/ActionBar/Footer.tsx b/components/ActionBar/Footer.tsx
index 67d191ee..bb105c3e 100644
--- a/components/ActionBar/Footer.tsx
+++ b/components/ActionBar/Footer.tsx
@@ -8,7 +8,7 @@ export function Footer(props: { children?: React.ReactNode }) {
actionFooter touch-none shrink-0
w-full z-10
px-2 pt-1 pwa-padding-bottom
- flex justify-start gap-1
+ flex justify-between
h-[calc(38px+var(--safe-padding-bottom))]
bg-[rgba(var(--bg-page),0.5)] border-top border-bg-page`}
>
diff --git a/components/ActionBar/MobileNavigation.tsx b/components/ActionBar/MobileNavigation.tsx
new file mode 100644
index 00000000..bcf52df7
--- /dev/null
+++ b/components/ActionBar/MobileNavigation.tsx
@@ -0,0 +1,63 @@
+import { useIdentityData } from "components/IdentityProvider";
+import { Separator } from "components/Layout";
+import {
+ navPages,
+ NotificationButton,
+ ReaderButton,
+ WriterButton,
+} from "./NavigationButtons";
+import { PublicationNavigation } from "./PublicationNavigation";
+import { LoginActionButton } from "components/LoginButton";
+import { ProfileButton } from "./ProfileButton";
+
+export const MobileNavigation = (props: {
+ currentPage: navPages;
+ currentPublicationUri?: string;
+ currentProfileDid?: string;
+}) => {
+ let { identity } = useIdentityData();
+
+ let compactOnMobile =
+ props.currentPage === "home" ||
+ props.currentPage === "looseleafs" ||
+ props.currentPage === "pub";
+
+ return (
+
+
+
+
+
+ {compactOnMobile && (
+ <>
+
+ >
+ )}
+
+ {identity?.atp_did ? (
+
+ ) : (
+
+ )}
+
+ );
+};
diff --git a/components/ActionBar/Navigation.tsx b/components/ActionBar/Navigation.tsx
deleted file mode 100644
index 636dc851..00000000
--- a/components/ActionBar/Navigation.tsx
+++ /dev/null
@@ -1,179 +0,0 @@
-import { HomeSmall } from "components/Icons/HomeSmall";
-import { ActionButton } from "./ActionButton";
-import { Sidebar } from "./Sidebar";
-import { useIdentityData } from "components/IdentityProvider";
-import Link from "next/link";
-import { DiscoverSmall } from "components/Icons/DiscoverSmall";
-import { PublicationButtons } from "./Publications";
-import { Popover } from "components/Popover";
-import { MenuSmall } from "components/Icons/MenuSmall";
-import {
- ReaderReadSmall,
- ReaderUnreadSmall,
-} from "components/Icons/ReaderSmall";
-import {
- NotificationsReadSmall,
- NotificationsUnreadSmall,
-} from "components/Icons/NotificationSmall";
-import { SpeedyLink } from "components/SpeedyLink";
-import { Separator } from "components/Layout";
-
-export type navPages =
- | "home"
- | "reader"
- | "pub"
- | "discover"
- | "notifications"
- | "looseleafs"
- | "tag"
- | "profile";
-
-export const DesktopNavigation = (props: {
- currentPage: navPages;
- publication?: string;
-}) => {
- let { identity } = useIdentityData();
- return (
-
-
-
-
- {identity?.atp_did && (
-
-
-
- )}
-
- );
-};
-
-export const MobileNavigation = (props: {
- currentPage: navPages;
- publication?: string;
-}) => {
- let { identity } = useIdentityData();
-
- return (
-
-
e.preventDefault()}
- asChild
- className="px-2! !max-w-[256px]"
- trigger={
-
-
-
- }
- >
-
-
- {identity?.atp_did && (
- <>
-
-
- >
- )}
-
- );
-};
-
-const NavigationOptions = (props: {
- currentPage: navPages;
- publication?: string;
- isMobile?: boolean;
-}) => {
- let { identity } = useIdentityData();
- let thisPublication = identity?.publications?.find(
- (pub) => pub.uri === props.publication,
- );
- return (
- <>
-
-
-
-
-
-
- >
- );
-};
-
-const HomeButton = (props: { current?: boolean }) => {
- return (
-
- }
- label="Home"
- className={props.current ? "bg-bg-page! border-border-light!" : ""}
- />
-
- );
-};
-
-const ReaderButton = (props: { current?: boolean; subs: boolean }) => {
- if (!props.subs) return;
- return (
-
- }
- label="Reader"
- className={props.current ? "bg-bg-page! border-border-light!" : ""}
- />
-
- );
-};
-
-const DiscoverButton = (props: { current?: boolean }) => {
- return (
-
-
}
- label="Discover"
- subtext=""
- className={props.current ? "bg-bg-page! border-border-light!" : ""}
- />
-
- );
-};
-
-export function NotificationButton(props: { current?: boolean }) {
- let { identity } = useIdentityData();
- let unreads = identity?.notifications[0]?.count;
-
- return (
-
-
- ) : (
-
- )
- }
- label="Notifications"
- className={`${props.current ? "bg-bg-page! border-border-light!" : ""} ${unreads ? "text-accent-contrast!" : ""}`}
- />
-
- );
-}
diff --git a/components/ActionBar/NavigationButtons.tsx b/components/ActionBar/NavigationButtons.tsx
new file mode 100644
index 00000000..71b83f77
--- /dev/null
+++ b/components/ActionBar/NavigationButtons.tsx
@@ -0,0 +1,101 @@
+import { HomeSmall } from "components/Icons/HomeSmall";
+import { ActionButton } from "./ActionButton";
+import { useIdentityData } from "components/IdentityProvider";
+import { PublicationButtons } from "./Publications";
+import { ReaderUnreadSmall } from "components/Icons/ReaderSmall";
+import {
+ NotificationsReadSmall,
+ NotificationsUnreadSmall,
+} from "components/Icons/NotificationSmall";
+import { SpeedyLink } from "components/SpeedyLink";
+import { Popover } from "components/Popover";
+import { WriterSmall } from "components/Icons/WriterSmall";
+export type navPages =
+ | "home"
+ | "reader"
+ | "pub"
+ | "notifications"
+ | "looseleafs"
+ | "tag"
+ | "profile"
+ | "discover";
+
+export const HomeButton = (props: {
+ current?: boolean;
+ className?: string;
+}) => {
+ return (
+
+ }
+ label="Home"
+ className={`${props.current ? "bg-bg-page! border-border-light!" : ""} w-full! ${props.className}`}
+ />
+
+ );
+};
+
+export const WriterButton = (props: {
+ currentPage: navPages;
+ currentPubUri?: string;
+ compactOnMobile?: boolean;
+}) => {
+ let current =
+ props.currentPage === "home" ||
+ props.currentPage === "looseleafs" ||
+ props.currentPage === "pub";
+
+ return (
+
+ }
+ label="Write"
+ className={`${current ? "bg-bg-page! border-border-light!" : ""}`}
+ />
+
+ );
+};
+
+export const ReaderButton = (props: {
+ current?: boolean;
+ subs: boolean;
+ compactOnMobile?: boolean;
+}) => {
+ return (
+
+ }
+ label="Read"
+ className={props.current ? "bg-bg-page! border-border-light!" : ""}
+ />
+
+ );
+};
+
+export function NotificationButton(props: { current?: boolean }) {
+ let { identity } = useIdentityData();
+ let unreads = identity?.notifications[0]?.count;
+
+ return (
+
+
+ ) : (
+
+ )
+ }
+ label="Notifications"
+ className={`${props.current ? "bg-bg-page! border-border-light!" : ""} ${unreads ? "text-accent-contrast!" : ""}`}
+ />
+
+ );
+}
diff --git a/components/ActionBar/ProfileButton.tsx b/components/ActionBar/ProfileButton.tsx
new file mode 100644
index 00000000..218101c2
--- /dev/null
+++ b/components/ActionBar/ProfileButton.tsx
@@ -0,0 +1,61 @@
+import { Avatar } from "components/Avatar";
+import { ActionButton } from "./ActionButton";
+import { useIdentityData } from "components/IdentityProvider";
+import { AccountSmall } from "components/Icons/AccountSmall";
+import { useRecordFromDid } from "src/utils/useRecordFromDid";
+import { Menu, MenuItem } from "components/Menu";
+import { useIsMobile } from "src/hooks/isMobile";
+import { LogoutSmall } from "components/Icons/LogoutSmall";
+import { mutate } from "swr";
+import { SpeedyLink } from "components/SpeedyLink";
+
+export const ProfileButton = () => {
+ let { identity } = useIdentityData();
+ let { data: record } = useRecordFromDid(identity?.atp_did);
+ let isMobile = useIsMobile();
+
+ return (
+
+ ) : (
+
+ )
+ }
+ label={record ? record.displayName || record.handle : "Account"}
+ className={`w-full`}
+ />
+ }
+ >
+ {record && (
+ <>
+
+ {}}>View Profile
+
+
+
+ >
+ )}
+
{
+ await fetch("/api/auth/logout");
+ mutate("identity", null);
+ }}
+ >
+
+ Log Out
+
+
+ );
+};
diff --git a/components/ActionBar/PublicationNavigation.tsx b/components/ActionBar/PublicationNavigation.tsx
new file mode 100644
index 00000000..60fbf6c0
--- /dev/null
+++ b/components/ActionBar/PublicationNavigation.tsx
@@ -0,0 +1,148 @@
+"use client";
+import { useIdentityData } from "components/IdentityProvider";
+import { getBasePublicationURL } from "app/lish/createPub/getPublicationURL";
+import {
+ normalizePublicationRecord,
+ type NormalizedPublication,
+} from "src/utils/normalizeRecords";
+import { SpeedyLink } from "components/SpeedyLink";
+import { Popover } from "components/Popover";
+import { ButtonPrimary } from "components/Buttons";
+import { LooseLeafSmall } from "components/Icons/LooseleafSmall";
+import { HomeButton, type navPages } from "./NavigationButtons";
+import { HomeSmall } from "components/Icons/HomeSmall";
+import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny";
+import { PubIcon, PublicationButtons } from "./Publications";
+import { HomeTiny } from "components/Icons/HomeTiny";
+import { LooseleafTiny } from "components/Icons/LooseleafTiny";
+import { Separator } from "components/Layout";
+import { Menu, MenuItem } from "components/Menu";
+import { AddTiny } from "components/Icons/AddTiny";
+
+export const PublicationNavigation = (props: {
+ currentPage: navPages;
+ currentPubUri?: string;
+}) => {
+ let { identity } = useIdentityData();
+
+ if (!identity) return;
+
+ let hasLooseleafs = !!identity?.permission_token_on_homepage.find(
+ (f) =>
+ f.permission_tokens.leaflets_to_documents &&
+ f.permission_tokens.leaflets_to_documents[0]?.document,
+ );
+
+ let pubCount = identity?.publications.length ?? 0;
+ let onlyOnePub = pubCount === 1 && hasLooseleafs;
+ let onlyLooseleafs = pubCount === 0 && hasLooseleafs;
+ let className =
+ "font-bold text-secondary flex gap-2 items-center grow min-w-0 text-sm h-[34px] px-2 accent-container";
+
+ // if not publications or looseleafs
+ if (!identity.publications && !hasLooseleafs) {
+ return (
+
+
+ Create a Publication!
+
+
+ );
+ }
+
+ switch (props.currentPage) {
+ case "looseleafs":
+ case "pub":
+ if (onlyLooseleafs || onlyOnePub)
+ return (
+ <>
+
+
+ Home
+
+ >
+ );
+ break;
+ case "home": {
+ if (onlyLooseleafs || onlyOnePub) {
+ let pub = identity.publications[0];
+ return (
+
+
}>
+
+ {}}>
+
+ Create New Publication
+
+
+
+
+ {onlyLooseleafs ? (
+
+ Looseleafs
+
+ ) : (
+
+
+ {pub.name}
+
+ )}
+
+ );
+ }
+ break;
+ }
+ }
+
+ return (
+
+ ({
+ record: normalizePublicationRecord(pub.record),
+ uri: pub.uri,
+ }))}
+ />{" "}
+ Publications
+
+ }
+ className="pt-1 px-2!"
+ >
+
+
+
+
+ );
+};
+
+function PubIcons(props: {
+ publications: { record: NormalizedPublication | null; uri: string }[];
+}) {
+ if (props.publications.length < 1) return null;
+ return (
+
+ {props.publications.map((pub, index) => {
+ if (index <= 2)
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/components/ActionBar/Publications.tsx b/components/ActionBar/Publications.tsx
index 80b58eb1..7fae0df9 100644
--- a/components/ActionBar/Publications.tsx
+++ b/components/ActionBar/Publications.tsx
@@ -19,11 +19,13 @@ import { ButtonSecondary } from "components/Buttons";
import { useIsMobile } from "src/hooks/isMobile";
import { useState } from "react";
import { LooseLeafSmall } from "components/Icons/LooseleafSmall";
-import { navPages } from "./Navigation";
+import { type navPages } from "./NavigationButtons";
export const PublicationButtons = (props: {
currentPage: navPages;
currentPubUri: string | undefined;
+ className?: string;
+ optionClassName?: string;
}) => {
let { identity } = useIdentityData();
let hasLooseleafs = !!identity?.permission_token_on_homepage.find(
@@ -38,12 +40,14 @@ export const PublicationButtons = (props: {
return ;
return (
-
+
{hasLooseleafs && (
<>
{/*TODO How should i get if this is the current page or not?
theres not "pub" to check the uri for. Do i need to add it as an option to NavPages? thats kinda annoying*/}
@@ -51,14 +55,14 @@ export const PublicationButtons = (props: {
label="Looseleafs"
icon={ }
nav
- className={
+ className={`w-full! ${
props.currentPage === "looseleafs"
? "bg-bg-page! border-border!"
: ""
}
+ ${props.optionClassName}`}
/>
-
>
)}
@@ -86,9 +90,10 @@ export const PublicationButtons = (props: {
})}
- New
+
+ New Publication
);
@@ -99,6 +104,7 @@ export const PublicationOption = (props: {
name: string;
record: Json;
current?: boolean;
+ className?: string;
}) => {
let record = normalizePublicationRecord(props.record);
if (!record) return;
@@ -106,13 +112,13 @@ export const PublicationOption = (props: {
return (
}
nav
- className={props.current ? "bg-bg-page! border-border!" : ""}
+ className={`w-full! ${props.current ? "bg-bg-page! border-border!" : ""} ${props.className}`}
/>
);
@@ -198,13 +204,14 @@ export const PubListEmptyContent = (props: { compact?: boolean }) => {
export const PubIcon = (props: {
record: NormalizedPublication | null;
uri: string;
+ tiny?: boolean;
small?: boolean;
large?: boolean;
className?: string;
}) => {
if (!props.record) return null;
- let iconSizeClassName = `${props.small ? "w-4 h-4" : props.large ? "w-12 h-12" : "w-6 h-6"} rounded-full`;
+ let iconSizeClassName = `${props.tiny ? "w-4 h-4" : props.small ? "w-5 h-5" : props.large ? "w-12 h-12" : "w-6 h-6"} rounded-full`;
return props.record.icon ? (
{props.record?.name.slice(0, 1).toUpperCase()}
diff --git a/components/Blocks/BaseTextareaBlock.tsx b/components/Blocks/BaseTextareaBlock.tsx
index bff3ccd6..26290189 100644
--- a/components/Blocks/BaseTextareaBlock.tsx
+++ b/components/Blocks/BaseTextareaBlock.tsx
@@ -83,7 +83,6 @@ export function BaseTextareaBlock(props: BaseTextareaBlockProps) {
let block = props.block.nextBlock;
let coord = getCoordinatesInTextarea(e.currentTarget, selection);
- console.log(coord);
if (block) {
focusBlock(block, {
left: coord.left + e.currentTarget.getBoundingClientRect().left,
diff --git a/components/Icons/HomeTiny.tsx b/components/Icons/HomeTiny.tsx
new file mode 100644
index 00000000..3bf12a74
--- /dev/null
+++ b/components/Icons/HomeTiny.tsx
@@ -0,0 +1,21 @@
+import { Props } from "./Props";
+
+export const HomeTiny = (props: Props) => {
+ return (
+
+
+
+ );
+};
diff --git a/components/Icons/LooseleafTiny.tsx b/components/Icons/LooseleafTiny.tsx
new file mode 100644
index 00000000..00bd29ad
--- /dev/null
+++ b/components/Icons/LooseleafTiny.tsx
@@ -0,0 +1,19 @@
+import { Props } from "./Props";
+
+export const LooseleafTiny = (props: Props) => {
+ return (
+
+
+
+ );
+};
diff --git a/components/Icons/MoreOptionsVerticalTiny.tsx b/components/Icons/MoreOptionsVerticalTiny.tsx
index 7d0a09a8..55ef377a 100644
--- a/components/Icons/MoreOptionsVerticalTiny.tsx
+++ b/components/Icons/MoreOptionsVerticalTiny.tsx
@@ -8,6 +8,7 @@ export const MoreOptionsVerticalTiny = (props: Props) => {
viewBox="0 0 12 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
+ {...props}
>
{
};
export const NotificationsReadSmall = (props: Props) => {
- return (
-
-
-
- );
-};
-
-export const ReaderUnread = (props: Props) => {
- return (
-
-
-
- );
-};
-
-export const ReaderRead = (props: Props) => {
return (
{
{...props}
>
diff --git a/components/Icons/RSSTiny.tsx b/components/Icons/RSSTiny.tsx
new file mode 100644
index 00000000..9e0c24ab
--- /dev/null
+++ b/components/Icons/RSSTiny.tsx
@@ -0,0 +1,18 @@
+import { Props } from "./Props";
+export const RSSTiny = (props: Props) => {
+ return (
+
+
+
+ );
+};
diff --git a/components/Icons/ReaderSmall.tsx b/components/Icons/ReaderSmall.tsx
index 1be96367..606331b0 100644
--- a/components/Icons/ReaderSmall.tsx
+++ b/components/Icons/ReaderSmall.tsx
@@ -3,14 +3,15 @@ import { Props } from "./Props";
export const ReaderUnreadSmall = (props: Props) => {
return (
diff --git a/components/Icons/ShareTiny.tsx b/components/Icons/ShareTiny.tsx
new file mode 100644
index 00000000..7e3f6a7c
--- /dev/null
+++ b/components/Icons/ShareTiny.tsx
@@ -0,0 +1,19 @@
+import { Props } from "./Props";
+
+export const ShareTiny = (props: Props) => {
+ return (
+
+
+
+ );
+};
diff --git a/components/Icons/TagSmall.tsx b/components/Icons/TagSmall.tsx
new file mode 100644
index 00000000..5180884e
--- /dev/null
+++ b/components/Icons/TagSmall.tsx
@@ -0,0 +1,19 @@
+import { Props } from "./Props";
+
+export const TagSmall = (props: Props) => {
+ return (
+
+
+
+ );
+};
diff --git a/components/Icons/WriterSmall.tsx b/components/Icons/WriterSmall.tsx
new file mode 100644
index 00000000..4a78145e
--- /dev/null
+++ b/components/Icons/WriterSmall.tsx
@@ -0,0 +1,19 @@
+import { Props } from "./Props";
+
+export const WriterSmall = (props: Props) => {
+ return (
+
+
+
+ );
+};
diff --git a/components/InteractionsPreview.tsx b/components/InteractionsPreview.tsx
index 930e2b36..799c8663 100644
--- a/components/InteractionsPreview.tsx
+++ b/components/InteractionsPreview.tsx
@@ -96,7 +96,7 @@ export const InteractionPreview = (props: {
);
};
-const TagPopover = (props: { tags: string[] }) => {
+export const TagPopover = (props: { tags: string[] }) => {
return (
} label="Sign In" />
}
diff --git a/components/NavStateTracker.tsx b/components/NavStateTracker.tsx
new file mode 100644
index 00000000..4bdf6fb3
--- /dev/null
+++ b/components/NavStateTracker.tsx
@@ -0,0 +1,27 @@
+"use client";
+
+import { usePathname } from "next/navigation";
+import { useEffect, useRef } from "react";
+
+export function NavStateTracker() {
+ const pathname = usePathname();
+ const lastState = useRef(null);
+
+ useEffect(() => {
+ let state: string | null = null;
+ if (pathname === "/home") state = "home";
+ else if (pathname === "/reader" || pathname.startsWith("/reader/"))
+ state = "reader";
+
+ if (state && state !== lastState.current) {
+ lastState.current = state;
+ fetch("/api/update-nav-state", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ state }),
+ });
+ }
+ }, [pathname]);
+
+ return null;
+}
diff --git a/components/PageHeader.tsx b/components/PageHeader.tsx
index 446b24b2..0e61a9f2 100644
--- a/components/PageHeader.tsx
+++ b/components/PageHeader.tsx
@@ -29,7 +29,7 @@ export const Header = (props: { children: React.ReactNode }) => {
diff --git a/components/PageLayouts/DashboardLayout.tsx b/components/PageLayouts/DashboardLayout.tsx
index 5521275e..0ab1677f 100644
--- a/components/PageLayouts/DashboardLayout.tsx
+++ b/components/PageLayouts/DashboardLayout.tsx
@@ -4,12 +4,13 @@ import { useSearchParams } from "next/navigation";
import { Header } from "../PageHeader";
import { Footer } from "components/ActionBar/Footer";
import { Sidebar } from "components/ActionBar/Sidebar";
+import { DesktopNavigation } from "components/ActionBar/DesktopNavigation";
+
+import { MobileNavigation } from "components/ActionBar/MobileNavigation";
import {
- DesktopNavigation,
- MobileNavigation,
navPages,
NotificationButton,
-} from "components/ActionBar/Navigation";
+} from "components/ActionBar/NavigationButtons";
import { create } from "zustand";
import { Popover } from "components/Popover";
import { Checkbox } from "components/Checkbox";
@@ -26,6 +27,7 @@ import Link from "next/link";
import { ExternalLinkTiny } from "components/Icons/ExternalLinkTiny";
import { usePreserveScroll } from "src/hooks/usePreserveScroll";
import { Tab } from "components/Tab";
+import { PubIcon, PublicationButtons } from "components/ActionBar/Publications";
export type DashboardState = {
display?: "grid" | "list";
@@ -69,7 +71,7 @@ export const useDashboardStore = create
((set, get) => ({
},
}));
-const DashboardIdContext = createContext(null);
+export const DashboardIdContext = createContext(null);
export const useDashboardId = () => {
const id = useContext(DashboardIdContext);
@@ -138,7 +140,10 @@ export function DashboardLayout<
defaultTab: keyof T;
currentPage: navPages;
publication?: string;
- actions: React.ReactNode;
+ profileDid?: string;
+ actions?: React.ReactNode;
+ pageTitle?: string;
+ onTabHover?: (tabName: string) => void;
}) {
const searchParams = useSearchParams();
const tabParam = searchParams.get("tab");
@@ -165,6 +170,7 @@ export function DashboardLayout<
let [headerState, setHeaderState] = useState<"default" | "controls">(
"default",
);
+
return (
+ {props.pageTitle && (
+
+ )}
+
{Object.keys(props.tabs).length <= 1 && !controls ? null : (
<>
@@ -198,6 +208,8 @@ export function DashboardLayout<
name={t}
selected={t === tab}
onSelect={() => setTabWithUrl(t)}
+ onMouseEnter={() => props.onTabHover?.(t)}
+ onPointerDown={() => props.onTabHover?.(t)}
/>
);
})}
@@ -240,20 +252,31 @@ export function DashboardLayout<
- {props.actions && (
- <>
-
- {props.actions}
- >
- )}
);
}
+export const PageTitle = (props: {
+ pageTitle: string;
+ actions: React.ReactNode;
+}) => {
+ return (
+
+ {props.pageTitle}
+ {props.actions}
+ {/* {props.controls}
*/}
+
+ );
+};
+
export const HomeDashboardControls = (props: {
searchValue: string;
setSearchValueAction: (searchValue: string) => void;
@@ -447,7 +470,7 @@ const SearchInput = (props: {
className={`dashboardSearchInput
appearance-none! outline-hidden!
w-full min-w-0 text-primary relative pl-7 pr-1 -my-px
- border rounded-md border-transparent focus-within:border-border
+ border rounded-md border-border-light focus-within:border-border
bg-transparent ${props.hasBackgroundImage ? "focus-within:bg-bg-page" : "focus-within:bg-bg-leaflet"} `}
type="text"
id="pubName"
diff --git a/components/Pages/useHasBackgroundImage.ts b/components/Pages/useHasBackgroundImage.ts
new file mode 100644
index 00000000..19bdaeaa
--- /dev/null
+++ b/components/Pages/useHasBackgroundImage.ts
@@ -0,0 +1,5 @@
+import { useHasBackgroundImageContext } from "components/ThemeManager/ThemeProvider";
+
+export function useHasBackgroundImage(entityID?: string | null) {
+ return useHasBackgroundImageContext();
+}
diff --git a/components/Popover/index.tsx b/components/Popover/index.tsx
index fb987d3d..98b44eef 100644
--- a/components/Popover/index.tsx
+++ b/components/Popover/index.tsx
@@ -42,7 +42,7 @@ export const Popover = (props: {
{
let pubRecord = props.publication?.pubRecord as
@@ -38,6 +44,22 @@ export const PostListing = (props: Post) => {
let isStandalone = !pubRecord;
let theme = usePubTheme(pubRecord?.theme || postRecord?.theme, isStandalone);
let themeRecord = pubRecord?.theme || postRecord?.theme;
+ let elRef = useRef(null);
+ let [hasBackgroundImage, setHasBackgroundImage] = useState(false);
+
+ useEffect(() => {
+ if (!themeRecord?.backgroundImage?.image || !elRef.current) {
+ setHasBackgroundImage(false);
+ return;
+ }
+ let alpha = Number(
+ window
+ .getComputedStyle(elRef.current)
+ .getPropertyValue("--bg-page-alpha"),
+ );
+ setHasBackgroundImage(alpha < 0.7);
+ }, [themeRecord?.backgroundImage?.image]);
+
let backgroundImage =
themeRecord?.backgroundImage?.image?.ref && uri
? blobRefToSrc(themeRecord.backgroundImage.image.ref, new AtUri(uri).host)
@@ -50,9 +72,15 @@ export const PostListing = (props: Post) => {
? pubRecord?.theme?.showPageBackground
: postRecord.theme?.showPageBackground ?? true;
- let mergedPrefs = mergePreferences(postRecord?.preferences, pubRecord?.preferences);
+ let mergedPrefs = mergePreferences(
+ postRecord?.preferences,
+ pubRecord?.preferences,
+ );
- let quotes = props.documents.document_mentions_in_bsky?.[0]?.count || 0;
+ let quotes =
+ props.documents.mentionsCount ??
+ props.documents.document_mentions_in_bsky?.[0]?.count ??
+ 0;
let comments =
mergedPrefs.showComments === false
? 0
@@ -61,66 +89,89 @@ export const PostListing = (props: Post) => {
let tags = (postRecord?.tags as string[] | undefined) || [];
// For standalone posts, link directly to the document
- let postHref = getDocumentURL(postRecord, props.documents.uri, pubRecord);
+ let postUrl = getDocumentURL(postRecord, props.documents.uri, pubRecord);
return (
-
-
-
+
+
-
{postRecord.title}
-
-
- {postRecord.description}
-
-
- {props.publication && pubRecord && (
-
- )}
-
-
-
+ {postRecord.coverImage && (
+
+
+ )}
+
+
+ {postRecord.title}
+
+
+
+ {postRecord.description}
+
+
+ {props.publication && pubRecord && (
+
+ )}
+
+
+ {tags.length === 0 ? null :
}
+
+
+
+
+
+
-
+
);
};
@@ -128,34 +179,129 @@ const PubInfo = (props: {
href: string;
pubRecord: NormalizedPublication;
uri: string;
+ postRecord: NormalizedDocument;
}) => {
+ let isLeaflet = hasLeafletContent(props.postRecord);
+ let cleanUrl = props.pubRecord.url
+ ?.replace(/^https?:\/\//, "")
+ .replace(/^www\./, "");
+
return (
-
-
-
-
- {props.pubRecord.name}
-
+
+
+
+
+
+
{props.pubRecord.name}
+
+ {!isLeaflet && (
+
+ )}
+
);
};
-const PostInfo = (props: { publishedAt: string | undefined }) => {
+const PostDate = (props: { publishedAt: string | undefined }) => {
let localizedDate = useLocalizedDate(props.publishedAt || "", {
year: "numeric",
month: "short",
day: "numeric",
});
+ if (props.publishedAt) {
+ return
{localizedDate}
;
+ } else return null;
+};
+
+const Interactions = (props: {
+ quotesCount: number;
+ commentsCount: number;
+ recommendsCount: number;
+ tags?: string[];
+ postUrl: string;
+ showComments: boolean;
+ showMentions: boolean;
+ documentUri: string;
+ document: NormalizedDocument;
+}) => {
+ let setSelectedPostListing = useSelectedPostListing(
+ (s) => s.setSelectedPostListing,
+ );
+ let selectPostListing = (drawer: "quotes" | "comments") => {
+ setSelectedPostListing({
+ document_uri: props.documentUri,
+ document: props.document,
+ drawer,
+ });
+ };
+
return (
-
- {props.publishedAt && (
- <>
-
{localizedDate}
- >
- )}
+
+
+
+ {!props.showMentions || props.quotesCount === 0 ? null : (
+ selectPostListing("quotes")}
+ className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast text-tertiary"
+ >
+ {props.quotesCount}
+
+ )}
+ {!props.showComments || props.commentsCount === 0 ? null : (
+ selectPostListing("comments")}
+ className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast text-tertiary"
+ >
+ {props.commentsCount}
+
+ )}
+
);
};
+
+const Share = (props: { postUrl: string }) => {
+ let smoker = useSmoker();
+ return (
+
{
+ e.stopPropagation();
+ e.preventDefault();
+ let mouseX = e.clientX;
+ let mouseY = e.clientY;
+
+ if (!props.postUrl) return;
+ navigator.clipboard.writeText(
+ props.postUrl.includes("http")
+ ? props.postUrl
+ : `leaflet.pub/${props.postUrl}`,
+ );
+
+ smoker({
+ text: Copied Link! ,
+ position: {
+ y: mouseY,
+ x: mouseX,
+ },
+ });
+ }}
+ >
+ Share
+
+ );
+};
diff --git a/components/Tab.tsx b/components/Tab.tsx
index c0963e18..dc66e2e9 100644
--- a/components/Tab.tsx
+++ b/components/Tab.tsx
@@ -4,12 +4,16 @@ export const Tab = (props: {
name: string;
selected: boolean;
onSelect: () => void;
+ onMouseEnter?: () => void;
+ onPointerDown?: () => void;
href?: string;
}) => {
return (
props.onSelect()}
+ onMouseEnter={props.onMouseEnter}
+ onPointerDown={props.onPointerDown}
>
{props.name}
{props.href && }
diff --git a/components/Tags.tsx b/components/Tags.tsx
index d2266e9a..a5af994b 100644
--- a/components/Tags.tsx
+++ b/components/Tags.tsx
@@ -109,7 +109,6 @@ export const TagSearchInput = (props: {
}
function selectTag(tag: string) {
- console.log("selected " + tag);
props.setSelectedTags([...props.selectedTags, tag]);
clearTagInput();
}
diff --git a/components/ThemeManager/ThemeProvider.tsx b/components/ThemeManager/ThemeProvider.tsx
index 574dc018..b68e51b6 100644
--- a/components/ThemeManager/ThemeProvider.tsx
+++ b/components/ThemeManager/ThemeProvider.tsx
@@ -8,6 +8,13 @@ export const CardBorderHiddenContext = createContext(false);
export function useCardBorderHiddenContext() {
return useContext(CardBorderHiddenContext);
}
+
+// Context for hasBackgroundImage
+export const HasBackgroundImageContext = createContext(false);
+
+export function useHasBackgroundImageContext() {
+ return useContext(HasBackgroundImageContext);
+}
import {
colorToString,
useColorAttribute,
@@ -79,22 +86,24 @@ export function LeafletThemeProvider(props: {
return (
-
- {props.children}
-
+
+
+ {props.children}
+
+
);
}
diff --git a/src/replicache/mutations.ts b/src/replicache/mutations.ts
index 40db9f73..232d4249 100644
--- a/src/replicache/mutations.ts
+++ b/src/replicache/mutations.ts
@@ -666,7 +666,6 @@ const updatePublicationDraft: Mutation<{
} | null;
}> = async (args, ctx) => {
await ctx.runOnServer(async (serverCtx) => {
- console.log("updating");
const updates: {
description?: string;
title?: string;
diff --git a/src/useSelectedPostState.ts b/src/useSelectedPostState.ts
new file mode 100644
index 00000000..88b06700
--- /dev/null
+++ b/src/useSelectedPostState.ts
@@ -0,0 +1,16 @@
+import { create } from "zustand";
+import type { NormalizedDocument } from "src/utils/normalizeRecords";
+
+export type SelectedPostListing = {
+ document_uri: string;
+ document: NormalizedDocument;
+ drawer: "quotes" | "comments";
+};
+
+export const useSelectedPostListing = create<{
+ selectedPostListing: SelectedPostListing | null;
+ setSelectedPostListing: (post: SelectedPostListing | null) => void;
+}>((set) => ({
+ selectedPostListing: null,
+ setSelectedPostListing: (post) => set({ selectedPostListing: post }),
+}));
diff --git a/src/utils/useRecordFromDid.ts b/src/utils/useRecordFromDid.ts
new file mode 100644
index 00000000..4b7da6ca
--- /dev/null
+++ b/src/utils/useRecordFromDid.ts
@@ -0,0 +1,12 @@
+import { callRPC } from "app/api/rpc/client";
+import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs";
+import useSWR from "swr";
+
+export function useRecordFromDid(did: string | undefined | null) {
+ return useSWR(did ? ["profile-data", did] : null, async () => {
+ const response = await callRPC("get_profile_data", {
+ didOrHandle: did!,
+ });
+ return response.result.profile as ProfileViewDetailed | undefined;
+ });
+}
--
2.51.2
From 38d5a055a559720644624c4ef7cf8c967a721e7d Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Sat, 14 Feb 2026 12:58:11 +0400
Subject: [PATCH 44/51] fix links in interaction drawer preview
---
.../reader/InteractionDrawers.tsx | 1 +
app/lish/createPub/getPublicationURL.ts | 42 +++++--------------
components/PostListing.tsx | 3 ++
src/useSelectedPostState.ts | 6 ++-
4 files changed, 20 insertions(+), 32 deletions(-)
diff --git a/app/(home-pages)/reader/InteractionDrawers.tsx b/app/(home-pages)/reader/InteractionDrawers.tsx
index 0d083e3a..3fbea5c1 100644
--- a/app/(home-pages)/reader/InteractionDrawers.tsx
+++ b/app/(home-pages)/reader/InteractionDrawers.tsx
@@ -60,6 +60,7 @@ const PreviewDrawerContent = (props: {
const postUrl = getDocumentURL(
props.selectedPost.document,
props.selectedPost.document_uri,
+ props.selectedPost.publication,
);
const drawerTitle =
diff --git a/app/lish/createPub/getPublicationURL.ts b/app/lish/createPub/getPublicationURL.ts
index cfb39d3f..af872c62 100644
--- a/app/lish/createPub/getPublicationURL.ts
+++ b/app/lish/createPub/getPublicationURL.ts
@@ -5,7 +5,6 @@ import { Json } from "supabase/database.types";
import {
normalizePublicationRecord,
isLeafletPublication,
- hasLeafletContent,
type NormalizedDocument,
type NormalizedPublication,
} from "src/utils/normalizeRecords";
@@ -22,7 +21,7 @@ export function getPublicationURL(pub: PublicationInput): string {
const normalized = normalizePublicationRecord(pub.record);
// If we have a normalized record with a URL (site.standard format), use it
- if (normalized?.url && isProductionDomain()) {
+ if (normalized?.url) {
return normalized.url;
}
@@ -50,8 +49,6 @@ export function getBasePublicationURL(pub: PublicationInput): string {
/**
* Gets the full URL for a document.
* Always appends the document's path property.
- * For non-leaflet documents (content.$type !== "pub.leaflet.content"),
- * always uses the full publication site URL, not internal /lish/ URLs.
*/
export function getDocumentURL(
doc: NormalizedDocument,
@@ -60,36 +57,19 @@ export function getDocumentURL(
): string {
let path = doc.path || "/" + new AtUri(docUri).rkey;
if (path[0] !== "/") path = "/" + path;
- const aturi = new AtUri(docUri);
- const isNormalized =
- !!publication &&
- (publication as NormalizedPublication).$type ===
- "site.standard.publication";
- const normPub = isNormalized
- ? (publication as NormalizedPublication)
- : publication
- ? normalizePublicationRecord((publication as PublicationInput).record)
- : null;
- const pubInput = isNormalized
- ? null
- : (publication as PublicationInput | null);
-
- // Non-leaflet documents always use the full publication site URL
- if (doc.content && !hasLeafletContent(doc) && normPub?.url) {
- return normPub.url + path;
- }
-
- // For leaflet documents, use getPublicationURL (may return /lish/ internal paths)
- if (pubInput) {
- return getPublicationURL(pubInput) + path;
+ if (!publication) {
+ return doc.site + path;
}
- // When we only have a normalized publication, use its URL directly
- if (normPub?.url) {
- return normPub.url + path;
+ // Already-normalized publications: use URL directly
+ if (
+ (publication as NormalizedPublication).$type ===
+ "site.standard.publication"
+ ) {
+ return ((publication as NormalizedPublication).url || doc.site) + path;
}
- // Standalone document fallback
- return `/p/${aturi.host}${path}`;
+ // Raw publication input: delegate to getPublicationURL for full resolution
+ return getPublicationURL(publication as PublicationInput) + path;
}
diff --git a/components/PostListing.tsx b/components/PostListing.tsx
index a39a4a14..3004461c 100644
--- a/components/PostListing.tsx
+++ b/components/PostListing.tsx
@@ -168,6 +168,7 @@ export const PostListing = (props: Post) => {
showMentions={mergedPrefs.showMentions !== false}
documentUri={props.documents.uri}
document={postRecord}
+ publication={pubRecord}
/>
@@ -229,6 +230,7 @@ const Interactions = (props: {
showMentions: boolean;
documentUri: string;
document: NormalizedDocument;
+ publication?: NormalizedPublication;
}) => {
let setSelectedPostListing = useSelectedPostListing(
(s) => s.setSelectedPostListing,
@@ -237,6 +239,7 @@ const Interactions = (props: {
setSelectedPostListing({
document_uri: props.documentUri,
document: props.document,
+ publication: props.publication,
drawer,
});
};
diff --git a/src/useSelectedPostState.ts b/src/useSelectedPostState.ts
index 88b06700..47013340 100644
--- a/src/useSelectedPostState.ts
+++ b/src/useSelectedPostState.ts
@@ -1,9 +1,13 @@
import { create } from "zustand";
-import type { NormalizedDocument } from "src/utils/normalizeRecords";
+import type {
+ NormalizedDocument,
+ NormalizedPublication,
+} from "src/utils/normalizeRecords";
export type SelectedPostListing = {
document_uri: string;
document: NormalizedDocument;
+ publication?: NormalizedPublication;
drawer: "quotes" | "comments";
};
--
2.51.2
From f758cd179a620805856f2f30ba516c6929ae2c28 Mon Sep 17 00:00:00 2001
From: Brendan Schlagel
Date: Sun, 15 Feb 2026 16:04:44 -0500
Subject: [PATCH 45/51] make looseleaf work as option in PubSelector even if no
publications exist
---
app/[leaflet_id]/actions/PublishButton.tsx | 13 ++++++++-----
1 file changed, 8 insertions(+), 5 deletions(-)
diff --git a/app/[leaflet_id]/actions/PublishButton.tsx b/app/[leaflet_id]/actions/PublishButton.tsx
index 7ba596ef..6ca18b02 100644
--- a/app/[leaflet_id]/actions/PublishButton.tsx
+++ b/app/[leaflet_id]/actions/PublishButton.tsx
@@ -345,17 +345,20 @@ const PubSelector = (props: {
Publish to…
{props.publications.length === 0 || props.publications === undefined ? (
-
-
+
props.setSelectedPub("looseleaf")}
+ >
+
Publish as Looseleaf
- Publish this as a one off doc to AT Proto
+ Publish as a one-off doc to AT Proto
-
+
@@ -363,7 +366,7 @@ const PubSelector = (props: {
Publish to Publication
- Publish your writing to a blog on AT Proto
+ Publish to a blog on AT Proto
--
2.51.2
From 6c70cf438f424db50fbea3dffc5a41896092d2b9 Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Wed, 18 Feb 2026 21:06:54 +0400
Subject: [PATCH 46/51] fix syncing leaflet published post bsky likes
---
.../functions/sync_document_metadata.ts | 18 +++++++++++++-----
1 file changed, 13 insertions(+), 5 deletions(-)
diff --git a/app/api/inngest/functions/sync_document_metadata.ts b/app/api/inngest/functions/sync_document_metadata.ts
index ee4e6b86..cacce907 100644
--- a/app/api/inngest/functions/sync_document_metadata.ts
+++ b/app/api/inngest/functions/sync_document_metadata.ts
@@ -3,12 +3,20 @@ import { supabaseServerClient } from "supabase/serverClient";
import { AtpAgent, AtUri } from "@atproto/api";
import { idResolver } from "app/(home-pages)/reader/idResolver";
-const TOTAL_ITERATIONS = 144; // 36 hours at 15-minute intervals
+// 1m, 2m, 4m, 8m, 16m, 32m, 1h, 2h, 4h, 8h, 8h, 8h (~37h total)
+const SLEEP_INTERVALS = [
+ "1m", "2m", "4m", "8m", "16m", "32m", "1h", "2h", "4h", "8h", "8h", "8h",
+];
export const sync_document_metadata = inngest.createFunction(
{
id: "sync_document_metadata",
- idempotency: "event.data.document_uri",
+ debounce: {
+ key: "event.data.document_uri",
+ period: "60s",
+ timeout: "3m",
+ },
+ concurrency: [{ key: "event.data.document_uri", limit: 1 }],
},
{ event: "appview/sync-document-metadata" },
async ({ event, step }) => {
@@ -61,9 +69,9 @@ export const sync_document_metadata = inngest.createFunction(
let likeCount = await step.run("sync-0", fetchAndUpdate);
- for (let i = 1; i < TOTAL_ITERATIONS; i++) {
- await step.sleep(`wait-${i}`, "15m");
- likeCount = await step.run(`sync-${i}`, fetchAndUpdate);
+ for (let i = 0; i < SLEEP_INTERVALS.length; i++) {
+ await step.sleep(`wait-${i + 1}`, SLEEP_INTERVALS[i]);
+ likeCount = await step.run(`sync-${i + 1}`, fetchAndUpdate);
}
return { likeCount, handle: handleResult.handle };
--
2.51.2
From de61ed82da2b99d7c32e90c9e2206c8cc96454f6 Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Wed, 18 Feb 2026 12:32:12 -0500
Subject: [PATCH 47/51] bump function id
---
app/api/inngest/functions/sync_document_metadata.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/api/inngest/functions/sync_document_metadata.ts b/app/api/inngest/functions/sync_document_metadata.ts
index cacce907..af2c3d4c 100644
--- a/app/api/inngest/functions/sync_document_metadata.ts
+++ b/app/api/inngest/functions/sync_document_metadata.ts
@@ -10,7 +10,7 @@ const SLEEP_INTERVALS = [
export const sync_document_metadata = inngest.createFunction(
{
- id: "sync_document_metadata",
+ id: "sync_document_metadata_v2",
debounce: {
key: "event.data.document_uri",
period: "60s",
--
2.51.2
From 8ed541914859103b362cbacb0f0c244b468c89e7 Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Thu, 19 Feb 2026 18:28:38 -0500
Subject: [PATCH 48/51] support embedding post links from blacksky + other
clients
---
components/Blocks/TextBlock/index.tsx | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/components/Blocks/TextBlock/index.tsx b/components/Blocks/TextBlock/index.tsx
index 3bfd54c0..f42233a2 100644
--- a/components/Blocks/TextBlock/index.tsx
+++ b/components/Blocks/TextBlock/index.tsx
@@ -318,6 +318,8 @@ export function BaseTextBlock(props: BlockProps & { className?: string }) {
);
}
+const blueskyclients = ["blacksky.community/", "bsky.app/", "witchsky.app/"];
+
const BlockifyLink = (props: {
entityID: string;
editorState: EditorState | undefined;
@@ -329,9 +331,10 @@ const BlockifyLink = (props: {
let focused = useUIState((s) => s.focusedEntity?.entityID === props.entityID);
let isBlueskyPost =
- editorState?.doc.textContent.includes("bsky.app/") &&
- editorState?.doc.textContent.includes("post");
- // only if the line stats with http or https and doesn't have other content
+ blueskyclients.some((client) =>
+ editorState?.doc.textContent.includes(client),
+ ) && editorState?.doc.textContent.includes("post");
+ // only if the line starts with http or https and doesn't have other content
// if its bluesky, change text to embed post
if (
--
2.51.2
From 01f52ae21e7ff6f9a6fb111ece227515f8a1b7f7 Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Thu, 19 Feb 2026 18:48:47 -0500
Subject: [PATCH 49/51] support linking to specific bsky clients
---
actions/publishToPublication.ts | 2 +
.../[rkey]/Blocks/PublishBskyPostBlock.tsx | 20 ++--------
.../[publication]/[rkey]/BskyPostContent.tsx | 9 +++--
.../[publication]/[rkey]/PostContent.tsx | 1 +
components/Blocks/BlueskyPostBlock/index.tsx | 37 ++-----------------
lexicons/api/lexicons.ts | 3 ++
.../api/types/pub/leaflet/blocks/bskyPost.ts | 1 +
lexicons/pub/leaflet/blocks/bskyPost.json | 3 ++
lexicons/src/blocks.ts | 1 +
src/replicache/attributes.ts | 4 ++
src/utils/addLinkBlock.ts | 37 ++++++++++++-------
11 files changed, 52 insertions(+), 66 deletions(-)
diff --git a/actions/publishToPublication.ts b/actions/publishToPublication.ts
index 70aa3d9d..cbd43c55 100644
--- a/actions/publishToPublication.ts
+++ b/actions/publishToPublication.ts
@@ -512,12 +512,14 @@ async function processBlocksToPages(
if (b.type === "bluesky-post") {
let [post] = scan.eav(b.value, "block/bluesky-post");
if (!post || !post.data.value.post) return;
+ let [hostFact] = scan.eav(b.value, "bluesky-post/host");
let block: $Typed = {
$type: ids.PubLeafletBlocksBskyPost,
postRef: {
uri: post.data.value.post.uri,
cid: post.data.value.post.cid,
},
+ clientHost: hostFact?.data.value,
};
return block;
}
diff --git a/app/lish/[did]/[publication]/[rkey]/Blocks/PublishBskyPostBlock.tsx b/app/lish/[did]/[publication]/[rkey]/Blocks/PublishBskyPostBlock.tsx
index 99cbc481..10b5d210 100644
--- a/app/lish/[did]/[publication]/[rkey]/Blocks/PublishBskyPostBlock.tsx
+++ b/app/lish/[did]/[publication]/[rkey]/Blocks/PublishBskyPostBlock.tsx
@@ -1,5 +1,5 @@
import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs";
-import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api";
+import { AppBskyFeedDefs } from "@atproto/api";
import { PostNotAvailable } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed";
import { BskyPostContent } from "../BskyPostContent";
@@ -7,6 +7,7 @@ export const PubBlueskyPostBlock = (props: {
post: PostView;
className: string;
pageId?: string;
+ clientHost?: string;
}) => {
let post = props.post;
@@ -21,24 +22,8 @@ export const PubBlueskyPostBlock = (props: {
);
case AppBskyFeedDefs.validatePostView(post).success:
- let record = post.record as AppBskyFeedDefs.PostView["record"];
-
- // silliness to get the text and timestamp from the record with proper types
- let timestamp: string | undefined = undefined;
- if (AppBskyFeedPost.isRecord(record)) {
- timestamp = (record as AppBskyFeedPost.Record).createdAt;
- }
-
- //getting the url to the post
- let postId = post.uri.split("/")[4];
let postView = post as PostView;
- let url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`;
-
- const parent = props.pageId
- ? { type: "doc" as const, id: props.pageId }
- : undefined;
-
return (
);
}
diff --git a/app/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx b/app/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx
index 6478687e..850c6168 100644
--- a/app/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx
+++ b/app/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx
@@ -28,6 +28,7 @@ export function BskyPostContent(props: {
quoteEnabled?: boolean;
replyEnabled?: boolean;
replyOnClick?: (e: React.MouseEvent) => void;
+ clientHost?: string;
}) {
const {
post,
@@ -39,11 +40,12 @@ export function BskyPostContent(props: {
quoteEnabled,
replyEnabled,
replyOnClick,
+ clientHost = "bsky.app",
} = props;
const record = post.record as AppBskyFeedPost.Record;
const postId = post.uri.split("/")[4];
- const url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`;
+ const url = `https://${clientHost}/profile/${post.author.handle}/post/${postId}`;
return (
@@ -138,12 +140,13 @@ export function CompactBskyPostContent(props: {
quoteEnabled?: boolean;
replyEnabled?: boolean;
replyOnClick?: (e: React.MouseEvent) => void;
+ clientHost?: string;
}) {
- const { post, parent, quoteEnabled, replyEnabled, replyOnClick } = props;
+ const { post, parent, quoteEnabled, replyEnabled, replyOnClick, clientHost = "bsky.app" } = props;
const record = post.record as AppBskyFeedPost.Record;
const postId = post.uri.split("/")[4];
- const url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`;
+ const url = `https://${clientHost}/profile/${post.author.handle}/post/${postId}`;
return (
diff --git a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx
index 37e7980b..47c27dc0 100644
--- a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx
+++ b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx
@@ -177,6 +177,7 @@ export let Block = ({
post={post}
className={className}
pageId={pageId}
+ clientHost={b.block.clientHost}
/>
);
}
diff --git a/components/Blocks/BlueskyPostBlock/index.tsx b/components/Blocks/BlueskyPostBlock/index.tsx
index 10353ad2..050a3c27 100644
--- a/components/Blocks/BlueskyPostBlock/index.tsx
+++ b/components/Blocks/BlueskyPostBlock/index.tsx
@@ -1,18 +1,14 @@
import { useEntitySetContext } from "components/EntitySetProvider";
-import { useEffect, useState } from "react";
+import { useEffect } from "react";
import { useEntity } from "src/replicache";
import { useUIState } from "src/useUIState";
import { BlockProps, BlockLayout } from "../Block";
import { elementId } from "src/utils/elementId";
import { focusBlock } from "src/utils/focusBlock";
-import { AppBskyFeedDefs, AppBskyFeedPost, RichText } from "@atproto/api";
+import { AppBskyFeedDefs } from "@atproto/api";
import { PostNotAvailable } from "./BlueskyEmbed";
import { BlueskyPostEmpty } from "./BlueskyEmpty";
-import { Separator } from "components/Layout";
-import { BlueskyTiny } from "components/Icons/BlueskyTiny";
-import { CommentTiny } from "components/Icons/CommentTiny";
-import { useLocalizedDate } from "src/hooks/useLocalizedDate";
import { BskyPostContent } from "app/lish/[did]/[publication]/[rkey]/BskyPostContent";
import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs";
@@ -22,6 +18,7 @@ export const BlueskyPostBlock = (props: BlockProps & { preview?: boolean }) => {
s.selectedBlocks.find((b) => b.value === props.entityID),
);
let post = useEntity(props.entityID, "block/bluesky-post")?.data.value;
+ let clientHost = useEntity(props.entityID, "bluesky-post/host")?.data.value;
useEffect(() => {
if (props.preview) return;
@@ -64,22 +61,7 @@ export const BlueskyPostBlock = (props: BlockProps & { preview?: boolean }) => {
);
case AppBskyFeedDefs.isThreadViewPost(post):
- let record = post.post
- .record as AppBskyFeedDefs.FeedViewPost["post"]["record"];
- let facets = record.facets;
-
- // silliness to get the text and timestamp from the record with proper types
- let text: string | null = null;
- let timestamp: string | undefined = undefined;
- if (AppBskyFeedPost.isRecord(record)) {
- text = (record as AppBskyFeedPost.Record).text;
- timestamp = (record as AppBskyFeedPost.Record).createdAt;
- }
-
- //getting the url to the post
- let postId = post.post.uri.split("/")[4];
let postView = post.post as PostView;
- let url = `https://bsky.app/profile/${post.post.author.handle}/post/${postId}`;
return (
{
showEmbed={true}
avatarSize="large"
className="text-sm text-secondary "
+ clientHost={clientHost}
/>
);
}
};
-
-function PostDate(props: { timestamp: string }) {
- const formattedDate = useLocalizedDate(props.timestamp, {
- month: "short",
- day: "numeric",
- year: "numeric",
- hour: "numeric",
- minute: "numeric",
- hour12: true,
- });
- return
{formattedDate}
;
-}
diff --git a/lexicons/api/lexicons.ts b/lexicons/api/lexicons.ts
index a4b2a81f..15de0b86 100644
--- a/lexicons/api/lexicons.ts
+++ b/lexicons/api/lexicons.ts
@@ -1048,6 +1048,9 @@ export const schemaDict = {
type: 'ref',
ref: 'lex:com.atproto.repo.strongRef',
},
+ clientHost: {
+ type: 'string',
+ },
},
},
},
diff --git a/lexicons/api/types/pub/leaflet/blocks/bskyPost.ts b/lexicons/api/types/pub/leaflet/blocks/bskyPost.ts
index 5c371dcb..06217602 100644
--- a/lexicons/api/types/pub/leaflet/blocks/bskyPost.ts
+++ b/lexicons/api/types/pub/leaflet/blocks/bskyPost.ts
@@ -18,6 +18,7 @@ const id = 'pub.leaflet.blocks.bskyPost'
export interface Main {
$type?: 'pub.leaflet.blocks.bskyPost'
postRef: ComAtprotoRepoStrongRef.Main
+ clientHost?: string
}
const hashMain = 'main'
diff --git a/lexicons/pub/leaflet/blocks/bskyPost.json b/lexicons/pub/leaflet/blocks/bskyPost.json
index a0fbbf05..1442c12a 100644
--- a/lexicons/pub/leaflet/blocks/bskyPost.json
+++ b/lexicons/pub/leaflet/blocks/bskyPost.json
@@ -11,6 +11,9 @@
"postRef": {
"type": "ref",
"ref": "com.atproto.repo.strongRef"
+ },
+ "clientHost": {
+ "type": "string"
}
}
}
diff --git a/lexicons/src/blocks.ts b/lexicons/src/blocks.ts
index dd3ff4ec..c7d54ccb 100644
--- a/lexicons/src/blocks.ts
+++ b/lexicons/src/blocks.ts
@@ -43,6 +43,7 @@ export const PubLeafletBlocksBskyPost: LexiconDoc = {
required: ["postRef"],
properties: {
postRef: { type: "ref", ref: "com.atproto.repo.strongRef" },
+ clientHost: { type: "string" },
},
},
},
diff --git a/src/replicache/attributes.ts b/src/replicache/attributes.ts
index 4f00e8e9..5ab061b3 100644
--- a/src/replicache/attributes.ts
+++ b/src/replicache/attributes.ts
@@ -151,6 +151,10 @@ const BlueskyPostBlockAttributes = {
type: "string",
cardinality: "one",
},
+ "bluesky-post/host": {
+ type: "string",
+ cardinality: "one",
+ },
} as const;
const ButtonBlockAttributes = {
diff --git a/src/utils/addLinkBlock.ts b/src/utils/addLinkBlock.ts
index e08a0f27..3fd79e01 100644
--- a/src/utils/addLinkBlock.ts
+++ b/src/utils/addLinkBlock.ts
@@ -144,6 +144,7 @@ export async function addBlueskyPostBlock(
) {
//construct bsky post uri from url
let urlParts = url?.split("/");
+ let host = urlParts ? urlParts[2] : "bsky.app"; // "bsky.app", "blacksky.community", "witchsky.app", etc.
let userDidOrHandle = urlParts ? urlParts[4] : ""; // "schlage.town" or "did:plc:jjsc5rflv3cpv6hgtqhn2dcm"
let collection = "app.bsky.feed.post";
let postId = urlParts ? urlParts[6] : "";
@@ -152,20 +153,30 @@ export async function addBlueskyPostBlock(
let post = await getBlueskyPost(uri);
if (!post || post === undefined) return false;
- await rep.mutate.assertFact({
- entity: entityID,
- attribute: "block/type",
- data: { type: "block-type-union", value: "bluesky-post" },
- });
- await rep?.mutate.assertFact({
- entity: entityID,
- attribute: "block/bluesky-post",
- data: {
- type: "bluesky-post",
- //TODO: this is a hack to get rid of a nested Array buffer which cannot be frozen, which replicache does on write.
- value: JSON.parse(JSON.stringify(post.data.thread)),
+ await rep.mutate.assertFact([
+ {
+ entity: entityID,
+ attribute: "block/type",
+ data: { type: "block-type-union", value: "bluesky-post" },
},
- });
+ {
+ entity: entityID,
+ attribute: "block/bluesky-post",
+ data: {
+ type: "bluesky-post",
+ //TODO: this is a hack to get rid of a nested Array buffer which cannot be frozen, which replicache does on write.
+ value: JSON.parse(JSON.stringify(post.data.thread)),
+ },
+ },
+ {
+ entity: entityID,
+ attribute: "bluesky-post/host",
+ data: {
+ type: "string",
+ value: host,
+ },
+ },
+ ]);
return true;
}
async function getBlueskyPost(uri: string) {
--
2.51.2
From 2684645332b2f72ff003ee97858bd462b61350e9 Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Mon, 23 Feb 2026 18:06:08 -0500
Subject: [PATCH 50/51] add check for ownership for standalone
---
actions/publishToPublication.ts | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/actions/publishToPublication.ts b/actions/publishToPublication.ts
index cbd43c55..8f5075c0 100644
--- a/actions/publishToPublication.ts
+++ b/actions/publishToPublication.ts
@@ -143,6 +143,21 @@ export async function publishToPublication({
.single();
draft = data;
existingDocUri = draft?.document;
+
+ // If updating an existing document, verify the current user is the owner
+ if (existingDocUri) {
+ let docOwner = new AtUri(existingDocUri).host;
+ if (docOwner !== identity.atp_did) {
+ return {
+ success: false,
+ error: {
+ type: "oauth_session_expired" as const,
+ message: "Not the document owner",
+ did: identity.atp_did,
+ },
+ };
+ }
+ }
}
// Heuristic: Remove title entities if this is the first time publishing
--
2.51.2
From da31fd1d4ec632e37cc03bfc244224772818eecb Mon Sep 17 00:00:00 2001
From: Jared Pereira
Date: Wed, 25 Feb 2026 17:21:39 -0500
Subject: [PATCH 51/51] add subscription and entitlements tables migration
Co-Authored-By: Claude Opus 4.6
---
...20260225000000_add_subscription_tables.sql | 93 +++++++++++++++++++
1 file changed, 93 insertions(+)
create mode 100644 supabase/migrations/20260225000000_add_subscription_tables.sql
diff --git a/supabase/migrations/20260225000000_add_subscription_tables.sql b/supabase/migrations/20260225000000_add_subscription_tables.sql
new file mode 100644
index 00000000..1a9792bb
--- /dev/null
+++ b/supabase/migrations/20260225000000_add_subscription_tables.sql
@@ -0,0 +1,93 @@
+-- user_subscriptions: tracks Stripe subscription state per identity
+create table "public"."user_subscriptions" (
+ "identity_id" uuid not null,
+ "stripe_customer_id" text not null,
+ "stripe_subscription_id" text,
+ "plan" text,
+ "status" text,
+ "current_period_end" timestamp with time zone,
+ "created_at" timestamp with time zone not null default now(),
+ "updated_at" timestamp with time zone not null default now()
+);
+
+alter table "public"."user_subscriptions" enable row level security;
+
+CREATE UNIQUE INDEX user_subscriptions_pkey ON public.user_subscriptions USING btree (identity_id);
+
+alter table "public"."user_subscriptions" add constraint "user_subscriptions_pkey" PRIMARY KEY using index "user_subscriptions_pkey";
+
+CREATE UNIQUE INDEX user_subscriptions_stripe_customer_id_key ON public.user_subscriptions USING btree (stripe_customer_id);
+
+CREATE UNIQUE INDEX user_subscriptions_stripe_subscription_id_key ON public.user_subscriptions USING btree (stripe_subscription_id);
+
+alter table "public"."user_subscriptions" add constraint "user_subscriptions_identity_id_fkey" FOREIGN KEY (identity_id) REFERENCES identities(id) ON DELETE CASCADE;
+
+grant delete on table "public"."user_subscriptions" to "anon";
+grant insert on table "public"."user_subscriptions" to "anon";
+grant references on table "public"."user_subscriptions" to "anon";
+grant select on table "public"."user_subscriptions" to "anon";
+grant trigger on table "public"."user_subscriptions" to "anon";
+grant truncate on table "public"."user_subscriptions" to "anon";
+grant update on table "public"."user_subscriptions" to "anon";
+
+grant delete on table "public"."user_subscriptions" to "authenticated";
+grant insert on table "public"."user_subscriptions" to "authenticated";
+grant references on table "public"."user_subscriptions" to "authenticated";
+grant select on table "public"."user_subscriptions" to "authenticated";
+grant trigger on table "public"."user_subscriptions" to "authenticated";
+grant truncate on table "public"."user_subscriptions" to "authenticated";
+grant update on table "public"."user_subscriptions" to "authenticated";
+
+grant delete on table "public"."user_subscriptions" to "service_role";
+grant insert on table "public"."user_subscriptions" to "service_role";
+grant references on table "public"."user_subscriptions" to "service_role";
+grant select on table "public"."user_subscriptions" to "service_role";
+grant trigger on table "public"."user_subscriptions" to "service_role";
+grant truncate on table "public"."user_subscriptions" to "service_role";
+grant update on table "public"."user_subscriptions" to "service_role";
+
+-- user_entitlements: feature access decoupled from billing
+create table "public"."user_entitlements" (
+ "identity_id" uuid not null,
+ "entitlement_key" text not null,
+ "granted_at" timestamp with time zone not null default now(),
+ "expires_at" timestamp with time zone,
+ "source" text,
+ "metadata" jsonb
+);
+
+alter table "public"."user_entitlements" enable row level security;
+
+CREATE UNIQUE INDEX user_entitlements_pkey ON public.user_entitlements USING btree (identity_id, entitlement_key);
+
+alter table "public"."user_entitlements" add constraint "user_entitlements_pkey" PRIMARY KEY using index "user_entitlements_pkey";
+
+CREATE INDEX user_entitlements_identity_id_idx ON public.user_entitlements USING btree (identity_id);
+
+CREATE INDEX user_entitlements_expires_at_idx ON public.user_entitlements USING btree (expires_at);
+
+alter table "public"."user_entitlements" add constraint "user_entitlements_identity_id_fkey" FOREIGN KEY (identity_id) REFERENCES identities(id) ON DELETE CASCADE;
+
+grant delete on table "public"."user_entitlements" to "anon";
+grant insert on table "public"."user_entitlements" to "anon";
+grant references on table "public"."user_entitlements" to "anon";
+grant select on table "public"."user_entitlements" to "anon";
+grant trigger on table "public"."user_entitlements" to "anon";
+grant truncate on table "public"."user_entitlements" to "anon";
+grant update on table "public"."user_entitlements" to "anon";
+
+grant delete on table "public"."user_entitlements" to "authenticated";
+grant insert on table "public"."user_entitlements" to "authenticated";
+grant references on table "public"."user_entitlements" to "authenticated";
+grant select on table "public"."user_entitlements" to "authenticated";
+grant trigger on table "public"."user_entitlements" to "authenticated";
+grant truncate on table "public"."user_entitlements" to "authenticated";
+grant update on table "public"."user_entitlements" to "authenticated";
+
+grant delete on table "public"."user_entitlements" to "service_role";
+grant insert on table "public"."user_entitlements" to "service_role";
+grant references on table "public"."user_entitlements" to "service_role";
+grant select on table "public"."user_entitlements" to "service_role";
+grant trigger on table "public"."user_entitlements" to "service_role";
+grant truncate on table "public"."user_entitlements" to "service_role";
+grant update on table "public"."user_entitlements" to "service_role";