a tool for shared writing and social publishing
0

Configure Feed

Select the types of activity you want to include in your feed.

Merge branch 'main' into refactor/route-nav

Jared Pereira (May 5, 2026, 3:42 PM EDT) 9899c853 7807e7ee

+2134 -806
+153 -24
package-lock.json
··· 25 25 "@next/mdx": "16.0.3", 26 26 "@radix-ui/react-dialog": "^1.1.15", 27 27 "@radix-ui/react-dropdown-menu": "^2.1.16", 28 - "@radix-ui/react-one-time-password-field": "^0.1.8", 28 + "@radix-ui/react-one-time-password-field": "^0.1.9-rc.1766004502650", 29 29 "@radix-ui/react-popover": "^1.1.15", 30 30 "@radix-ui/react-slider": "^1.3.6", 31 31 "@radix-ui/react-tooltip": "^1.2.8", ··· 5062 5062 } 5063 5063 }, 5064 5064 "node_modules/@radix-ui/react-one-time-password-field": { 5065 - "version": "0.1.8", 5066 - "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.8.tgz", 5067 - "integrity": "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==", 5065 + "version": "0.1.9-rc.1766004502650", 5066 + "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.9-rc.1766004502650.tgz", 5067 + "integrity": "sha512-Rr8u+CFcS1HKq0D8oPp68/d2M14RjladXbFzOSXPWnxNOtjfA/JOqrbUdt8VvpgK0UW2Vf/cCOXszTPVOvfuDg==", 5068 + "license": "MIT", 5068 5069 "dependencies": { 5069 5070 "@radix-ui/number": "1.1.1", 5070 5071 "@radix-ui/primitive": "1.1.3", 5071 - "@radix-ui/react-collection": "1.1.7", 5072 + "@radix-ui/react-collection": "1.1.9-rc.1766004502650", 5072 5073 "@radix-ui/react-compose-refs": "1.1.2", 5073 - "@radix-ui/react-context": "1.1.2", 5074 - "@radix-ui/react-direction": "1.1.1", 5075 - "@radix-ui/react-primitive": "2.1.3", 5076 - "@radix-ui/react-roving-focus": "1.1.11", 5074 + "@radix-ui/react-context": "1.1.3", 5075 + "@radix-ui/react-direction": "1.1.2-rc.1766004502650", 5076 + "@radix-ui/react-primitive": "2.1.4", 5077 + "@radix-ui/react-roving-focus": "1.1.12-rc.1766004502650", 5077 5078 "@radix-ui/react-use-controllable-state": "1.2.2", 5078 5079 "@radix-ui/react-use-effect-event": "0.0.2", 5079 5080 "@radix-ui/react-use-is-hydrated": "0.1.0", ··· 5090 5091 "optional": true 5091 5092 }, 5092 5093 "@types/react-dom": { 5094 + "optional": true 5095 + } 5096 + } 5097 + }, 5098 + "node_modules/@radix-ui/react-one-time-password-field/node_modules/@radix-ui/react-collection": { 5099 + "version": "1.1.9-rc.1766004502650", 5100 + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.9-rc.1766004502650.tgz", 5101 + "integrity": "sha512-yB1SMB95n6gZpmOtN+3dYjKWDeDcp2ret8fwyd6Cp06RXOjDPRYK7NJvhdzRR7pohyvym8kFngLk2sBbvXjPRA==", 5102 + "license": "MIT", 5103 + "dependencies": { 5104 + "@radix-ui/react-compose-refs": "1.1.2", 5105 + "@radix-ui/react-context": "1.1.3", 5106 + "@radix-ui/react-primitive": "2.1.4", 5107 + "@radix-ui/react-slot": "1.2.4" 5108 + }, 5109 + "peerDependencies": { 5110 + "@types/react": "*", 5111 + "@types/react-dom": "*", 5112 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 5113 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 5114 + }, 5115 + "peerDependenciesMeta": { 5116 + "@types/react": { 5117 + "optional": true 5118 + }, 5119 + "@types/react-dom": { 5120 + "optional": true 5121 + } 5122 + } 5123 + }, 5124 + "node_modules/@radix-ui/react-one-time-password-field/node_modules/@radix-ui/react-context": { 5125 + "version": "1.1.3", 5126 + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz", 5127 + "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", 5128 + "license": "MIT", 5129 + "peerDependencies": { 5130 + "@types/react": "*", 5131 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 5132 + }, 5133 + "peerDependenciesMeta": { 5134 + "@types/react": { 5135 + "optional": true 5136 + } 5137 + } 5138 + }, 5139 + "node_modules/@radix-ui/react-one-time-password-field/node_modules/@radix-ui/react-direction": { 5140 + "version": "1.1.2-rc.1766004502650", 5141 + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.2-rc.1766004502650.tgz", 5142 + "integrity": "sha512-oz3U1P7nqM9GRfXxQQ8unpaVg1z3pezaFc6Lb4ygFwsSaIpawxWgi1r0zQSAQHryaLTVvAagV/aJU5YFBCk+UQ==", 5143 + "license": "MIT", 5144 + "peerDependencies": { 5145 + "@types/react": "*", 5146 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 5147 + }, 5148 + "peerDependenciesMeta": { 5149 + "@types/react": { 5150 + "optional": true 5151 + } 5152 + } 5153 + }, 5154 + "node_modules/@radix-ui/react-one-time-password-field/node_modules/@radix-ui/react-primitive": { 5155 + "version": "2.1.4", 5156 + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", 5157 + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", 5158 + "license": "MIT", 5159 + "dependencies": { 5160 + "@radix-ui/react-slot": "1.2.4" 5161 + }, 5162 + "peerDependencies": { 5163 + "@types/react": "*", 5164 + "@types/react-dom": "*", 5165 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 5166 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 5167 + }, 5168 + "peerDependenciesMeta": { 5169 + "@types/react": { 5170 + "optional": true 5171 + }, 5172 + "@types/react-dom": { 5173 + "optional": true 5174 + } 5175 + } 5176 + }, 5177 + "node_modules/@radix-ui/react-one-time-password-field/node_modules/@radix-ui/react-roving-focus": { 5178 + "version": "1.1.12-rc.1766004502650", 5179 + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.12-rc.1766004502650.tgz", 5180 + "integrity": "sha512-nR3+hkQovm4f17+XpfIgVDIukV/y0sGl7teNNckq1aMGAWD38QopNOQnxYIdX0FNp+ZtXnzPS9dvTPh0oMG3ZQ==", 5181 + "license": "MIT", 5182 + "dependencies": { 5183 + "@radix-ui/primitive": "1.1.3", 5184 + "@radix-ui/react-collection": "1.1.9-rc.1766004502650", 5185 + "@radix-ui/react-compose-refs": "1.1.2", 5186 + "@radix-ui/react-context": "1.1.3", 5187 + "@radix-ui/react-direction": "1.1.2-rc.1766004502650", 5188 + "@radix-ui/react-id": "1.1.1", 5189 + "@radix-ui/react-primitive": "2.1.4", 5190 + "@radix-ui/react-use-callback-ref": "1.1.1", 5191 + "@radix-ui/react-use-controllable-state": "1.2.2" 5192 + }, 5193 + "peerDependencies": { 5194 + "@types/react": "*", 5195 + "@types/react-dom": "*", 5196 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 5197 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 5198 + }, 5199 + "peerDependenciesMeta": { 5200 + "@types/react": { 5201 + "optional": true 5202 + }, 5203 + "@types/react-dom": { 5204 + "optional": true 5205 + } 5206 + } 5207 + }, 5208 + "node_modules/@radix-ui/react-one-time-password-field/node_modules/@radix-ui/react-slot": { 5209 + "version": "1.2.4", 5210 + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", 5211 + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", 5212 + "license": "MIT", 5213 + "dependencies": { 5214 + "@radix-ui/react-compose-refs": "1.1.2" 5215 + }, 5216 + "peerDependencies": { 5217 + "@types/react": "*", 5218 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 5219 + }, 5220 + "peerDependenciesMeta": { 5221 + "@types/react": { 5093 5222 "optional": true 5094 5223 } 5095 5224 } ··· 9670 9799 "version": "7.0.15", 9671 9800 "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", 9672 9801 "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", 9673 - "devOptional": true 9802 + "dev": true 9674 9803 }, 9675 9804 "node_modules/@types/json5": { 9676 9805 "version": "0.0.29", ··· 10810 10939 "version": "4.28.2", 10811 10940 "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", 10812 10941 "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", 10813 - "devOptional": true, 10942 + "dev": true, 10814 10943 "funding": [ 10815 10944 { 10816 10945 "type": "opencollective", ··· 10868 10997 "version": "1.1.2", 10869 10998 "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", 10870 10999 "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", 10871 - "devOptional": true 11000 + "dev": true 10872 11001 }, 10873 11002 "node_modules/bytes": { 10874 11003 "version": "3.1.2", ··· 12532 12661 "version": "1.5.340", 12533 12662 "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.340.tgz", 12534 12663 "integrity": "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==", 12535 - "devOptional": true, 12664 + "dev": true, 12536 12665 "license": "ISC" 12537 12666 }, 12538 12667 "node_modules/emoji-regex": { ··· 12614 12743 "version": "5.20.1", 12615 12744 "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", 12616 12745 "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", 12617 - "devOptional": true, 12746 + "dev": true, 12618 12747 "license": "MIT", 12619 12748 "dependencies": { 12620 12749 "graceful-fs": "^4.2.4", ··· 13405 13534 "version": "4.3.0", 13406 13535 "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", 13407 13536 "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", 13408 - "devOptional": true, 13537 + "dev": true, 13409 13538 "dependencies": { 13410 13539 "estraverse": "^5.2.0" 13411 13540 }, ··· 13417 13546 "version": "5.3.0", 13418 13547 "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", 13419 13548 "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", 13420 - "devOptional": true, 13549 + "dev": true, 13421 13550 "engines": { 13422 13551 "node": ">=4.0" 13423 13552 } ··· 14179 14308 "version": "0.4.1", 14180 14309 "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", 14181 14310 "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", 14182 - "devOptional": true 14311 + "dev": true 14183 14312 }, 14184 14313 "node_modules/glob/node_modules/brace-expansion": { 14185 14314 "version": "2.0.2", ··· 14256 14385 "version": "4.2.11", 14257 14386 "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", 14258 14387 "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", 14259 - "devOptional": true 14388 + "dev": true 14260 14389 }, 14261 14390 "node_modules/graphemer": { 14262 14391 "version": "1.4.0", ··· 14306 14435 "version": "4.0.0", 14307 14436 "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", 14308 14437 "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", 14309 - "devOptional": true, 14438 + "dev": true, 14310 14439 "engines": { 14311 14440 "node": ">=8" 14312 14441 } ··· 18107 18236 "version": "2.0.37", 18108 18237 "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", 18109 18238 "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", 18110 - "devOptional": true, 18239 + "dev": true, 18111 18240 "license": "MIT" 18112 18241 }, 18113 18242 "node_modules/normalize-path": { ··· 21514 21643 "version": "0.6.1", 21515 21644 "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", 21516 21645 "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", 21517 - "devOptional": true, 21646 + "dev": true, 21518 21647 "engines": { 21519 21648 "node": ">=0.10.0" 21520 21649 } ··· 21532 21661 "version": "0.5.21", 21533 21662 "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", 21534 21663 "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", 21535 - "devOptional": true, 21664 + "dev": true, 21536 21665 "dependencies": { 21537 21666 "buffer-from": "^1.0.0", 21538 21667 "source-map": "^0.6.0" ··· 21951 22080 "version": "2.3.2", 21952 22081 "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", 21953 22082 "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", 21954 - "devOptional": true, 22083 + "dev": true, 21955 22084 "license": "MIT", 21956 22085 "engines": { 21957 22086 "node": ">=6" ··· 22566 22695 "version": "1.2.3", 22567 22696 "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", 22568 22697 "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", 22569 - "devOptional": true, 22698 + "dev": true, 22570 22699 "funding": [ 22571 22700 { 22572 22701 "type": "opencollective",
+1 -1
package.json
··· 37 37 "@next/mdx": "16.0.3", 38 38 "@radix-ui/react-dialog": "^1.1.15", 39 39 "@radix-ui/react-dropdown-menu": "^2.1.16", 40 - "@radix-ui/react-one-time-password-field": "^0.1.8", 40 + "@radix-ui/react-one-time-password-field": "^0.1.9-rc.1766004502650", 41 41 "@radix-ui/react-popover": "^1.1.15", 42 42 "@radix-ui/react-slider": "^1.3.6", 43 43 "@radix-ui/react-tooltip": "^1.2.8",
-145
actions/emailAuth.ts
··· 1 - "use server"; 2 - 3 - import { randomBytes } from "crypto"; 4 - import { drizzle } from "drizzle-orm/node-postgres"; 5 - 6 - import { email_auth_tokens, identities } from "drizzle/schema"; 7 - import { and, eq } from "drizzle-orm"; 8 - import { cookies } from "next/headers"; 9 - import { setAuthToken } from "src/auth"; 10 - import { pool } from "supabase/pool"; 11 - import { supabaseServerClient } from "supabase/serverClient"; 12 - 13 - async function sendAuthCode(email: string, code: string) { 14 - if (process.env.NODE_ENV === "development") { 15 - console.log("Auth code:", code); 16 - return; 17 - } 18 - 19 - let res = await fetch("https://api.postmarkapp.com/email", { 20 - method: "POST", 21 - headers: { 22 - "Content-Type": "application/json", 23 - "X-Postmark-Server-Token": process.env.POSTMARK_API_KEY!, 24 - }, 25 - body: JSON.stringify({ 26 - From: "Leaflet <accounts@leaflet.pub>", 27 - Subject: `Your authentication code for Leaflet is ${code}`, 28 - To: email, 29 - TextBody: `Paste this code to login to Leaflet: 30 - 31 - ${code} 32 - `, 33 - HtmlBody: ` 34 - <html> 35 - <body> 36 - <p>Paste this code to login to Leaflet: <strong>${code}</strong></p> 37 - </body> 38 - </html> 39 - `, 40 - }), 41 - }); 42 - } 43 - 44 - export async function requestAuthEmailToken(emailNonNormalized: string) { 45 - let email = emailNonNormalized.toLowerCase(); 46 - const client = await pool.connect(); 47 - const db = drizzle(client); 48 - 49 - const code = randomBytes(3).toString("hex").toUpperCase(); 50 - 51 - const [token] = await db 52 - .insert(email_auth_tokens) 53 - .values({ 54 - email, 55 - confirmation_code: code, 56 - confirmed: false, 57 - }) 58 - .returning({ 59 - id: email_auth_tokens.id, 60 - }); 61 - 62 - await sendAuthCode(email, code); 63 - 64 - client.release(); 65 - return token.id; 66 - } 67 - 68 - export async function confirmEmailAuthToken(tokenId: string, code: string) { 69 - const client = await pool.connect(); 70 - const db = drizzle(client); 71 - 72 - const [token] = await db 73 - .select() 74 - .from(email_auth_tokens) 75 - .where(eq(email_auth_tokens.id, tokenId)); 76 - 77 - if (!token || !token.email) { 78 - client.release(); 79 - return null; 80 - } 81 - 82 - if (token.confirmation_code !== code) { 83 - client.release(); 84 - return null; 85 - } 86 - 87 - if (token.confirmed) { 88 - client.release(); 89 - return null; 90 - } 91 - let authToken = (await cookies()).get("auth_token"); 92 - if (authToken) { 93 - let [existingToken] = await db 94 - .select() 95 - .from(email_auth_tokens) 96 - .rightJoin(identities, eq(identities.id, email_auth_tokens.identity)) 97 - .where(eq(email_auth_tokens.id, authToken.value)); 98 - 99 - if (existingToken) { 100 - if (existingToken.identities?.email) { 101 - } 102 - await db 103 - .update(identities) 104 - .set({ email: token.email }) 105 - .where(eq(identities.id, existingToken.identities.id)); 106 - client.release(); 107 - return existingToken; 108 - } 109 - } 110 - 111 - let identityID; 112 - let [identity] = await db 113 - .select() 114 - .from(identities) 115 - .where(eq(identities.email, token.email)); 116 - if (!identity) { 117 - const { data: newIdentity } = await supabaseServerClient 118 - .from("identities") 119 - .insert({ email: token.email }) 120 - .select() 121 - .single(); 122 - identityID = newIdentity!.id; 123 - } else { 124 - identityID = identity.id; 125 - } 126 - 127 - const [confirmedToken] = await db 128 - .update(email_auth_tokens) 129 - .set({ 130 - confirmed: true, 131 - identity: identityID, 132 - }) 133 - .where( 134 - and( 135 - eq(email_auth_tokens.id, tokenId), 136 - eq(email_auth_tokens.confirmation_code, code), 137 - ), 138 - ) 139 - .returning(); 140 - 141 - await setAuthToken(confirmedToken.id); 142 - 143 - client.release(); 144 - return confirmedToken; 145 - }
+134
actions/emailAuth.tsx
··· 1 + "use server"; 2 + 3 + import { randomBytes } from "crypto"; 4 + import { drizzle } from "drizzle-orm/node-postgres"; 5 + 6 + import { email_auth_tokens, identities } from "drizzle/schema"; 7 + import { and, eq } from "drizzle-orm"; 8 + import { cookies } from "next/headers"; 9 + import { setAuthToken } from "src/auth"; 10 + import { pool } from "supabase/pool"; 11 + import { supabaseServerClient } from "supabase/serverClient"; 12 + import { LeafletConfirmEmail } from "emails/leafletConfirmEmail"; 13 + import { sendConfirmationEmail } from "src/utils/confirmationEmail"; 14 + 15 + async function sendAuthCode(email: string, code: string) { 16 + await sendConfirmationEmail({ 17 + to: email, 18 + subject: `Your authentication code for Leaflet is ${code}`, 19 + template: ( 20 + <LeafletConfirmEmail 21 + code={code} 22 + title="Sign in to Leaflet" 23 + message="Paste this code to sign in" 24 + assetsBaseUrl={process.env.NEXT_PUBLIC_APP_URL || "https://leaflet.pub"} 25 + /> 26 + ), 27 + text: `Paste this code to login to Leaflet:\n\n${code}\n`, 28 + devLogTag: "auth code", 29 + code, 30 + }); 31 + } 32 + 33 + export async function requestAuthEmailToken(emailNonNormalized: string) { 34 + let email = emailNonNormalized.toLowerCase(); 35 + const client = await pool.connect(); 36 + const db = drizzle(client); 37 + 38 + const code = randomBytes(3).toString("hex").toUpperCase(); 39 + 40 + const [token] = await db 41 + .insert(email_auth_tokens) 42 + .values({ 43 + email, 44 + confirmation_code: code, 45 + confirmed: false, 46 + }) 47 + .returning({ 48 + id: email_auth_tokens.id, 49 + }); 50 + 51 + await sendAuthCode(email, code); 52 + 53 + client.release(); 54 + return token.id; 55 + } 56 + 57 + export async function confirmEmailAuthToken(tokenId: string, code: string) { 58 + const client = await pool.connect(); 59 + const db = drizzle(client); 60 + 61 + const [token] = await db 62 + .select() 63 + .from(email_auth_tokens) 64 + .where(eq(email_auth_tokens.id, tokenId)); 65 + 66 + if (!token || !token.email) { 67 + client.release(); 68 + return null; 69 + } 70 + 71 + if (token.confirmation_code !== code) { 72 + client.release(); 73 + return null; 74 + } 75 + 76 + if (token.confirmed) { 77 + client.release(); 78 + return null; 79 + } 80 + let authToken = (await cookies()).get("auth_token"); 81 + if (authToken) { 82 + let [existingToken] = await db 83 + .select() 84 + .from(email_auth_tokens) 85 + .rightJoin(identities, eq(identities.id, email_auth_tokens.identity)) 86 + .where(eq(email_auth_tokens.id, authToken.value)); 87 + 88 + if (existingToken) { 89 + if (existingToken.identities?.email) { 90 + } 91 + await db 92 + .update(identities) 93 + .set({ email: token.email }) 94 + .where(eq(identities.id, existingToken.identities.id)); 95 + client.release(); 96 + return existingToken; 97 + } 98 + } 99 + 100 + let identityID; 101 + let [identity] = await db 102 + .select() 103 + .from(identities) 104 + .where(eq(identities.email, token.email)); 105 + if (!identity) { 106 + const { data: newIdentity } = await supabaseServerClient 107 + .from("identities") 108 + .insert({ email: token.email }) 109 + .select() 110 + .single(); 111 + identityID = newIdentity!.id; 112 + } else { 113 + identityID = identity.id; 114 + } 115 + 116 + const [confirmedToken] = await db 117 + .update(email_auth_tokens) 118 + .set({ 119 + confirmed: true, 120 + identity: identityID, 121 + }) 122 + .where( 123 + and( 124 + eq(email_auth_tokens.id, tokenId), 125 + eq(email_auth_tokens.confirmation_code, code), 126 + ), 127 + ) 128 + .returning(); 129 + 130 + await setAuthToken(confirmedToken.id); 131 + 132 + client.release(); 133 + return confirmedToken; 134 + }
+9 -141
actions/mergeIdentity.ts
··· 1 1 "use server"; 2 2 3 3 import { cookies } from "next/headers"; 4 - import { drizzle } from "drizzle-orm/node-postgres"; 5 - import { eq, inArray, sql } from "drizzle-orm"; 6 - import { 7 - custom_domains, 8 - email_auth_tokens, 9 - identities, 10 - permission_token_on_homepage, 11 - publication_email_subscribers, 12 - user_entitlements, 13 - user_subscriptions, 14 - } from "drizzle/schema"; 15 - import { pool } from "supabase/pool"; 16 4 import { supabaseServerClient } from "supabase/serverClient"; 17 5 import { 18 6 AUTH_TOKEN_COOKIE, ··· 22 10 setAuthToken, 23 11 } from "src/auth"; 24 12 import { Err, Ok, type Result } from "src/result"; 25 - 26 - type MergeError = 27 - | "merge_not_pending" 28 - | "invalid_source" 29 - | "invalid_target" 30 - | "same_identity" 31 - | "database_error"; 13 + import { 14 + mergeEmailIdentityIntoAtpIdentity, 15 + type MergeError, 16 + } from "src/mergeIdentity"; 32 17 33 18 export async function confirmIdentityMerge(): Promise<Result<null, MergeError>> { 34 19 const jar = await cookies(); ··· 48 33 return Err("invalid_source"); 49 34 if (!target.identity.atp_did) return Err("invalid_target"); 50 35 51 - const sourceId = source.identity.id; 52 - const targetId = target.identity.id; 53 - const sourceEmail = source.identity.email; 54 - 55 - const client = await pool.connect(); 56 - try { 57 - const db = drizzle(client); 58 - await db.transaction(async (tx) => { 59 - // Re-verify invariants under a row lock. Protects against racing 60 - // concurrent merges or an identity being mutated between cookie 61 - // resolution and this transaction. 62 - const locked = await tx 63 - .select({ 64 - id: identities.id, 65 - email: identities.email, 66 - atp_did: identities.atp_did, 67 - }) 68 - .from(identities) 69 - .where(inArray(identities.id, [sourceId, targetId])) 70 - .for("update"); 71 - const lockedSource = locked.find((r) => r.id === sourceId); 72 - const lockedTarget = locked.find((r) => r.id === targetId); 73 - if (!lockedSource || !lockedTarget) 74 - throw new Error("merge: identity disappeared under lock"); 75 - if (lockedSource.atp_did !== null) 76 - throw new Error("merge: source has atp_did"); 77 - if (lockedTarget.atp_did === null) 78 - throw new Error("merge: target missing atp_did"); 79 - if (!lockedSource.email) throw new Error("merge: source missing email"); 80 - 81 - // email_auth_tokens: caller is about to swap auth_token to the pending 82 - // token (which already points at target). Source's tokens are now stale. 83 - await tx 84 - .delete(email_auth_tokens) 85 - .where(eq(email_auth_tokens.identity, sourceId)); 86 - 87 - // Target wins on (identity_id) PK collision. 88 - await tx.execute(sql` 89 - delete from user_subscriptions 90 - where identity_id = ${sourceId} 91 - and exists (select 1 from user_subscriptions where identity_id = ${targetId}) 92 - `); 93 - await tx 94 - .update(user_subscriptions) 95 - .set({ identity_id: targetId }) 96 - .where(eq(user_subscriptions.identity_id, sourceId)); 97 - 98 - // Target wins on (identity_id, entitlement_key) PK collision. 99 - await tx.execute(sql` 100 - delete from user_entitlements 101 - where identity_id = ${sourceId} 102 - and entitlement_key in ( 103 - select entitlement_key from user_entitlements where identity_id = ${targetId} 104 - ) 105 - `); 106 - await tx 107 - .update(user_entitlements) 108 - .set({ identity_id: targetId }) 109 - .where(eq(user_entitlements.identity_id, sourceId)); 110 - 111 - // Target wins on (token, identity) PK collision. 112 - await tx.execute(sql` 113 - delete from permission_token_on_homepage 114 - where identity = ${sourceId} 115 - and token in ( 116 - select token from permission_token_on_homepage where identity = ${targetId} 117 - ) 118 - `); 119 - await tx 120 - .update(permission_token_on_homepage) 121 - .set({ identity: targetId }) 122 - .where(eq(permission_token_on_homepage.identity, sourceId)); 123 - 124 - // Target wins on unique (publication, email) collision. 125 - await tx.execute(sql` 126 - delete from ${publication_email_subscribers} 127 - where ${publication_email_subscribers.identity_id} = ${sourceId} 128 - and (${publication_email_subscribers.publication}, ${publication_email_subscribers.email}) in ( 129 - select ${publication_email_subscribers.publication}, ${publication_email_subscribers.email} 130 - from ${publication_email_subscribers} 131 - where ${publication_email_subscribers.identity_id} = ${targetId} 132 - ) 133 - `); 134 - await tx 135 - .update(publication_email_subscribers) 136 - .set({ identity_id: targetId }) 137 - .where(eq(publication_email_subscribers.identity_id, sourceId)); 138 - 139 - await tx 140 - .update(custom_domains) 141 - .set({ identity_id: targetId }) 142 - .where(eq(custom_domains.identity_id, sourceId)); 143 - 144 - // identities.email is unique — step via NULL so we don't collide 145 - // mid-swap. custom_domains.identity is nullable and cascades on update; 146 - // we re-set it explicitly after the final value lands because NULL→value 147 - // cascades don't fire. 148 - await tx 149 - .update(identities) 150 - .set({ email: null }) 151 - .where(eq(identities.id, targetId)); 152 - await tx 153 - .update(identities) 154 - .set({ email: null }) 155 - .where(eq(identities.id, sourceId)); 156 - await tx 157 - .update(identities) 158 - .set({ email: sourceEmail }) 159 - .where(eq(identities.id, targetId)); 160 - await tx 161 - .update(custom_domains) 162 - .set({ identity: sourceEmail }) 163 - .where(eq(custom_domains.identity_id, targetId)); 164 - 165 - await tx.delete(identities).where(eq(identities.id, sourceId)); 166 - }); 167 - } catch (e) { 168 - console.error("[mergeIdentity] transaction failed:", e); 169 - return Err("database_error"); 170 - } finally { 171 - client.release(); 172 - } 36 + const result = await mergeEmailIdentityIntoAtpIdentity({ 37 + sourceId: source.identity.id, 38 + targetId: target.identity.id, 39 + }); 40 + if (!result.ok) return result; 173 41 174 42 await setAuthToken(pendingTokenId!); 175 43 await removePendingMergeToken();
+26
appview/index.ts
··· 15 15 SiteStandardDocument, 16 16 SiteStandardPublication, 17 17 SiteStandardGraphSubscription, 18 + SiteStandardGraphRecommend, 18 19 } from "lexicons/api"; 19 20 import { 20 21 AppBskyEmbedExternal, ··· 55 56 ids.SiteStandardDocument, 56 57 ids.SiteStandardPublication, 57 58 ids.SiteStandardGraphSubscription, 59 + ids.SiteStandardGraphRecommend, 58 60 "parts.page.mention.service", 59 61 "parts.page.mention.config", 60 62 ], ··· 336 338 if (evt.event === "delete") { 337 339 await supabase 338 340 .from("publications") 341 + .delete() 342 + .eq("uri", evt.uri.toString()); 343 + } 344 + } 345 + 346 + // site.standard.graph.recommend records go into the main "recommends_on_documents" table 347 + if (evt.collection === ids.SiteStandardGraphRecommend) { 348 + if (evt.event === "create" || evt.event === "update") { 349 + let record = SiteStandardGraphRecommend.validateRecord(evt.record); 350 + if (!record.success) return; 351 + await supabase 352 + .from("identities") 353 + .upsert({ atp_did: evt.did }, { onConflict: "atp_did" }); 354 + let { error } = await supabase.from("recommends_on_documents").upsert({ 355 + uri: evt.uri.toString(), 356 + recommender_did: evt.did, 357 + document: record.value.document, 358 + record: record.value as Json, 359 + }); 360 + if (error) console.log("Error upserting recommend:", error); 361 + } 362 + if (evt.event === "delete") { 363 + await supabase 364 + .from("recommends_on_documents") 339 365 .delete() 340 366 .eq("uri", evt.uri.toString()); 341 367 }
+10 -2
components/LoginButton.tsx
··· 24 24 25 25 export const LoginModal = (props: { 26 26 noEmailLogin?: boolean; 27 - trigger: React.ReactNode; 27 + trigger?: React.ReactNode; 28 28 asChild?: boolean; 29 29 redirectRoute?: string; 30 + open?: boolean; 31 + onOpenChange?: (open: boolean) => void; 30 32 }) => { 31 - let [open, setOpen] = useState(false); 33 + let [internalOpen, setInternalOpen] = useState(false); 34 + let isControlled = props.open !== undefined; 35 + let open = props.open ?? internalOpen; 36 + let setOpen = (o: boolean) => { 37 + if (!isControlled) setInternalOpen(o); 38 + props.onOpenChange?.(o); 39 + }; 32 40 return ( 33 41 <Modal 34 42 asChild={props.asChild}
+2 -3
components/Modal.tsx
··· 51 51 } 52 52 className={` 53 53 z-50 fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 54 - overflow-y-scroll no-scrollbar max-w-[calc(100vw-32px)] h-fit max-h-[calc(100dvh-32px)] p-3 flex flex-col w-full sm:w-max 55 - 54 + overflow-y-scroll no-scrollbar max-w-[calc(100vw-32px)] h-fit max-h-[calc(100dvh-32px)] p-3 flex flex-col w-full sm:w-max text-primary 56 55 `} 57 56 > 58 57 <Dialog.Close className="bg-bg-page rounded-full -mb-3 mr-2 z-10 w-fit p-1 place-self-end border border-border-light text-tertiary"> ··· 66 65 > 67 66 {title ? ( 68 67 <Dialog.Title asChild> 69 - <h3 className="pb-1">{title}</h3> 68 + <h3 className="pb-1 text-primary">{title}</h3> 70 69 </Dialog.Title> 71 70 ) : ( 72 71 <Dialog.Title />
+47 -35
components/RecommendButton.tsx
··· 13 13 import { OAuthErrorMessage, isOAuthSessionError } from "./OAuthError"; 14 14 import { ButtonSecondary } from "./Buttons"; 15 15 import { Separator } from "./Layout"; 16 + import { useIdentityData } from "./IdentityProvider"; 17 + import { LoginModal } from "./LoginButton"; 16 18 17 19 // Create a batcher for recommendation checks 18 20 // Batches requests made within 10ms window ··· 61 63 const { hasRecommended, isLoading } = useUserRecommendation( 62 64 props.documentUri, 63 65 ); 66 + const { identity } = useIdentityData(); 64 67 const [count, setCount] = useState(props.recommendsCount); 65 68 const [isPending, setIsPending] = useState(false); 66 69 const [optimisticRecommended, setOptimisticRecommended] = useState< 67 70 boolean | null 68 71 >(null); 72 + const [loginOpen, setLoginOpen] = useState(false); 69 73 const toaster = useToaster(); 70 74 const smoker = useSmoker(); 71 75 ··· 75 79 76 80 const handleClick = async (e: React.MouseEvent) => { 77 81 if (isPending || isLoading) return; 82 + if (!identity?.atp_did) { 83 + setLoginOpen(true); 84 + return; 85 + } 78 86 79 87 const currentlyRecommended = displayRecommended; 80 88 setIsPending(true); ··· 117 125 setIsPending(false); 118 126 }; 119 127 120 - if (props.expanded) 121 - return ( 122 - <ButtonSecondary 123 - onClick={(e) => { 124 - e.preventDefault(); 125 - e.stopPropagation(); 126 - handleClick(e); 127 - }} 128 - > 129 - {displayRecommended ? ( 130 - <RecommendTinyFilled className="text-accent-contrast" /> 131 - ) : ( 132 - <RecommendTinyEmpty /> 133 - )} 134 - <div className="flex gap-2 items-center"> 135 - {count > 0 && ( 136 - <> 137 - <span 138 - className={`${displayRecommended && "text-accent-contrast"}`} 139 - > 140 - {count} 141 - </span> 142 - <Separator classname="h-4! text-accent-contrast!" /> 143 - </> 144 - )} 145 - {displayRecommended ? "Recommended!" : "Recommend"} 146 - </div> 147 - </ButtonSecondary> 148 - ); 128 + const onClick = (e: React.MouseEvent) => { 129 + e.preventDefault(); 130 + e.stopPropagation(); 131 + handleClick(e); 132 + }; 149 133 150 - return ( 134 + const button = props.expanded ? ( 135 + <ButtonSecondary onClick={onClick}> 136 + {displayRecommended ? ( 137 + <RecommendTinyFilled className="text-accent-contrast" /> 138 + ) : ( 139 + <RecommendTinyEmpty /> 140 + )} 141 + <div className="flex gap-2 items-center"> 142 + {count > 0 && ( 143 + <> 144 + <span className={`${displayRecommended && "text-accent-contrast"}`}> 145 + {count} 146 + </span> 147 + <Separator classname="h-4! text-accent-contrast!" /> 148 + </> 149 + )} 150 + {displayRecommended ? "Recommended!" : "Recommend"} 151 + </div> 152 + </ButtonSecondary> 153 + ) : ( 151 154 <button 152 - onClick={(e) => { 153 - e.preventDefault(); 154 - e.stopPropagation(); 155 - handleClick(e); 156 - }} 155 + onClick={onClick} 157 156 className={`recommendButton relative flex gap-1 items-center hover:text-accent-contrast ${props.className || ""}`} 158 157 aria-label={displayRecommended ? "Remove recommend" : "Recommend"} 159 158 > ··· 168 167 </span> 169 168 )} 170 169 </button> 170 + ); 171 + 172 + return ( 173 + <> 174 + {button} 175 + {loginOpen && ( 176 + <LoginModal 177 + noEmailLogin 178 + open={loginOpen} 179 + onOpenChange={setLoginOpen} 180 + /> 181 + )} 182 + </> 171 183 ); 172 184 }
+4 -1
components/SubscriptionSuccessModal.tsx
··· 62 62 We're looking forward to bringing you even cooler stuff in the near 63 63 future! 64 64 </p> 65 - <ButtonPrimary className="mx-auto mb-2"> 65 + <ButtonPrimary 66 + className="mx-auto mb-2" 67 + onClick={() => handleOpenChange(false)} 68 + > 66 69 Got it, Thanks! 67 70 </ButtonPrimary> 68 71 </div>
+19 -1
drizzle/relations.ts
··· 1 1 import { relations } from "drizzle-orm/relations"; 2 - import { identities, notifications, publications, documents, comments_on_documents, bsky_profiles, entity_sets, entities, facts, email_auth_tokens, recommends_on_documents, poll_votes_on_entity, permission_tokens, user_subscriptions, phone_rsvps_to_entity, site_standard_publications, custom_domains, custom_domain_routes, site_standard_documents, email_subscriptions_to_entity, atp_poll_records, atp_poll_votes, publication_newsletter_settings, publication_email_subscribers, publication_email_subscriber_events, bsky_follows, site_standard_documents_in_publications, documents_in_publications, document_mentions_in_bsky, bsky_posts, permission_token_on_homepage, publication_domains, publication_subscriptions, site_standard_subscriptions, user_entitlements, permission_token_rights, publication_post_sends, leaflets_to_documents, leaflets_in_publications } from "./schema"; 2 + import { identities, notifications, publications, documents, comments_on_documents, bsky_profiles, entity_sets, entities, facts, email_auth_tokens, recommends_on_documents, poll_votes_on_entity, permission_tokens, user_subscriptions, phone_rsvps_to_entity, site_standard_publications, custom_domains, custom_domain_routes, site_standard_documents, email_subscriptions_to_entity, atp_poll_records, atp_poll_votes, publication_newsletter_settings, publication_email_subscribers, publication_email_subscriber_events, bsky_follows, site_standard_documents_in_publications, documents_in_publications, document_mentions_in_bsky, bsky_posts, permission_token_on_homepage, publication_domains, publication_subscriptions, site_standard_subscriptions, user_entitlements, permission_token_rights, publication_post_sends, publication_pages, leaflets_to_documents, leaflets_in_publications } from "./schema"; 3 3 4 4 export const notificationsRelations = relations(notifications, ({one}) => ({ 5 5 identity: one(identities, { ··· 53 53 publication_domains: many(publication_domains), 54 54 publication_subscriptions: many(publication_subscriptions), 55 55 publication_post_sends: many(publication_post_sends), 56 + publication_pages: many(publication_pages), 56 57 leaflets_in_publications: many(leaflets_in_publications), 57 58 })); 58 59 ··· 73 74 documents_in_publications: many(documents_in_publications), 74 75 document_mentions_in_bskies: many(document_mentions_in_bsky), 75 76 publication_post_sends: many(publication_post_sends), 77 + publication_pages: many(publication_pages), 76 78 leaflets_to_documents: many(leaflets_to_documents), 77 79 leaflets_in_publications: many(leaflets_in_publications), 78 80 })); ··· 160 162 email_subscriptions_to_entities: many(email_subscriptions_to_entity), 161 163 permission_token_on_homepages: many(permission_token_on_homepage), 162 164 permission_token_rights: many(permission_token_rights), 165 + publication_pages: many(publication_pages), 163 166 leaflets_to_documents: many(leaflets_to_documents), 164 167 leaflets_in_publications: many(leaflets_in_publications), 165 168 })); ··· 402 405 }), 403 406 publication: one(publications, { 404 407 fields: [publication_post_sends.publication], 408 + references: [publications.uri] 409 + }), 410 + })); 411 + 412 + export const publication_pagesRelations = relations(publication_pages, ({one}) => ({ 413 + document: one(documents, { 414 + fields: [publication_pages.document], 415 + references: [documents.uri] 416 + }), 417 + permission_token: one(permission_tokens, { 418 + fields: [publication_pages.leaflet_src], 419 + references: [permission_tokens.id] 420 + }), 421 + publication: one(publications, { 422 + fields: [publication_pages.publication], 405 423 references: [publications.uri] 406 424 }), 407 425 }));
+21
drizzle/schema.ts
··· 527 527 } 528 528 }); 529 529 530 + export const publication_pages = pgTable("publication_pages", { 531 + // You can use { mode: "bigint" } if numbers are exceeding js number limitations 532 + id: bigint("id", { mode: "number" }).notNull(), 533 + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 534 + leaflet_src: uuid("leaflet_src").notNull().references(() => permission_tokens.id), 535 + document: text("document").references(() => documents.uri), 536 + path: text("path"), 537 + publication: text("publication").notNull().references(() => publications.uri, { onDelete: "cascade", onUpdate: "cascade" } ), 538 + title: text("title").default('').notNull(), 539 + metadata: jsonb("metadata").default({}).notNull(), 540 + }, 541 + (table) => { 542 + return { 543 + publication_pages_pkey: primaryKey({ columns: [table.id, table.publication], name: "publication_pages_pkey"}), 544 + } 545 + }); 546 + 530 547 export const leaflets_to_documents = pgTable("leaflets_to_documents", { 531 548 leaflet: uuid("leaflet").notNull().references(() => permission_tokens.id, { onDelete: "cascade", onUpdate: "cascade" } ), 532 549 document: text("document").notNull().references(() => documents.uri, { onDelete: "cascade", onUpdate: "cascade" } ), ··· 536 553 tags: text("tags").default('RRAY[').array(), 537 554 cover_image: text("cover_image"), 538 555 preferences: jsonb("preferences"), 556 + scheduled_publish_at: timestamp("scheduled_publish_at", { withTimezone: true, mode: 'string' }), 557 + scheduled_publish_data: jsonb("scheduled_publish_data"), 539 558 }, 540 559 (table) => { 541 560 return { ··· 553 572 tags: text("tags").default('RRAY[').array(), 554 573 cover_image: text("cover_image"), 555 574 preferences: jsonb("preferences"), 575 + scheduled_publish_at: timestamp("scheduled_publish_at", { withTimezone: true, mode: 'string' }), 576 + scheduled_publish_data: jsonb("scheduled_publish_data"), 556 577 }, 557 578 (table) => { 558 579 return {
+1
emails/fromPublication.ts
··· 41 41 headingFont: getFontFamilyValue(getFontConfig(theme.headingFont)), 42 42 bodyFont: getFontFamilyValue(getFontConfig(theme.bodyFont)), 43 43 pageWidth: theme.pageWidth ?? defaultEmailTheme.pageWidth, 44 + showPageBackground: theme.showPageBackground ?? false, 44 45 }; 45 46 }; 46 47
+6 -2
emails/leafletConfirmEmail.tsx
··· 17 17 export const LeafletConfirmEmail = (props: { 18 18 code?: string; 19 19 assetsBaseUrl?: string; 20 + title?: string; 21 + message?: string; 20 22 }) => { 21 23 const leafletSrc = makeStaticUrl( 22 24 props.assetsBaseUrl ?? "https://leaflet.pub", 23 25 )("leaflet.png"); 26 + const title = props.title ?? "Welcome to Leaflet!"; 27 + const message = props.message ?? "Verify your email with this code"; 24 28 return ( 25 29 <Html> 26 30 <Tailwind config={confirmEmailTailwindConfig}> ··· 42 46 43 47 <Column> 44 48 <Heading noPadding as="h2"> 45 - Welcome to Leaflet!{" "} 49 + {title}{" "} 46 50 </Heading> 47 51 </Column> 48 52 </Row> 49 53 <Text className="!mt-6 !mb-1 text-secondary"> 50 - Verify your email with this code 54 + {message} 51 55 </Text> 52 56 <Container className="bg-border-light rounded-md w-fit px-2 py-1"> 53 57 <Heading noPadding as="h1">
+393 -234
emails/post.tsx
··· 16 16 import React, { type CSSProperties } from "react"; 17 17 import { 18 18 PubLeafletBlocksBlockquote, 19 + PubLeafletBlocksButton, 19 20 PubLeafletBlocksCode, 20 21 PubLeafletBlocksHeader, 21 22 PubLeafletBlocksHorizontalRule, ··· 27 28 PubLeafletPagesLinearDocument, 28 29 } from "lexicons/api"; 29 30 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 31 + import { normalizePublicationRecord } from "src/utils/normalizeRecords"; 32 + import { supabaseServerClient } from "supabase/serverClient"; 33 + import { emailPropsFromPublication } from "./fromPublication"; 30 34 import { 31 35 bgcolorAttr, 32 36 defaultEmailTheme, ··· 88 92 publicationName: "Publication", 89 93 publicationUrl: "https://leaflet.pub", 90 94 postTitle: "Post Title Here", 91 - postDescription: 92 - "Hello this is a description of everything that is to come", 95 + postDescription: "Hello this is a description of everything that is to come", 93 96 postUrl: "https://leaflet.pub", 94 97 authorName: "author", 95 98 publishedAtLabel: "Jan 1, 2026", ··· 161 164 }, 162 165 { 163 166 $type: "pub.leaflet.pages.linearDocument#block", 167 + block: { 168 + $type: "pub.leaflet.blocks.button", 169 + text: "Click me", 170 + url: "https://leaflet.pub", 171 + }, 172 + }, 173 + { 174 + $type: "pub.leaflet.pages.linearDocument#block", 175 + alignment: "lex:pub.leaflet.pages.linearDocument#textAlignLeft", 176 + block: { 177 + $type: "pub.leaflet.blocks.button", 178 + text: "Aligned left", 179 + url: "https://leaflet.pub", 180 + }, 181 + }, 182 + { 183 + $type: "pub.leaflet.pages.linearDocument#block", 184 + alignment: "lex:pub.leaflet.pages.linearDocument#textAlignRight", 185 + block: { 186 + $type: "pub.leaflet.blocks.button", 187 + text: "Aligned right", 188 + url: "https://leaflet.pub", 189 + }, 190 + }, 191 + { 192 + $type: "pub.leaflet.pages.linearDocument#block", 164 193 block: { $type: "pub.leaflet.blocks.horizontalRule" }, 165 194 }, 166 195 { ··· 206 235 @media only screen and (max-width: 480px) { 207 236 .email-page-pad { padding: 12px 8px !important; } 208 237 .email-card-pad { padding: 16px !important; } 209 - /* The card table carries an HTML width attribute (e.g. 624) so 210 - Gmail anchors on it; on mobile that pins it wider than the 211 - viewport. Force it to fit. */ 212 - .email-card-table { 213 - width: 100% !important; 214 - max-width: 100% !important; 215 - } 216 238 } 217 239 `}</style> 218 240 </MailHead> ··· 252 274 padding: "24px 16px", 253 275 }} 254 276 > 255 - {/* Both the HTML `width` attribute and CSS `width` are set: 256 - Gmail strips/ignores `max-width` in some contexts but 257 - always honors the HTML attribute, so it pins the box at 258 - the publication's web page width on desktop. The 259 - media-query rule above forces 100% on small screens. */} 277 + {/* Responsive width: `width="100%"` lets the card fill its 278 + container, and `max-width: pageWidth` caps it on wider 279 + viewports. This keeps it readable at the publication's 280 + page width on desktop while shrinking gracefully when 281 + the email is viewed in a narrow window (e.g. Apple Mail 282 + desktop resized below the page width). Outlook desktop 283 + ignores `max-width` and will render full-width — that's 284 + a known tradeoff for the simpler markup. */} 260 285 <table 261 286 role="presentation" 262 287 align="center" 263 288 className="email-card-table" 264 - width={theme.pageWidth} 289 + width="100%" 265 290 cellPadding={0} 266 291 cellSpacing={0} 267 292 border={0} 268 - style={{ width: theme.pageWidth, maxWidth: "100%" }} 293 + style={{ 294 + width: "100%", 295 + maxWidth: theme.pageWidth, 296 + margin: "0 auto", 297 + }} 269 298 > 270 299 <tbody> 271 300 <tr> 272 301 <td 273 302 className="email-card-pad" 274 - {...bgcolorAttr(theme.pageBackground)} 303 + {...(theme.showPageBackground 304 + ? bgcolorAttr(theme.pageBackground) 305 + : {})} 275 306 style={{ 276 - backgroundColor: theme.pageBackground, 277 - border: `1px solid ${c.border}`, 307 + backgroundColor: theme.showPageBackground 308 + ? theme.pageBackground 309 + : undefined, 310 + border: theme.showPageBackground 311 + ? `1px solid ${c.border}` 312 + : undefined, 278 313 borderRadius: 8, 279 314 padding: "20px 24px", 280 315 }} 281 316 > 282 - <Link 283 - href={p.publicationUrl} 284 - style={{ 285 - ...accentLink, 286 - fontWeight: "bold", 287 - fontSize: 16, 288 - }} 289 - > 290 - {p.publicationName} 291 - </Link> 292 - 293 - <ReactEmailHeading 294 - as="h1" 295 - style={{ 296 - color: theme.primary, 297 - fontFamily: theme.headingFont, 298 - fontWeight: "bold", 299 - fontSize: 26, 300 - lineHeight: 1.2, 301 - margin: "8px 0 0", 302 - }} 303 - > 304 - {p.postTitle} 305 - </ReactEmailHeading> 306 - 307 - {p.postDescription ? ( 308 - <ReactEmailText 309 - style={{ 310 - color: c.secondary, 311 - fontFamily: theme.bodyFont, 312 - fontSize: 16, 313 - fontStyle: "italic", 314 - lineHeight: 1.4, 315 - margin: "4px 0 0", 316 - }} 317 - > 318 - {p.postDescription} 319 - </ReactEmailText> 320 - ) : null} 321 - 322 - {byline ? ( 323 - <Section 324 - style={{ margin: "12px 0 28px", minWidth: "100%" }} 325 - > 326 - <Row style={{ minWidth: "100%" }}> 327 - <Column style={{ verticalAlign: "middle" }}> 328 - <ReactEmailText 317 + <Link 318 + href={p.publicationUrl} 329 319 style={{ 330 - color: c.tertiary, 331 - fontFamily: theme.bodyFont, 332 - fontSize: 14, 333 - lineHeight: 1.4, 334 - margin: 0, 320 + ...accentLink, 321 + fontWeight: "bold", 322 + fontSize: 16, 335 323 }} 336 324 > 337 - {byline} 338 - </ReactEmailText> 339 - </Column> 340 - <Column style={{ width: 12 }} /> 341 - <Column style={{ width: 16, verticalAlign: "middle" }}> 342 - <Link 343 - href={drawerUrl(p.postUrl, "quotes")} 344 - style={accentLink} 345 - > 346 - <Img 347 - width={16} 348 - height={16} 349 - src={staticUrl("quote.png")} 350 - alt="See quotes" 351 - /> 325 + {p.publicationName} 352 326 </Link> 353 - </Column> 354 - <Column style={{ width: 8 }} /> 355 - <Column style={{ width: 16, verticalAlign: "middle" }}> 356 - <Link 357 - href={drawerUrl(p.postUrl, "comments")} 358 - style={accentLink} 359 - > 360 - <Img 361 - width={16} 362 - height={16} 363 - src={staticUrl("comment.png")} 364 - alt="See comments" 365 - /> 366 - </Link> 367 - </Column> 368 - <Column style={{ width: 10 }} /> 369 - <Column style={{ width: 16, verticalAlign: "middle" }}> 370 - <Link href={p.postUrl} style={accentLink}> 371 - <Img 372 - width={16} 373 - height={16} 374 - src={staticUrl("external-link.png")} 375 - alt="Open post" 376 - /> 377 - </Link> 378 - </Column> 379 - </Row> 380 - </Section> 381 - ) : null} 382 327 383 - {p.blocks.map((b, i) => ( 384 - <BlockRenderer 385 - key={i} 386 - block={b.block} 387 - did={p.did} 388 - assetsBaseUrl={p.assetsBaseUrl} 389 - theme={theme} 390 - colors={c} 391 - /> 392 - ))} 328 + <ReactEmailHeading 329 + as="h1" 330 + style={{ 331 + color: theme.primary, 332 + fontFamily: theme.headingFont, 333 + fontWeight: "bold", 334 + fontSize: 26, 335 + lineHeight: 1.2, 336 + margin: "8px 0 0", 337 + }} 338 + > 339 + <Link 340 + href={p.postUrl} 341 + style={{ 342 + color: theme.primary, 343 + fontFamily: theme.headingFont, 344 + textDecoration: "none", 345 + }} 346 + > 347 + {p.postTitle} 348 + </Link> 349 + </ReactEmailHeading> 393 350 394 - {/* Footer: Gmail won't reliably cascade `text-align` from a 351 + {p.postDescription ? ( 352 + <ReactEmailText 353 + style={{ 354 + color: c.secondary, 355 + fontFamily: theme.bodyFont, 356 + fontSize: 16, 357 + fontStyle: "italic", 358 + lineHeight: 1.4, 359 + margin: "4px 0 0", 360 + }} 361 + > 362 + {p.postDescription} 363 + </ReactEmailText> 364 + ) : null} 365 + 366 + {byline ? ( 367 + <Section 368 + style={{ margin: "12px 0 28px", minWidth: "100%" }} 369 + > 370 + <Row style={{ minWidth: "100%" }}> 371 + <Column style={{ verticalAlign: "middle" }}> 372 + <ReactEmailText 373 + style={{ 374 + color: c.tertiary, 375 + fontFamily: theme.bodyFont, 376 + fontSize: 14, 377 + lineHeight: 1.4, 378 + margin: 0, 379 + }} 380 + > 381 + {byline} 382 + </ReactEmailText> 383 + </Column> 384 + <Column style={{ width: 12 }} /> 385 + <Column 386 + style={{ width: 16, verticalAlign: "middle" }} 387 + > 388 + <Link 389 + href={drawerUrl(p.postUrl, "quotes")} 390 + style={accentLink} 391 + > 392 + <Img 393 + width={16} 394 + height={16} 395 + src={staticUrl("quote.png")} 396 + alt="See quotes" 397 + /> 398 + </Link> 399 + </Column> 400 + <Column style={{ width: 8 }} /> 401 + <Column 402 + style={{ width: 16, verticalAlign: "middle" }} 403 + > 404 + <Link 405 + href={drawerUrl(p.postUrl, "comments")} 406 + style={accentLink} 407 + > 408 + <Img 409 + width={16} 410 + height={16} 411 + src={staticUrl("comment.png")} 412 + alt="See comments" 413 + /> 414 + </Link> 415 + </Column> 416 + </Row> 417 + </Section> 418 + ) : null} 419 + 420 + {p.blocks.map((b, i) => ( 421 + <BlockRenderer 422 + key={i} 423 + block={b.block} 424 + alignment={b.alignment} 425 + did={p.did} 426 + assetsBaseUrl={p.assetsBaseUrl} 427 + theme={theme} 428 + colors={c} 429 + /> 430 + ))} 431 + 432 + {/* Footer: Gmail won't reliably cascade `text-align` from a 395 433 wrapping <table>, so each centered row is its own <td 396 434 align="center"> — the bulletproof email-centering 397 435 pattern. `min-width: 100%` keeps Gmail iOS from 398 436 shrink-wrapping the table around the short link text. */} 399 - <table 400 - role="presentation" 401 - width="100%" 402 - cellPadding={0} 403 - cellSpacing={0} 404 - border={0} 405 - style={{ width: "100%", minWidth: "100%" }} 406 - > 407 - <tbody> 408 - <tr> 409 - <td align="center" style={{ paddingTop: 16 }}> 410 - <Link 411 - href={p.postUrl} 412 - style={{ 413 - ...accentLink, 414 - fontWeight: "bold", 415 - fontSize: 14, 416 - lineHeight: "20px", 417 - }} 437 + <table 438 + role="presentation" 439 + width="100%" 440 + cellPadding={0} 441 + cellSpacing={0} 442 + border={0} 443 + style={{ width: "100%", minWidth: "100%" }} 418 444 > 419 - See Full Post 420 - </Link> 421 - </td> 422 - </tr> 423 - <tr> 424 - <td 425 - align="center" 426 - style={{ 427 - color: c.tertiary, 428 - fontFamily: theme.bodyFont, 429 - fontSize: 14, 430 - lineHeight: "20px", 431 - paddingTop: 8, 432 - }} 433 - > 434 - {p.unsubscribeUrl ? ( 435 - <Link 436 - href={p.unsubscribeUrl} 437 - style={{ 438 - color: c.tertiary, 439 - fontSize: 14, 440 - lineHeight: "20px", 441 - textDecoration: "underline", 442 - }} 443 - > 444 - Unsubscribe 445 - </Link> 446 - ) : ( 447 - <span style={{ fontStyle: "italic" }}> 448 - (preview — not sent to subscribers) 449 - </span> 450 - )} 451 - </td> 452 - </tr> 453 - </tbody> 454 - </table> 455 - </td> 456 - </tr> 445 + <tbody> 446 + <tr> 447 + <td align="center" style={{ paddingTop: 16 }}> 448 + <Link 449 + href={p.postUrl} 450 + style={{ 451 + ...accentLink, 452 + fontWeight: "bold", 453 + fontSize: 14, 454 + lineHeight: "20px", 455 + }} 456 + > 457 + Read in Browser 458 + </Link> 459 + </td> 460 + </tr> 461 + <tr> 462 + <td 463 + align="center" 464 + style={{ 465 + color: c.tertiary, 466 + fontFamily: theme.bodyFont, 467 + fontSize: 14, 468 + lineHeight: "20px", 469 + paddingTop: 8, 470 + }} 471 + > 472 + {p.unsubscribeUrl ? ( 473 + <Link 474 + href={p.unsubscribeUrl} 475 + style={{ 476 + color: c.tertiary, 477 + fontSize: 14, 478 + lineHeight: "20px", 479 + textDecoration: "underline", 480 + }} 481 + > 482 + Unsubscribe 483 + </Link> 484 + ) : ( 485 + <span style={{ fontStyle: "italic" }}> 486 + (preview — not sent to subscribers) 487 + </span> 488 + )} 489 + </td> 490 + </tr> 457 491 458 - {/* Spacer */} 459 - <tr> 460 - <td 461 - style={{ 462 - fontSize: 0, 463 - height: 12, 464 - lineHeight: "12px", 465 - }} 466 - > 467 - &nbsp; 468 - </td> 469 - </tr> 492 + {/* Spacer */} 493 + <tr> 494 + <td 495 + style={{ 496 + fontSize: 0, 497 + height: 16, 498 + lineHeight: "16px", 499 + }} 500 + > 501 + &nbsp; 502 + </td> 503 + </tr> 470 504 471 - {/* Horizontal rule between card and watermark. <hr> 472 - margins are flaky in Gmail, so we use a 1px-tall 473 - <td> with border-top instead. */} 474 - <tr> 475 - <td 476 - style={{ 477 - borderTop: `1px solid ${c.borderLight}`, 478 - fontSize: 0, 479 - height: 1, 480 - lineHeight: "1px", 481 - }} 482 - > 483 - &nbsp; 484 - </td> 485 - </tr> 505 + {/* Horizontal rule above watermark. <hr> margins are 506 + flaky in Gmail, so we use a 1px-tall <td> with 507 + border-top instead. */} 508 + <tr> 509 + <td 510 + style={{ 511 + borderTop: `1px solid ${c.borderLight}`, 512 + fontSize: 0, 513 + height: 1, 514 + lineHeight: "1px", 515 + }} 516 + > 517 + &nbsp; 518 + </td> 519 + </tr> 486 520 487 - <tr> 488 - <td 489 - style={{ 490 - fontSize: 0, 491 - height: 12, 492 - lineHeight: "12px", 493 - }} 494 - > 495 - &nbsp; 496 - </td> 497 - </tr> 521 + <tr> 522 + <td 523 + style={{ 524 + fontSize: 0, 525 + height: 12, 526 + lineHeight: "12px", 527 + }} 528 + > 529 + &nbsp; 530 + </td> 531 + </tr> 498 532 499 - <tr> 500 - <td align="center"> 501 - <LeafletWatermark 502 - theme={theme} 503 - staticUrl={staticUrl} 504 - /> 533 + <tr> 534 + <td align="center"> 535 + <LeafletWatermark 536 + theme={theme} 537 + staticUrl={staticUrl} 538 + /> 539 + </td> 540 + </tr> 541 + </tbody> 542 + </table> 505 543 </td> 506 544 </tr> 507 545 </tbody> ··· 514 552 </Html> 515 553 ); 516 554 }; 517 - export default PostEmail; 555 + // `npm run email:dev` renders this default export. Set 556 + // `PREVIEW_PUBLICATION_URI` below to an `at://did:plc:.../pub.leaflet.publication/<rkey>` 557 + // to preview the post template with that publication's name, url, and theme — 558 + // matching what `send_post_broadcast` and `sendPostPreview` resolve at send time. 559 + // Leave it null to render with the static default props. 560 + const PREVIEW_PUBLICATION_URI: string | null = 561 + "at://did:plc:x2xmijn2egk5g67u3cwkddzy/site.standard.publication/3m2avdoogvs2f"; 562 + 563 + const PostEmailPreview = async () => { 564 + if (!PREVIEW_PUBLICATION_URI) return <PostEmail />; 565 + 566 + const { data: publication, error } = await supabaseServerClient 567 + .from("publications") 568 + .select("record") 569 + .eq("uri", PREVIEW_PUBLICATION_URI) 570 + .maybeSingle(); 571 + 572 + if (error || !publication) { 573 + console.warn( 574 + `[email preview] could not load publication ${PREVIEW_PUBLICATION_URI}:`, 575 + error?.message ?? "not found", 576 + ); 577 + return <PostEmail />; 578 + } 579 + 580 + const pubProps = emailPropsFromPublication( 581 + normalizePublicationRecord(publication.record), 582 + ); 583 + return <PostEmail {...pubProps} />; 584 + }; 585 + 586 + export default PostEmailPreview; 587 + 588 + // Map the lexicon's alignment token to the simple left/center/right values 589 + // usable as HTML `align` attributes. `justify` falls through to `left` to 590 + // match published web behavior (`justify-start` flex). For buttons we 591 + // default to `center` when alignment is unset, matching PostContent.tsx. 592 + const resolveButtonAlignment = ( 593 + alignment: string | undefined, 594 + ): "left" | "center" | "right" => { 595 + switch (alignment) { 596 + case "lex:pub.leaflet.pages.linearDocument#textAlignRight": 597 + return "right"; 598 + case "lex:pub.leaflet.pages.linearDocument#textAlignLeft": 599 + case "lex:pub.leaflet.pages.linearDocument#textAlignJustify": 600 + return "left"; 601 + case "lex:pub.leaflet.pages.linearDocument#textAlignCenter": 602 + default: 603 + return "center"; 604 + } 605 + }; 518 606 519 607 const BlockRenderer = ({ 520 608 block, 609 + alignment, 521 610 did, 522 611 assetsBaseUrl, 523 612 theme, 524 613 colors, 525 614 }: { 526 615 block: PubLeafletPagesLinearDocument.Block["block"]; 616 + alignment?: string; 527 617 did: string; 528 618 assetsBaseUrl: string; 529 619 theme: EmailTheme; ··· 545 635 ); 546 636 } 547 637 if (PubLeafletBlocksHeader.isMain(block)) { 548 - const level = Math.min( 549 - 3, 550 - Math.max(1, Math.floor(block.level ?? 1)), 551 - ) as 1 | 2 | 3; 638 + const level = Math.min(3, Math.max(1, Math.floor(block.level ?? 1))) as 639 + | 1 640 + | 2 641 + | 3; 552 642 return ( 553 643 <ReactEmailHeading 554 644 as={`h${level}`} ··· 633 723 /> 634 724 ); 635 725 } 726 + if (PubLeafletBlocksButton.isMain(block)) { 727 + return ( 728 + <ButtonBlock 729 + text={block.text} 730 + url={block.url} 731 + align={resolveButtonAlignment(alignment)} 732 + theme={theme} 733 + /> 734 + ); 735 + } 636 736 if (PubLeafletBlocksHorizontalRule.isMain(block)) { 637 737 return ( 638 738 <Hr ··· 704 804 className?: string; 705 805 style?: CSSProperties; 706 806 }) => { 707 - const fontSize = 708 - props.as === "h1" ? 26 : props.as === "h2" ? 18 : 16; 807 + const fontSize = props.as === "h1" ? 26 : props.as === "h2" ? 18 : 16; 709 808 return ( 710 809 <ReactEmailHeading 711 810 as={props.as} ··· 910 1009 </Column> 911 1010 ) : null} 912 1011 </Row> 1012 + </Section> 1013 + ); 1014 + }; 1015 + 1016 + export const ButtonBlock = ({ 1017 + text, 1018 + url, 1019 + align = "center", 1020 + theme = defaultEmailTheme, 1021 + }: { 1022 + text: string; 1023 + url: string; 1024 + align?: "left" | "center" | "right"; 1025 + theme?: EmailTheme; 1026 + }) => { 1027 + // Bulletproof button: table-based so Outlook (which ignores padding on 1028 + // <a>) renders a real clickable button. The `<td>` carries the bgcolor 1029 + // attribute and padding; the `<a>` is `display: block` so the entire 1030 + // padded area is clickable. Alignment via the table's `align` HTML 1031 + // attribute — Gmail won't reliably cascade `text-align` from a wrapping 1032 + // <Section>, so we anchor on the table itself. 1033 + return ( 1034 + <Section style={{ margin: BLOCK_MARGIN, minWidth: "100%" }}> 1035 + <table 1036 + role="presentation" 1037 + align={align} 1038 + cellPadding={0} 1039 + cellSpacing={0} 1040 + border={0} 1041 + style={{ borderCollapse: "separate" }} 1042 + > 1043 + <tbody> 1044 + <tr> 1045 + <td 1046 + align="center" 1047 + {...bgcolorAttr(theme.accentBackground)} 1048 + style={{ 1049 + backgroundColor: theme.accentBackground, 1050 + borderRadius: 6, 1051 + padding: "10px 20px", 1052 + }} 1053 + > 1054 + <Link 1055 + href={url} 1056 + style={{ 1057 + color: theme.accentText, 1058 + display: "block", 1059 + fontFamily: theme.bodyFont, 1060 + fontSize: 16, 1061 + fontWeight: "bold", 1062 + lineHeight: "20px", 1063 + textDecoration: "none", 1064 + }} 1065 + > 1066 + {text} 1067 + </Link> 1068 + </td> 1069 + </tr> 1070 + </tbody> 1071 + </table> 913 1072 </Section> 914 1073 ); 915 1074 };
+27 -36
emails/shared.tsx
··· 1 - import { 2 - Head, 3 - Img, 4 - Link, 5 - pixelBasedPreset, 6 - } from "@react-email/components"; 1 + import { Head, Img, Link, pixelBasedPreset } from "@react-email/components"; 7 2 import React from "react"; 8 3 9 4 export type EmailTheme = { ··· 18 13 // post at the same column width in their inbox as on the live page. 19 14 // Default 624 mirrors `ThemeProvider.tsx`'s fallback. 20 15 pageWidth: number; 16 + showPageBackground: boolean; 21 17 }; 22 18 23 19 export const defaultEmailTheme: EmailTheme = { ··· 29 25 headingFont: "Georgia, serif", 30 26 bodyFont: "Verdana, sans-serif", 31 27 pageWidth: 624, 28 + showPageBackground: true, 32 29 }; 33 30 34 31 // Parse rgb()/rgba()/#hex into [r, g, b]. Returns black on parse failure — 35 32 // theme colors come from a typed config so this is just defensive. 36 33 const parseColor = (input: string): [number, number, number] => { 37 - const rgbMatch = input.match( 38 - /rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/i, 39 - ); 34 + const rgbMatch = input.match(/rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/i); 40 35 if (rgbMatch) 41 - return [ 42 - Number(rgbMatch[1]), 43 - Number(rgbMatch[2]), 44 - Number(rgbMatch[3]), 45 - ]; 36 + return [Number(rgbMatch[1]), Number(rgbMatch[2]), Number(rgbMatch[3])]; 46 37 const hexMatch = input.match(/^#([0-9a-f]{6})$/i); 47 38 if (hexMatch) { 48 39 const h = hexMatch[1]; ··· 69 60 // leaving borders invisible and tinted text falling back to defaults. 70 61 // Linear mixing isn't perceptually identical to the oklab original, but 71 62 // for the near-grayscale tints we use it's visually indistinguishable. 72 - export const mixRgb = ( 73 - a: string, 74 - b: string, 75 - bPercent: number, 76 - ): string => { 63 + export const mixRgb = (a: string, b: string, bPercent: number): string => { 77 64 const [ar, ag, ab] = parseColor(a); 78 65 const [br, bg, bb] = parseColor(b); 79 66 const t = bPercent / 100; ··· 91 78 borderLight: string; 92 79 }; 93 80 94 - export const resolveColors = (theme: EmailTheme): ResolvedColors => ({ 95 - primary: theme.primary, 96 - secondary: mixRgb(theme.primary, theme.pageBackground, 25), 97 - tertiary: mixRgb(theme.primary, theme.pageBackground, 55), 98 - border: mixRgb(theme.primary, theme.pageBackground, 75), 99 - borderLight: mixRgb(theme.primary, theme.pageBackground, 85), 100 - }); 81 + export const resolveColors = (theme: EmailTheme): ResolvedColors => { 82 + // When the card background is hidden, the surface visible behind every 83 + // block is the outer page bg, not the publication's pageBackground. 84 + // Mix tints against that visible surface so block fills (e.g. the 85 + // unsupported-block fallback) sit on a tone derived from what's 86 + // actually behind them — mirroring `BaseThemeProvider`'s web behavior 87 + // where `bgPage` collapses to `bgLeaflet` when showPageBackground is 88 + // false. 89 + const effectiveBg = theme.showPageBackground 90 + ? theme.pageBackground 91 + : theme.backgroundColor; 92 + return { 93 + primary: theme.primary, 94 + secondary: mixRgb(theme.primary, effectiveBg, 25), 95 + tertiary: mixRgb(theme.primary, effectiveBg, 55), 96 + border: mixRgb(theme.primary, effectiveBg, 75), 97 + borderLight: mixRgb(theme.primary, effectiveBg, 85), 98 + }; 99 + }; 101 100 102 101 // Postmark fetches `<img src>` and link `href` values verbatim from the 103 102 // rendered HTML — relative paths break. Templates accept an `assetsBaseUrl` ··· 179 178 // makes every element render visually small). Templates can pass extra 180 179 // children — usually a `<style>` block with template-specific @media 181 180 // rules — and they'll be appended after the meta. 182 - export const MailHead = ({ 183 - children, 184 - }: { 185 - children?: React.ReactNode; 186 - }) => ( 181 + export const MailHead = ({ children }: { children?: React.ReactNode }) => ( 187 182 <Head> 188 - <meta 189 - name="viewport" 190 - content="width=device-width, initial-scale=1.0" 191 - /> 183 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 192 184 {children} 193 185 </Head> 194 186 ); ··· 259 251 </table> 260 252 ); 261 253 }; 262 -
+161
src/mergeIdentity.ts
··· 1 + import { drizzle } from "drizzle-orm/node-postgres"; 2 + import { eq, inArray, sql } from "drizzle-orm"; 3 + import { 4 + custom_domains, 5 + email_auth_tokens, 6 + identities, 7 + permission_token_on_homepage, 8 + publication_email_subscribers, 9 + user_entitlements, 10 + user_subscriptions, 11 + } from "drizzle/schema"; 12 + import { pool } from "supabase/pool"; 13 + import { Err, Ok, type Result } from "src/result"; 14 + import { backfillAtprotoSubscriptionsForIdentity } from "app/lish/subscribeToPublication"; 15 + 16 + export type MergeError = 17 + | "merge_not_pending" 18 + | "invalid_source" 19 + | "invalid_target" 20 + | "same_identity" 21 + | "database_error"; 22 + 23 + // Merges an email-only identity (source) into an atp-linked identity (target). 24 + // Re-verifies invariants under a row lock so concurrent mutations can't 25 + // invalidate them mid-transaction. 26 + // 27 + // NOT a server action — kept in a plain module so it can't be invoked over 28 + // the wire. Callers must validate that the requester is authorized to merge 29 + // these specific identities before calling. 30 + export async function mergeEmailIdentityIntoAtpIdentity(args: { 31 + sourceId: string; 32 + targetId: string; 33 + }): Promise<Result<null, MergeError>> { 34 + const { sourceId, targetId } = args; 35 + if (sourceId === targetId) return Err("same_identity"); 36 + 37 + const client = await pool.connect(); 38 + let targetAtpDid: string; 39 + try { 40 + const db = drizzle(client); 41 + await db.transaction(async (tx) => { 42 + const locked = await tx 43 + .select({ 44 + id: identities.id, 45 + email: identities.email, 46 + atp_did: identities.atp_did, 47 + }) 48 + .from(identities) 49 + .where(inArray(identities.id, [sourceId, targetId])) 50 + .for("update"); 51 + const lockedSource = locked.find((r) => r.id === sourceId); 52 + const lockedTarget = locked.find((r) => r.id === targetId); 53 + if (!lockedSource || !lockedTarget) 54 + throw new Error("merge: identity disappeared under lock"); 55 + if (lockedSource.atp_did !== null) 56 + throw new Error("merge: source has atp_did"); 57 + if (lockedTarget.atp_did === null) 58 + throw new Error("merge: target missing atp_did"); 59 + if (!lockedSource.email) throw new Error("merge: source missing email"); 60 + targetAtpDid = lockedTarget.atp_did; 61 + 62 + const sourceEmail = lockedSource.email; 63 + 64 + // email_auth_tokens: source's tokens are stale once source is gone. 65 + // Caller is responsible for swapping any cookie-held auth_token to a 66 + // target-pointing token before calling. 67 + await tx 68 + .delete(email_auth_tokens) 69 + .where(eq(email_auth_tokens.identity, sourceId)); 70 + 71 + // Target wins on (identity_id) PK collision. 72 + await tx.execute(sql` 73 + delete from user_subscriptions 74 + where identity_id = ${sourceId} 75 + and exists (select 1 from user_subscriptions where identity_id = ${targetId}) 76 + `); 77 + await tx 78 + .update(user_subscriptions) 79 + .set({ identity_id: targetId }) 80 + .where(eq(user_subscriptions.identity_id, sourceId)); 81 + 82 + // Target wins on (identity_id, entitlement_key) PK collision. 83 + await tx.execute(sql` 84 + delete from user_entitlements 85 + where identity_id = ${sourceId} 86 + and entitlement_key in ( 87 + select entitlement_key from user_entitlements where identity_id = ${targetId} 88 + ) 89 + `); 90 + await tx 91 + .update(user_entitlements) 92 + .set({ identity_id: targetId }) 93 + .where(eq(user_entitlements.identity_id, sourceId)); 94 + 95 + // Target wins on (token, identity) PK collision. 96 + await tx.execute(sql` 97 + delete from permission_token_on_homepage 98 + where identity = ${sourceId} 99 + and token in ( 100 + select token from permission_token_on_homepage where identity = ${targetId} 101 + ) 102 + `); 103 + await tx 104 + .update(permission_token_on_homepage) 105 + .set({ identity: targetId }) 106 + .where(eq(permission_token_on_homepage.identity, sourceId)); 107 + 108 + // Target wins on unique (publication, email) collision. 109 + await tx.execute(sql` 110 + delete from ${publication_email_subscribers} 111 + where ${publication_email_subscribers.identity_id} = ${sourceId} 112 + and (${publication_email_subscribers.publication}, ${publication_email_subscribers.email}) in ( 113 + select ${publication_email_subscribers.publication}, ${publication_email_subscribers.email} 114 + from ${publication_email_subscribers} 115 + where ${publication_email_subscribers.identity_id} = ${targetId} 116 + ) 117 + `); 118 + await tx 119 + .update(publication_email_subscribers) 120 + .set({ identity_id: targetId }) 121 + .where(eq(publication_email_subscribers.identity_id, sourceId)); 122 + 123 + await tx 124 + .update(custom_domains) 125 + .set({ identity_id: targetId }) 126 + .where(eq(custom_domains.identity_id, sourceId)); 127 + 128 + // identities.email is unique — step via NULL so we don't collide 129 + // mid-swap. custom_domains.identity is nullable and cascades on update; 130 + // we re-set it explicitly after the final value lands because NULL→value 131 + // cascades don't fire. 132 + await tx 133 + .update(identities) 134 + .set({ email: null }) 135 + .where(eq(identities.id, targetId)); 136 + await tx 137 + .update(identities) 138 + .set({ email: null }) 139 + .where(eq(identities.id, sourceId)); 140 + await tx 141 + .update(identities) 142 + .set({ email: sourceEmail }) 143 + .where(eq(identities.id, targetId)); 144 + await tx 145 + .update(custom_domains) 146 + .set({ identity: sourceEmail }) 147 + .where(eq(custom_domains.identity_id, targetId)); 148 + 149 + await tx.delete(identities).where(eq(identities.id, sourceId)); 150 + }); 151 + } catch (e) { 152 + console.error("[mergeIdentity] transaction failed:", e); 153 + return Err("database_error"); 154 + } finally { 155 + client.release(); 156 + } 157 + 158 + await backfillAtprotoSubscriptionsForIdentity(targetId, targetAtpDid!); 159 + 160 + return Ok(null); 161 + }
+70 -9
supabase/database.types.ts
··· 337 337 Row: { 338 338 bsky_like_count: number 339 339 data: Json 340 - identity_did: string | null 341 340 indexed: boolean 342 341 indexed_at: string 343 342 recommend_count: number 344 - sort_date: string 343 + sort_date: string | null 345 344 uri: string 346 345 } 347 346 Insert: { 348 347 bsky_like_count?: number 349 348 data: Json 350 - identity_did?: string | null 351 349 indexed?: boolean 352 350 indexed_at?: string 353 351 recommend_count?: number 354 - sort_date?: string 352 + sort_date?: string | null 355 353 uri: string 356 354 } 357 355 Update: { 358 356 bsky_like_count?: number 359 357 data?: Json 360 - identity_did?: string | null 361 358 indexed?: boolean 362 359 indexed_at?: string 363 360 recommend_count?: number 364 - sort_date?: string 361 + sort_date?: string | null 365 362 uri?: string 366 363 } 367 364 Relationships: [] ··· 605 602 leaflet: string 606 603 preferences: Json | null 607 604 publication: string 605 + scheduled_publish_at: string | null 606 + scheduled_publish_data: Json | null 608 607 tags: string[] | null 609 608 title: string 610 609 } ··· 616 615 leaflet: string 617 616 preferences?: Json | null 618 617 publication: string 618 + scheduled_publish_at?: string | null 619 + scheduled_publish_data?: Json | null 619 620 tags?: string[] | null 620 621 title?: string 621 622 } ··· 627 628 leaflet?: string 628 629 preferences?: Json | null 629 630 publication?: string 631 + scheduled_publish_at?: string | null 632 + scheduled_publish_data?: Json | null 630 633 tags?: string[] | null 631 634 title?: string 632 635 } ··· 656 659 } 657 660 leaflets_to_documents: { 658 661 Row: { 659 - archived: boolean | null 660 662 cover_image: string | null 661 663 created_at: string 662 664 description: string 663 665 document: string 664 666 leaflet: string 665 667 preferences: Json | null 668 + scheduled_publish_at: string | null 669 + scheduled_publish_data: Json | null 666 670 tags: string[] | null 667 671 title: string 668 672 } 669 673 Insert: { 670 - archived?: boolean | null 671 674 cover_image?: string | null 672 675 created_at?: string 673 676 description?: string 674 677 document: string 675 678 leaflet: string 676 679 preferences?: Json | null 680 + scheduled_publish_at?: string | null 681 + scheduled_publish_data?: Json | null 677 682 tags?: string[] | null 678 683 title?: string 679 684 } 680 685 Update: { 681 - archived?: boolean | null 682 686 cover_image?: string | null 683 687 created_at?: string 684 688 description?: string 685 689 document?: string 686 690 leaflet?: string 687 691 preferences?: Json | null 692 + scheduled_publish_at?: string | null 693 + scheduled_publish_data?: Json | null 688 694 tags?: string[] | null 689 695 title?: string 690 696 } ··· 1189 1195 foreignKeyName: "publication_newsletter_settings_publication_fkey" 1190 1196 columns: ["publication"] 1191 1197 isOneToOne: true 1198 + referencedRelation: "publications" 1199 + referencedColumns: ["uri"] 1200 + }, 1201 + ] 1202 + } 1203 + publication_pages: { 1204 + Row: { 1205 + created_at: string 1206 + document: string | null 1207 + id: number 1208 + leaflet_src: string 1209 + metadata: Json 1210 + path: string | null 1211 + publication: string 1212 + title: string 1213 + } 1214 + Insert: { 1215 + created_at?: string 1216 + document?: string | null 1217 + id?: number 1218 + leaflet_src: string 1219 + metadata?: Json 1220 + path?: string | null 1221 + publication: string 1222 + title?: string 1223 + } 1224 + Update: { 1225 + created_at?: string 1226 + document?: string | null 1227 + id?: number 1228 + leaflet_src?: string 1229 + metadata?: Json 1230 + path?: string | null 1231 + publication?: string 1232 + title?: string 1233 + } 1234 + Relationships: [ 1235 + { 1236 + foreignKeyName: "publication_pages_document_fkey" 1237 + columns: ["document"] 1238 + isOneToOne: false 1239 + referencedRelation: "documents" 1240 + referencedColumns: ["uri"] 1241 + }, 1242 + { 1243 + foreignKeyName: "publication_pages_leaflet_src_fkey" 1244 + columns: ["leaflet_src"] 1245 + isOneToOne: false 1246 + referencedRelation: "permission_tokens" 1247 + referencedColumns: ["id"] 1248 + }, 1249 + { 1250 + foreignKeyName: "publication_pages_publication_fkey" 1251 + columns: ["publication"] 1252 + isOneToOne: false 1192 1253 referencedRelation: "publications" 1193 1254 referencedColumns: ["uri"] 1194 1255 },
+77 -5
actions/publications/subscribeEmail.tsx
··· 1 1 "use server"; 2 2 3 3 import { getIdentityData } from "actions/getIdentityData"; 4 + import { mergeEmailIdentityIntoAtpIdentity } from "src/mergeIdentity"; 4 5 import { supabaseServerClient } from "supabase/serverClient"; 5 6 import { setAuthToken } from "src/auth"; 6 7 import { PubConfirmEmail } from "emails/pubConfirmEmail"; ··· 17 18 deleteSuppression, 18 19 } from "src/utils/postmarkSuppressions"; 19 20 import { 21 + backfillAtprotoSubscriptionsForIdentity, 20 22 publishAtprotoSubscriptionForDid, 21 23 unsubscribeToPublication, 22 24 } from "app/lish/subscribeToPublication"; 25 + import type { OAuthSessionError } from "src/atproto-oauth"; 23 26 import { normalizePublicationRecord } from "src/utils/normalizeRecords"; 24 27 25 28 type RequestError = ··· 29 32 | "suppression_delete_failed" 30 33 | ConfirmationError; 31 34 type RequestSuccess = { confirmed: boolean }; 32 - type ConfirmError = "subscriber_not_found" | ConfirmationError; 33 - type UnsubscribeError = "unauthorized" | "not_subscribed" | "database_error"; 35 + type ConfirmError = 36 + | "subscriber_not_found" 37 + | "link_invalid_state" 38 + | "email_belongs_to_other_account" 39 + | ConfirmationError; 40 + type UnsubscribeError = 41 + | "unauthorized" 42 + | "not_subscribed" 43 + | "database_error" 44 + | OAuthSessionError; 34 45 35 46 export async function requestPublicationEmailSubscription( 36 47 publicationUri: string, ··· 174 185 publicationUri: string, 175 186 emailRaw: string, 176 187 code: string, 188 + linkToCurrent: boolean = false, 177 189 ): Promise<Result<null, ConfirmError>> { 178 190 const email = emailRaw.trim().toLowerCase(); 179 191 ··· 192 204 // The confirmation code proves ownership of `email`. Issue (or look up) an 193 205 // auth token for it so the subscriber is logged in and can one-click 194 206 // subscribe to other publications from the same device. 195 - const identityId = await ensureAuthTokenForEmail(email); 196 - if (!identityId) return Err("database_error"); 207 + let identityId: string | null; 208 + if (linkToCurrent) { 209 + const linkResult = await linkEmailToCurrentIdentity(email); 210 + if (!linkResult.ok) return linkResult; 211 + identityId = linkResult.value; 212 + } else { 213 + identityId = await ensureAuthTokenForEmail(email); 214 + if (!identityId) return Err("database_error"); 215 + } 197 216 198 217 const [{ error: updateError }, { error: eventError }] = await Promise.all([ 199 218 supabaseServerClient ··· 301 320 } 302 321 303 322 if (atprotoSub) { 304 - await unsubscribeToPublication(publicationUri); 323 + const atprotoResult = await unsubscribeToPublication(publicationUri); 324 + if (!atprotoResult.success) return Err(atprotoResult.error); 305 325 } 306 326 307 327 // NOTE: Postmark Suppressions API is deliberately NOT called here. Per spec, ··· 310 330 // stream. Phase 7 handles webhook-driven suppression reconciliation. 311 331 312 332 return Ok(null); 333 + } 334 + 335 + // Confirms an email subscription where the user has already chosen to link 336 + // the email to their currently signed-in atp-only identity (via the 337 + // LinkIdentityModal in the subscribe flow). Either attaches `email` to the 338 + // current identity, or — if another email-only identity already owns it — 339 + // merges that identity into the current one. 340 + async function linkEmailToCurrentIdentity( 341 + email: string, 342 + ): Promise<Result<string, ConfirmError>> { 343 + const [current, { data: existing }] = await Promise.all([ 344 + getIdentityData(), 345 + supabaseServerClient 346 + .from("identities") 347 + .select("id, atp_did") 348 + .eq("email", email) 349 + .maybeSingle(), 350 + ]); 351 + if (!current || !current.atp_did) return Err("link_invalid_state"); 352 + 353 + // Already linked — confirmation is a no-op for the identity row itself. 354 + if (current.email && current.email.toLowerCase() === email) 355 + return Ok(current.id); 356 + 357 + // Current identity already has a *different* email. Linking would clobber 358 + // it, which the modal didn't promise — refuse. 359 + if (current.email) return Err("link_invalid_state"); 360 + 361 + if (!existing || existing.id === current.id) { 362 + const { error } = await supabaseServerClient 363 + .from("identities") 364 + .update({ email }) 365 + .eq("id", current.id); 366 + if (error) { 367 + console.error("[subscribeEmail] attach email failed:", error); 368 + return Err("database_error"); 369 + } 370 + await backfillAtprotoSubscriptionsForIdentity(current.id, current.atp_did); 371 + return Ok(current.id); 372 + } 373 + 374 + // Existing identity owns this email and isn't us. We can only merge if it's 375 + // an unlinked email-only account; otherwise it has its own atp_did and 376 + // merging would silently drop one of the two Bluesky links. 377 + if (existing.atp_did) return Err("email_belongs_to_other_account"); 378 + 379 + const merged = await mergeEmailIdentityIntoAtpIdentity({ 380 + sourceId: existing.id, 381 + targetId: current.id, 382 + }); 383 + if (!merged.ok) return Err("database_error"); 384 + return Ok(current.id); 313 385 } 314 386 315 387 async function ensureAuthTokenForEmail(email: string): Promise<string | null> {
+22
app/lish/subscribeToPublication.ts
··· 192 192 } 193 193 } 194 194 195 + // Publishes atproto subscription records for every confirmed email 196 + // subscription tied to `identityId`. Call after an identity gains an atp_did 197 + // (account-linking, merge) so existing email-only subscriptions also live as 198 + // records on the user's PDS — otherwise they'd be invisible to atproto-side 199 + // consumers and would silently drop if a publication ever leaves email-only 200 + // mode. Best-effort: errors are swallowed by publishAtprotoSubscriptionForDid. 201 + export async function backfillAtprotoSubscriptionsForIdentity( 202 + identityId: string, 203 + atp_did: string, 204 + ): Promise<void> { 205 + const { data: subs } = await supabaseServerClient 206 + .from("publication_email_subscribers") 207 + .select("publication") 208 + .eq("identity_id", identityId) 209 + .eq("state", "confirmed"); 210 + if (!subs || subs.length === 0) return; 211 + 212 + await Promise.all( 213 + subs.map((s) => publishAtprotoSubscriptionForDid(atp_did, s.publication)), 214 + ); 215 + } 216 + 195 217 type UnsubscribeResult = 196 218 | { success: true } 197 219 | { success: false; error: OAuthSessionError };
+6
components/Blocks/Block.tsx
··· 124 124 const bindSwipe = useDrag( 125 125 ({ last, movement: [mx], event }) => { 126 126 if (!last) return; 127 + if ( 128 + event && 129 + "pointerType" in event && 130 + event.pointerType !== "touch" 131 + ) 132 + return; 127 133 if (!rep || !props.listData || !entity_set.permissions.write) return; 128 134 if (Math.abs(mx) < SWIPE_THRESHOLD) return; 129 135 event?.preventDefault();
+20
components/Blocks/MailboxBlock.tsx
··· 1 1 import { BlockProps, BlockLayout } from "./Block"; 2 2 import { useUIState } from "src/useUIState"; 3 + import { useEntity, useReplicache } from "src/replicache"; 4 + import { focusPage } from "src/utils/focusPage"; 3 5 4 6 export const MailboxBlock = ( 5 7 props: BlockProps & { ··· 10 12 let isSelected = useUIState((s) => 11 13 s.selectedBlocks.find((b) => b.value === props.entityID), 12 14 ); 15 + let archive = useEntity(props.entityID, "mailbox/archive"); 16 + let { rep } = useReplicache(); 13 17 return ( 14 18 <BlockLayout 15 19 isSelected={!!isSelected} ··· 24 28 <div className="text-tertiary text-sm"> 25 29 Email subscriptions have moved to publication newsletters. 26 30 </div> 31 + {archive && ( 32 + <button 33 + className="text-accent-contrast hover:underline text-sm mt-1" 34 + onMouseDown={(e) => { 35 + e.preventDefault(); 36 + if (rep) { 37 + useUIState 38 + .getState() 39 + .openPage(props.parent, archive.data.value); 40 + focusPage(archive.data.value, rep); 41 + } 42 + }} 43 + > 44 + See past posts 45 + </button> 46 + )} 27 47 </BlockLayout> 28 48 ); 29 49 };
+2 -2
components/Footnotes/FootnoteItemLayout.tsx
··· 10 10 className?: string; 11 11 }) { 12 12 let indexClassName = 13 - "text-tertiary font-medium shrink-0 text-sm leading-normal no-underline hover:underline cursor-pointer"; 13 + "text-tertiary font-medium shrink-0 text-sm leading-normal no-underline hover:underline cursor-pointer w-7 text-right"; 14 14 15 15 let indexContent = <>{props.index}.</>; 16 16 17 17 return ( 18 18 <div 19 19 id={props.id} 20 - className={`footnote-item flex items-start gap-2 text-sm group/footnote ${props.className ?? ""}`} 20 + className={`footnote-item flex items-start gap-2 text-sm group/footnote${props.className ?? ""}`} 21 21 > 22 22 {props.indexHref ? ( 23 23 <a href={props.indexHref} className={indexClassName}>
+56
components/PageLayouts/PageSearch.tsx
··· 167 167 ); 168 168 }; 169 169 170 + export const SubscriberStatusFilter = () => { 171 + let { subscriberStatus } = useDashboardState(); 172 + let setState = useSetDashboardState(); 173 + let count = Object.values(subscriberStatus).filter(Boolean).length; 174 + 175 + return ( 176 + <Popover 177 + className="text-sm px-2! py-1!" 178 + trigger={<div>Status {count > 0 && `(${count})`}</div>} 179 + > 180 + <Checkbox 181 + small 182 + checked={subscriberStatus.subscribed} 183 + onChange={(e) => 184 + setState({ 185 + subscriberStatus: { 186 + ...subscriberStatus, 187 + subscribed: !!e.target.checked, 188 + }, 189 + }) 190 + } 191 + > 192 + Subscribed 193 + </Checkbox> 194 + <Checkbox 195 + small 196 + checked={subscriberStatus.unconfirmed} 197 + onChange={(e) => 198 + setState({ 199 + subscriberStatus: { 200 + ...subscriberStatus, 201 + unconfirmed: !!e.target.checked, 202 + }, 203 + }) 204 + } 205 + > 206 + Unconfirmed 207 + </Checkbox> 208 + <Checkbox 209 + small 210 + checked={subscriberStatus.unsubscribed} 211 + onChange={(e) => 212 + setState({ 213 + subscriberStatus: { 214 + ...subscriberStatus, 215 + unsubscribed: !!e.target.checked, 216 + }, 217 + }) 218 + } 219 + > 220 + Unsubscribed 221 + </Checkbox> 222 + </Popover> 223 + ); 224 + }; 225 + 170 226 const FilterOptions = (props: { hasPubs: boolean; hasArchived: boolean }) => { 171 227 let { filter } = useDashboardState(); 172 228 let filterCount = Object.values(filter).filter(Boolean).length;
+28 -5
components/PageLayouts/dashboardState.ts
··· 13 13 docs: boolean; 14 14 archived: boolean; 15 15 }; 16 + subscriberStatus: { 17 + unconfirmed: boolean; 18 + subscribed: boolean; 19 + unsubscribed: boolean; 20 + }; 16 21 }; 17 22 18 23 type DashboardStore = { ··· 29 34 docs: false, 30 35 archived: false, 31 36 }, 37 + subscriberStatus: { 38 + unconfirmed: false, 39 + subscribed: true, 40 + unsubscribed: false, 41 + }, 32 42 }; 43 + 44 + // Existing identities have stored interface_state without newer fields 45 + // (e.g. subscriberStatus). Merge so callers always see a complete shape. 46 + function withDefaults(stored: DashboardState | undefined): DashboardState { 47 + if (!stored) return defaultDashboardState; 48 + return { 49 + ...defaultDashboardState, 50 + ...stored, 51 + filter: { ...defaultDashboardState.filter, ...stored.filter }, 52 + subscriberStatus: { 53 + ...defaultDashboardState.subscriberStatus, 54 + ...stored.subscriberStatus, 55 + }, 56 + }; 57 + } 33 58 34 59 export const useDashboardStore = create<DashboardStore>((set) => ({ 35 60 dashboards: {}, ··· 59 84 export const useDashboardState = () => { 60 85 const id = useDashboardId(); 61 86 let { identity } = useIdentityData(); 62 - let localState = useDashboardStore( 63 - (state) => state.dashboards[id] || defaultDashboardState, 64 - ); 65 - if (!identity) return localState; 87 + let localState = useDashboardStore((state) => state.dashboards[id]); 88 + if (!identity) return withDefaults(localState); 66 89 let metadata = identity.interface_state as InterfaceState; 67 - return metadata?.dashboards?.[id] || defaultDashboardState; 90 + return withDefaults(metadata?.dashboards?.[id]); 68 91 }; 69 92 70 93 export const useSetDashboardState = () => {
+6 -3
components/Subscribe/EmailSubscribe.tsx
··· 32 32 <Input 33 33 type="email" 34 34 autoFocus={props.autoFocus} 35 - className={`appearance-none! outline-none! grow ${props.large ? "py-1!" : "py-0.5 "}`} 35 + className={`appearance-none! outline-none! grow ${props.large ? "py-1!" : "py-0.5 disabled:text-tertiary disabled:italic disabled:border-border-light"}`} 36 36 disabled={props.disabled || props.loading} 37 37 placeholder="email@example.com" 38 38 size={0} ··· 77 77 </div> 78 78 <OneTimePasswordField.HiddenInput /> 79 79 </OneTimePasswordField.Root> 80 - <button className="text-sm text-accent-contrast mt-2"> 81 - Change Email 80 + <button 81 + className="text-sm text-accent-contrast mt-1" 82 + onClick={() => props.onBack()} 83 + > 84 + Use a different email? 82 85 </button> 83 86 </div> 84 87 );
+43 -8
components/Subscribe/HandleSubscribe.tsx
··· 13 13 import { Avatar } from "components/Avatar"; 14 14 import { useIdentityData } from "components/IdentityProvider"; 15 15 import { useRecordFromDid } from "src/utils/useRecordFromDid"; 16 + import { LinkIdentityModal } from "./LinkIdentityModal"; 16 17 const apps = [ 17 18 { name: "Leaflet", logo: "/logos/leaflet.svg" }, 18 19 { name: "Bluesky", logo: "/logos/bluesky.svg" }, ··· 54 55 let [loading, setLoading] = useState(false); 55 56 let [subscribing, setSubscribing] = useState(false); 56 57 let [oauthError, setOauthError] = useState<OAuthSessionError | null>(null); 58 + // When an email-only user subscribes via the atproto flow, we surface a 59 + // confirmation modal first ("link Bluesky to your account?") so they can't 60 + // accidentally orphan their email account. 61 + let [pendingLinkHandle, setPendingLinkHandle] = useState<string | null>(null); 62 + const viewerEmail = identity?.email; 63 + const viewerAtpDid = identity?.atp_did; 64 + const needsLinkConfirmation = 65 + !!viewerEmail && !viewerAtpDid; 66 + 67 + const redirectToOauthForSubscribe = (handle: string, link: boolean) => { 68 + let action = encodeActionToSearchParam({ 69 + action: "subscribe", 70 + publication: props.publicationUri, 71 + }); 72 + let url = new URL(window.location.href); 73 + url.searchParams.set("refreshAuth", ""); 74 + let redirectUrl = encodeURIComponent(url.toString()); 75 + let extra = link ? "&link=true&autoMerge=true" : ""; 76 + window.location.href = `/api/oauth/login?handle=${encodeURIComponent(handle)}&redirect_url=${redirectUrl}&action=${action}${extra}`; 77 + }; 57 78 58 79 if (props.user.loggedIn && props.user.handle) { 59 80 return ( ··· 119 140 onSubmit={(handle) => { 120 141 let trimmed = handle.trim(); 121 142 if (!trimmed) return; 143 + if (needsLinkConfirmation) { 144 + setPendingLinkHandle(trimmed); 145 + return; 146 + } 122 147 setLoading(true); 123 - let action = encodeActionToSearchParam({ 124 - action: "subscribe", 125 - publication: props.publicationUri, 126 - }); 127 - let url = new URL(window.location.href); 128 - url.searchParams.set("refreshAuth", ""); 129 - let redirectUrl = encodeURIComponent(url.toString()); 130 - window.location.href = `/api/oauth/login?handle=${encodeURIComponent(trimmed)}&redirect_url=${redirectUrl}&action=${action}`; 148 + redirectToOauthForSubscribe(trimmed, false); 131 149 }} 132 150 action=<div className="bg-accent-1 rounded-md px-1 text-accent-2 font-bold text-sm"> 133 151 Subscribe ··· 136 154 <div className=" pt-1 "> 137 155 <AtmosphericHandleInfo /> 138 156 </div> 157 + {needsLinkConfirmation && ( 158 + <LinkIdentityModal 159 + open={pendingLinkHandle !== null} 160 + onOpenChange={(open) => { 161 + if (!open) setPendingLinkHandle(null); 162 + }} 163 + signedInAs={viewerEmail!} 164 + linkingIdentity={`@${pendingLinkHandle ?? ""}`} 165 + confirmButtonLabel="Link Bluesky" 166 + confirming={loading} 167 + onConfirm={() => { 168 + if (!pendingLinkHandle) return; 169 + setLoading(true); 170 + redirectToOauthForSubscribe(pendingLinkHandle, true); 171 + }} 172 + /> 173 + )} 139 174 </div> 140 175 ); 141 176 };
+69
components/Subscribe/LinkIdentityModal.tsx
··· 1 + "use client"; 2 + import { useState } from "react"; 3 + import { useRouter } from "next/navigation"; 4 + import { mutate } from "swr"; 5 + import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; 6 + import { Modal } from "components/Modal"; 7 + import { DotLoader } from "components/utils/DotLoader"; 8 + 9 + export const LinkIdentityModal = (props: { 10 + open: boolean; 11 + onOpenChange: (open: boolean) => void; 12 + signedInAs: string; 13 + linkingIdentity: string; 14 + confirmButtonLabel: string; 15 + onConfirm: () => void | Promise<void>; 16 + confirming?: boolean; 17 + }) => { 18 + let router = useRouter(); 19 + let [loggingOut, setLoggingOut] = useState(false); 20 + 21 + return ( 22 + <Modal 23 + open={props.open} 24 + onOpenChange={(o) => { 25 + if (loggingOut || props.confirming) return; 26 + props.onOpenChange(o); 27 + }} 28 + className="w-[400px] max-w-full" 29 + > 30 + <div className="flex flex-col gap-3"> 31 + <h3 className="text-primary leading-tight"> 32 + You're signed in as{" "} 33 + <span className="font-bold">{props.signedInAs}</span> 34 + </h3> 35 + <p className="text-secondary leading-snug"> 36 + Do you want to link{" "} 37 + <span className="font-bold">{props.linkingIdentity}</span> to this 38 + account? 39 + </p> 40 + <div className="flex flex-wrap gap-2 justify-end pt-1"> 41 + <ButtonSecondary 42 + disabled={loggingOut || props.confirming} 43 + onClick={async () => { 44 + if (loggingOut) return; 45 + setLoggingOut(true); 46 + try { 47 + await fetch("/api/auth/logout"); 48 + } finally { 49 + mutate("identity", null); 50 + router.refresh(); 51 + } 52 + }} 53 + > 54 + {loggingOut ? <DotLoader /> : "Log out"} 55 + </ButtonSecondary> 56 + <ButtonPrimary 57 + disabled={loggingOut || props.confirming} 58 + onClick={() => { 59 + if (props.confirming) return; 60 + void props.onConfirm(); 61 + }} 62 + > 63 + {props.confirming ? <DotLoader /> : props.confirmButtonLabel} 64 + </ButtonPrimary> 65 + </div> 66 + </div> 67 + </Modal> 68 + ); 69 + };
+6 -3
components/Subscribe/ManageSubscribe.tsx
··· 6 6 import { GoToArrow } from "components/Icons/GoToArrow"; 7 7 import { Modal } from "components/Modal"; 8 8 import { useToaster } from "components/Toast"; 9 + import { isOAuthSessionError, OAuthErrorMessage } from "components/OAuthError"; 9 10 import { LinkHandle } from "./HandleSubscribe"; 10 11 import { EmailInput, EmailConfirm } from "./EmailSubscribe"; 11 12 import type { ViewerUser } from "./viewerSubscription"; ··· 102 103 if (!res.ok) { 103 104 toaster({ 104 105 type: "error", 105 - content: ( 106 + content: isOAuthSessionError(res.error) ? ( 107 + <OAuthErrorMessage error={res.error} /> 108 + ) : ( 106 109 <div className="font-bold"> 107 - {UNSUBSCRIBE_ERROR_MESSAGES[res.error]} 110 + {UNSUBSCRIBE_ERROR_MESSAGES[res.error] ?? "Couldn't unsubscribe."} 108 111 </div> 109 112 ), 110 113 }); ··· 121 124 <Modal 122 125 title={ 123 126 <div className="text-secondary border-b border-border mb-2"> 124 - Preferences 127 + Manage Subscription 125 128 </div> 126 129 } 127 130 className="w-md max-w-full"
+146 -47
components/Subscribe/SubscribeButton.tsx
··· 4 4 import { SubscribeWithHandle } from "./HandleSubscribe"; 5 5 import { EmailInput, EmailConfirm } from "./EmailSubscribe"; 6 6 import { EmailSubscribeSuccess } from "./EmailSubscribeSuccess"; 7 + import { LinkIdentityModal } from "./LinkIdentityModal"; 7 8 import { Modal } from "components/Modal"; 8 9 import { ButtonPrimary } from "components/Buttons"; 9 10 import { ManageSubscription } from "./ManageSubscribe"; 10 11 import { useToaster } from "components/Toast"; 12 + import { useIdentityData } from "components/IdentityProvider"; 11 13 import { 12 14 requestPublicationEmailSubscription, 13 15 confirmPublicationEmailSubscription, ··· 30 32 <div className="accent-container rounded-lg! w-full px-3 pt-3 pb-4 sm:px-4 sm:pt-4 sm:pb-5 text-center justify-center"> 31 33 <h3 className="leading-snug text-secondary">{props.publicationName}</h3> 32 34 {props.publicationDescription && ( 33 - <div className="text-tertiary pb-3"> 35 + <div className="text-tertiary pb-1"> 34 36 {props.publicationDescription} 35 37 </div> 36 38 )} 37 39 38 - <div className="max-w-sm mx-auto "> 40 + <div className="mx-auto"> 39 41 <SubscribeInput {...props} /> 40 42 </div> 41 43 </div> ··· 45 47 46 48 export const SubscribeButton = (props: SubscribeProps) => { 47 49 const user = useViewerSubscription(props.publicationUri); 50 + const showManage = props.newsletterMode 51 + ? user.emailSubscribed 52 + : user.atprotoSubscribed; 48 53 return ( 49 54 <Modal 50 55 className="px-0! py-3! sm:py-4! w-[1000px] sm:max-w-md max-w-full" 51 56 asChild 52 57 trigger={ 53 - user.subscribed ? ( 58 + showManage ? ( 54 59 <ManageSubscription 55 60 publicationUri={props.publicationUri} 56 61 publicationUrl={props.publicationUrl} ··· 73 78 let toaster = useToaster(); 74 79 let router = useRouter(); 75 80 const user = useViewerSubscription(props.publicationUri); 81 + const { identity } = useIdentityData(); 76 82 let [email, setEmail] = useState(user.email ?? ""); 77 83 let [confirmState, setConfirmState] = useState<"confirm" | "success">( 78 84 "confirm", ··· 81 87 let [requesting, setRequesting] = useState(false); 82 88 let [confirming, setConfirming] = useState(false); 83 89 let [locallySubscribed, setLocallySubscribed] = useState(false); 90 + let [linkModalOpen, setLinkModalOpen] = useState(false); 91 + // Tracks that the user passed through LinkIdentityModal — when they enter 92 + // the confirmation code we attach the email to their current atp identity 93 + // (or merge from any existing email-only identity) instead of creating a 94 + // disconnected email-only account. 95 + let [linkToCurrent, setLinkToCurrent] = useState(false); 84 96 85 - const isSubscribed = user.subscribed || locallySubscribed; 97 + const viewerHandle = identity?.bsky_profiles?.handle; 98 + const viewerAtpDid = identity?.atp_did; 99 + const viewerEmail = identity?.email; 100 + // The atp-only-but-subscribing-via-email case: signed in as a Bluesky 101 + // account with no email yet. The modal asks them to link the typed email 102 + // (or log out) before we send a confirmation code. 103 + const needsLinkConfirmation = !!viewerAtpDid && !viewerEmail && !!email; 104 + 105 + const sendRequest = async (link: boolean) => { 106 + setRequesting(true); 107 + setLinkToCurrent(link); 108 + let res = await requestPublicationEmailSubscription( 109 + props.publicationUri, 110 + email, 111 + ); 112 + setRequesting(false); 113 + if (!res.ok) { 114 + toaster({ type: "error", content: ERROR_MESSAGES[res.error] }); 115 + return; 116 + } 117 + if (res.value.confirmed) { 118 + setConfirmState("success"); 119 + router.refresh(); 120 + } 121 + setConfirmOpen(true); 122 + }; 123 + 124 + const showManage = props.newsletterMode 125 + ? user.emailSubscribed 126 + : user.atprotoSubscribed; 127 + const isSubscribed = showManage || locallySubscribed; 86 128 return ( 87 129 <> 130 + <div className="h-1 w-full spacer" /> 131 + 88 132 {isSubscribed ? ( 89 - <ManageSubscription 90 - publicationUri={props.publicationUri} 91 - publicationUrl={props.publicationUrl} 92 - newsletterMode={props.newsletterMode} 93 - user={user} 94 - /> 95 - ) : props.newsletterMode ? ( 96 - <EmailInput 97 - value={email} 98 - onChange={setEmail} 99 - disabled={user.loggedIn && !!user.email} 100 - autoFocus={props.autoFocus} 101 - loading={requesting} 102 - action={ 103 - <ButtonPrimary 104 - compact 105 - className="leading-tight! outline-none! text-sm!" 106 - disabled={requesting || !email} 107 - onClick={async () => { 108 - if (requesting) return; 109 - setRequesting(true); 110 - let res = await requestPublicationEmailSubscription( 111 - props.publicationUri, 112 - email, 113 - ); 114 - setRequesting(false); 115 - if (!res.ok) { 116 - toaster({ 117 - type: "error", 118 - content: ERROR_MESSAGES[res.error], 119 - }); 120 - return; 121 - } 122 - if (res.value.confirmed) { 123 - setConfirmState("success"); 124 - router.refresh(); 125 - } 126 - setConfirmOpen(true); 133 + <> 134 + <ManageSubscription 135 + publicationUri={props.publicationUri} 136 + publicationUrl={props.publicationUrl} 137 + newsletterMode={props.newsletterMode} 138 + user={user} 139 + /> 140 + 141 + {props.newsletterMode && 142 + user.atprotoSubscribed && 143 + !user.emailSubscribed ? ( 144 + <div 145 + className="text-secondary w-full text-sm p-2 pt-1.5 mt-1 rounded-md flex flex-col gap-1" 146 + style={{ 147 + background: 148 + "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 70%", 127 149 }} 128 150 > 129 - Subscribe 130 - </ButtonPrimary> 131 - } 132 - /> 151 + <div className="font-bold">Opt in to get updates via email!</div> 152 + <div className="max-w-sm w-full mx-auto"> 153 + <EmailInput 154 + value={email} 155 + onChange={setEmail} 156 + disabled={user.loggedIn && !!user.email} 157 + autoFocus={props.autoFocus} 158 + loading={requesting} 159 + action={ 160 + <ButtonPrimary 161 + compact 162 + className="leading-tight! outline-none! text-sm!" 163 + disabled={requesting || !email} 164 + onClick={async () => { 165 + if (requesting) return; 166 + if (needsLinkConfirmation) { 167 + setLinkModalOpen(true); 168 + return; 169 + } 170 + await sendRequest(false); 171 + }} 172 + > 173 + Get Emails 174 + </ButtonPrimary> 175 + } 176 + /> 177 + </div> 178 + </div> 179 + ) : null} 180 + </> 181 + ) : props.newsletterMode ? ( 182 + <div className="max-w-sm w-full mx-auto"> 183 + <EmailInput 184 + value={email} 185 + onChange={setEmail} 186 + disabled={user.loggedIn && !!user.email} 187 + autoFocus={props.autoFocus} 188 + loading={requesting} 189 + action={ 190 + <ButtonPrimary 191 + compact 192 + className="leading-tight! outline-none! text-sm!" 193 + disabled={requesting || !email} 194 + onClick={async () => { 195 + if (requesting) return; 196 + if (needsLinkConfirmation) { 197 + setLinkModalOpen(true); 198 + return; 199 + } 200 + await sendRequest(false); 201 + }} 202 + > 203 + Subscribe 204 + </ButtonPrimary> 205 + } 206 + /> 207 + </div> 133 208 ) : ( 134 209 <SubscribeWithHandle 135 210 user={user} 136 211 autoFocus={props.autoFocus} 137 212 publicationUri={props.publicationUri} 138 213 onSubscribed={() => setLocallySubscribed(true)} 214 + /> 215 + )} 216 + {props.newsletterMode && needsLinkConfirmation && ( 217 + <LinkIdentityModal 218 + open={linkModalOpen} 219 + onOpenChange={setLinkModalOpen} 220 + signedInAs={ 221 + viewerHandle ? `@${viewerHandle}` : "your Bluesky account" 222 + } 223 + linkingIdentity={email} 224 + confirmButtonLabel="Link email" 225 + confirming={requesting} 226 + onConfirm={async () => { 227 + setLinkModalOpen(false); 228 + await sendRequest(true); 229 + }} 139 230 /> 140 231 )} 141 232 {props.newsletterMode && ( ··· 146 237 if (!open) { 147 238 if (confirmState === "success") setLocallySubscribed(true); 148 239 setConfirmState("confirm"); 240 + setLinkToCurrent(false); 149 241 } 150 242 }} 151 243 > ··· 164 256 props.publicationUri, 165 257 email, 166 258 code, 259 + linkToCurrent, 167 260 ); 168 261 setConfirming(false); 169 262 if (!res.ok) { ··· 192 285 | "invalid_code" 193 286 | "database_error" 194 287 | "suppressed_spam_complaint" 195 - | "suppression_delete_failed"; 288 + | "suppression_delete_failed" 289 + | "link_invalid_state" 290 + | "email_belongs_to_other_account"; 196 291 197 292 const ERROR_MESSAGES: Record<SubscribeError, string> = { 198 293 invalid_email: "Please enter a valid email address.", ··· 205 300 "This address was previously marked as spam and can't be resubscribed. Contact the publication to resolve.", 206 301 suppression_delete_failed: 207 302 "We couldn't clear a prior delivery issue on this address. Try again later.", 303 + link_invalid_state: 304 + "Couldn't link this email to your account. Try logging out and subscribing again.", 305 + email_belongs_to_other_account: 306 + "This email is already linked to a different Bluesky account. Log out to use that account instead.", 208 307 };
+12 -8
components/Subscribe/viewerSubscription.ts
··· 6 6 loggedIn: boolean; 7 7 email: string | undefined; 8 8 handle: string | undefined; 9 - subscribed: boolean; 9 + atprotoSubscribed: boolean; 10 + emailSubscribed: boolean; 10 11 }; 11 12 12 13 export function useViewerSubscription(publicationUri: string): ViewerUser { 13 14 const { identity } = useIdentityData(); 14 15 15 16 return useMemo(() => { 16 - const subscribed = 17 + const atprotoSubscribed = 17 18 !!identity && 18 - ((identity.publication_subscriptions ?? []).some( 19 + (identity.publication_subscriptions ?? []).some( 19 20 (s) => s.publication === publicationUri, 20 - ) || 21 - (identity.publication_email_subscribers ?? []).some( 22 - (s) => s.publication === publicationUri && s.state === "confirmed", 23 - )); 21 + ); 22 + const emailSubscribed = 23 + !!identity && 24 + (identity.publication_email_subscribers ?? []).some( 25 + (s) => s.publication === publicationUri && s.state === "confirmed", 26 + ); 24 27 return { 25 28 loggedIn: !!identity, 26 29 email: identity?.email ?? undefined, 27 30 handle: identity?.bsky_profiles?.handle ?? undefined, 28 - subscribed, 31 + atprotoSubscribed, 32 + emailSubscribed, 29 33 }; 30 34 }, [identity, publicationUri]); 31 35 }
+87
lexicons/api/index.ts
··· 56 56 import * as PubLeafletThemeBackgroundImage from './types/pub/leaflet/theme/backgroundImage' 57 57 import * as PubLeafletThemeColor from './types/pub/leaflet/theme/color' 58 58 import * as SiteStandardDocument from './types/site/standard/document' 59 + import * as SiteStandardGraphRecommend from './types/site/standard/graph/recommend' 59 60 import * as SiteStandardGraphSubscription from './types/site/standard/graph/subscription' 60 61 import * as SiteStandardPublication from './types/site/standard/publication' 61 62 import * as SiteStandardThemeBasic from './types/site/standard/theme/basic' ··· 108 109 export * as PubLeafletThemeBackgroundImage from './types/pub/leaflet/theme/backgroundImage' 109 110 export * as PubLeafletThemeColor from './types/pub/leaflet/theme/color' 110 111 export * as SiteStandardDocument from './types/site/standard/document' 112 + export * as SiteStandardGraphRecommend from './types/site/standard/graph/recommend' 111 113 export * as SiteStandardGraphSubscription from './types/site/standard/graph/subscription' 112 114 export * as SiteStandardPublication from './types/site/standard/publication' 113 115 export * as SiteStandardThemeBasic from './types/site/standard/theme/basic' ··· 1319 1321 1320 1322 export class SiteStandardGraphNS { 1321 1323 _client: XrpcClient 1324 + recommend: SiteStandardGraphRecommendRecord 1322 1325 subscription: SiteStandardGraphSubscriptionRecord 1323 1326 1324 1327 constructor(client: XrpcClient) { 1325 1328 this._client = client 1329 + this.recommend = new SiteStandardGraphRecommendRecord(client) 1326 1330 this.subscription = new SiteStandardGraphSubscriptionRecord(client) 1331 + } 1332 + } 1333 + 1334 + export class SiteStandardGraphRecommendRecord { 1335 + _client: XrpcClient 1336 + 1337 + constructor(client: XrpcClient) { 1338 + this._client = client 1339 + } 1340 + 1341 + async list( 1342 + params: OmitKey<ComAtprotoRepoListRecords.QueryParams, 'collection'>, 1343 + ): Promise<{ 1344 + cursor?: string 1345 + records: { uri: string; value: SiteStandardGraphRecommend.Record }[] 1346 + }> { 1347 + const res = await this._client.call('com.atproto.repo.listRecords', { 1348 + collection: 'site.standard.graph.recommend', 1349 + ...params, 1350 + }) 1351 + return res.data 1352 + } 1353 + 1354 + async get( 1355 + params: OmitKey<ComAtprotoRepoGetRecord.QueryParams, 'collection'>, 1356 + ): Promise<{ 1357 + uri: string 1358 + cid: string 1359 + value: SiteStandardGraphRecommend.Record 1360 + }> { 1361 + const res = await this._client.call('com.atproto.repo.getRecord', { 1362 + collection: 'site.standard.graph.recommend', 1363 + ...params, 1364 + }) 1365 + return res.data 1366 + } 1367 + 1368 + async create( 1369 + params: OmitKey< 1370 + ComAtprotoRepoCreateRecord.InputSchema, 1371 + 'collection' | 'record' 1372 + >, 1373 + record: Un$Typed<SiteStandardGraphRecommend.Record>, 1374 + headers?: Record<string, string>, 1375 + ): Promise<{ uri: string; cid: string }> { 1376 + const collection = 'site.standard.graph.recommend' 1377 + const res = await this._client.call( 1378 + 'com.atproto.repo.createRecord', 1379 + undefined, 1380 + { collection, ...params, record: { ...record, $type: collection } }, 1381 + { encoding: 'application/json', headers }, 1382 + ) 1383 + return res.data 1384 + } 1385 + 1386 + async put( 1387 + params: OmitKey< 1388 + ComAtprotoRepoPutRecord.InputSchema, 1389 + 'collection' | 'record' 1390 + >, 1391 + record: Un$Typed<SiteStandardGraphRecommend.Record>, 1392 + headers?: Record<string, string>, 1393 + ): Promise<{ uri: string; cid: string }> { 1394 + const collection = 'site.standard.graph.recommend' 1395 + const res = await this._client.call( 1396 + 'com.atproto.repo.putRecord', 1397 + undefined, 1398 + { collection, ...params, record: { ...record, $type: collection } }, 1399 + { encoding: 'application/json', headers }, 1400 + ) 1401 + return res.data 1402 + } 1403 + 1404 + async delete( 1405 + params: OmitKey<ComAtprotoRepoDeleteRecord.InputSchema, 'collection'>, 1406 + headers?: Record<string, string>, 1407 + ): Promise<void> { 1408 + await this._client.call( 1409 + 'com.atproto.repo.deleteRecord', 1410 + undefined, 1411 + { collection: 'site.standard.graph.recommend', ...params }, 1412 + { headers }, 1413 + ) 1327 1414 } 1328 1415 } 1329 1416
+28
lexicons/api/lexicons.ts
··· 2602 2602 id: 'site.standard.document', 2603 2603 lexicon: 1, 2604 2604 }, 2605 + SiteStandardGraphRecommend: { 2606 + id: 'site.standard.graph.recommend', 2607 + defs: { 2608 + main: { 2609 + key: 'tid', 2610 + type: 'record', 2611 + record: { 2612 + type: 'object', 2613 + required: ['document', 'createdAt'], 2614 + properties: { 2615 + document: { 2616 + type: 'string', 2617 + format: 'at-uri', 2618 + description: 2619 + 'AT-URI reference to the document record being recommended (ex: at://did:plc:abc123/site.standard.document/xyz789).', 2620 + }, 2621 + createdAt: { 2622 + type: 'string', 2623 + format: 'datetime', 2624 + }, 2625 + }, 2626 + }, 2627 + description: 'Record declaring a recommendation of a document.', 2628 + }, 2629 + }, 2630 + lexicon: 1, 2631 + }, 2605 2632 SiteStandardGraphSubscription: { 2606 2633 defs: { 2607 2634 main: { ··· 2856 2883 PubLeafletThemeBackgroundImage: 'pub.leaflet.theme.backgroundImage', 2857 2884 PubLeafletThemeColor: 'pub.leaflet.theme.color', 2858 2885 SiteStandardDocument: 'site.standard.document', 2886 + SiteStandardGraphRecommend: 'site.standard.graph.recommend', 2859 2887 SiteStandardGraphSubscription: 'site.standard.graph.subscription', 2860 2888 SiteStandardPublication: 'site.standard.publication', 2861 2889 SiteStandardThemeBasic: 'site.standard.theme.basic',
-4
src/hooks/useEntitlement.ts
··· 13 13 export function useCanSeePro(): boolean { 14 14 return true; 15 15 } 16 - 17 - export function useCanSeeNewsletterMode(): boolean { 18 - return useHasEntitlement("can_see_newsletter_mode"); 19 - }
+11 -2
src/utils/focusElement.ts
··· 1 1 import { isIOS } from "src/utils/isDevice"; 2 2 3 + // setSelectionRange throws InvalidStateError on input types other than 4 + // text/search/url/tel/password (e.g. email, number). 5 + const supportsSelection = ( 6 + el: HTMLInputElement | HTMLTextAreaElement, 7 + ): boolean => { 8 + if (el instanceof HTMLTextAreaElement) return true; 9 + return ["text", "search", "url", "tel", "password"].includes(el.type); 10 + }; 11 + 3 12 export const focusElement = ( 4 13 el?: HTMLInputElement | HTMLTextAreaElement | null, 5 14 ) => { ··· 22 31 el?.focus(); 23 32 fakeInput.remove(); 24 33 el.value = " "; 25 - el.setSelectionRange(1, 1); 34 + if (supportsSelection(el)) el.setSelectionRange(1, 1); 26 35 requestAnimationFrame(() => { 27 36 if (el) { 28 37 el.style.transform = ""; ··· 31 40 setTimeout(() => { 32 41 if (!el) return; 33 42 el.value = ""; 34 - el.setSelectionRange(0, 0); 43 + if (supportsSelection(el)) el.setSelectionRange(0, 0); 35 44 }, 50); 36 45 }, 20); 37 46 };
+3
supabase/migrations/20260429170854_add_rls_to_mention_tables.sql
··· 1 + alter table "public"."mention_service_configs" enable row level security; 2 + 3 + alter table "public"."mention_services" enable row level security;
+71
supabase/migrations/20260429191659_add_publicaton_pages_table.sql
··· 1 + create table "public"."publication_pages" ( 2 + "id" bigint generated by default as identity not null, 3 + "created_at" timestamp with time zone not null default now(), 4 + "leaflet_src" uuid not null, 5 + "document" text, 6 + "path" text, 7 + "publication" text not null, 8 + "title" text not null default '', 9 + "metadata" jsonb not null default '{}'::jsonb 10 + ); 11 + 12 + 13 + alter table "public"."publication_pages" enable row level security; 14 + 15 + CREATE UNIQUE INDEX publication_pages_pkey ON public.publication_pages USING btree (id, publication); 16 + 17 + alter table "public"."publication_pages" add constraint "publication_pages_pkey" PRIMARY KEY using index "publication_pages_pkey"; 18 + 19 + alter table "public"."publication_pages" add constraint "publication_pages_document_fkey" FOREIGN KEY (document) REFERENCES documents(uri) not valid; 20 + 21 + alter table "public"."publication_pages" validate constraint "publication_pages_document_fkey"; 22 + 23 + alter table "public"."publication_pages" add constraint "publication_pages_leaflet_src_fkey" FOREIGN KEY (leaflet_src) REFERENCES permission_tokens(id) not valid; 24 + 25 + alter table "public"."publication_pages" validate constraint "publication_pages_leaflet_src_fkey"; 26 + 27 + alter table "public"."publication_pages" add constraint "publication_pages_publication_fkey" FOREIGN KEY (publication) REFERENCES publications(uri) ON UPDATE CASCADE ON DELETE CASCADE not valid; 28 + 29 + alter table "public"."publication_pages" validate constraint "publication_pages_publication_fkey"; 30 + 31 + grant delete on table "public"."publication_pages" to "anon"; 32 + 33 + grant insert on table "public"."publication_pages" to "anon"; 34 + 35 + grant references on table "public"."publication_pages" to "anon"; 36 + 37 + grant select on table "public"."publication_pages" to "anon"; 38 + 39 + grant trigger on table "public"."publication_pages" to "anon"; 40 + 41 + grant truncate on table "public"."publication_pages" to "anon"; 42 + 43 + grant update on table "public"."publication_pages" to "anon"; 44 + 45 + grant delete on table "public"."publication_pages" to "authenticated"; 46 + 47 + grant insert on table "public"."publication_pages" to "authenticated"; 48 + 49 + grant references on table "public"."publication_pages" to "authenticated"; 50 + 51 + grant select on table "public"."publication_pages" to "authenticated"; 52 + 53 + grant trigger on table "public"."publication_pages" to "authenticated"; 54 + 55 + grant truncate on table "public"."publication_pages" to "authenticated"; 56 + 57 + grant update on table "public"."publication_pages" to "authenticated"; 58 + 59 + grant delete on table "public"."publication_pages" to "service_role"; 60 + 61 + grant insert on table "public"."publication_pages" to "service_role"; 62 + 63 + grant references on table "public"."publication_pages" to "service_role"; 64 + 65 + grant select on table "public"."publication_pages" to "service_role"; 66 + 67 + grant trigger on table "public"."publication_pages" to "service_role"; 68 + 69 + grant truncate on table "public"."publication_pages" to "service_role"; 70 + 71 + grant update on table "public"."publication_pages" to "service_role";
+17
components/Blocks/TextBlock/inputRules.ts
··· 3 3 inputRules, 4 4 wrappingInputRule, 5 5 } from "prosemirror-inputrules"; 6 + import { EditorState } from "prosemirror-state"; 6 7 import { MutableRefObject } from "react"; 7 8 import { Replicache } from "replicache"; 8 9 import type { ReplicacheMutators } from "src/replicache"; ··· 14 15 import { LAST_USED_CODE_LANGUAGE_KEY } from "src/utils/codeLanguageStorage"; 15 16 import { insertFootnote } from "./insertFootnote"; 16 17 import { useEditorStates } from "src/state/useEditorState"; 18 + 19 + const anchorInCodeMark = (state: EditorState, start: number, end: number) => { 20 + const codeMark = state.schema.marks.code; 21 + if (!codeMark) return false; 22 + const startMarks = state.doc.resolve(start).marks(); 23 + const endMarks = state.doc.resolve(end).marks(); 24 + return !!codeMark.isInSet(startMarks) || !!codeMark.isInSet(endMarks); 25 + }; 17 26 export const inputrules = ( 18 27 propsRef: MutableRefObject<BlockProps & { entity_set: { set: string } }>, 19 28 repRef: MutableRefObject<Replicache<ReplicacheMutators> | null>, ··· 23 32 //Strikethrough 24 33 rules: [ 25 34 new InputRule(/\~\~([^*]+)\~\~$/, (state, match, start, end) => { 35 + if (anchorInCodeMark(state, start, end)) return null; 26 36 const [fullMatch, content] = match; 27 37 const { tr } = state; 28 38 if (content) { ··· 40 50 41 51 //Highlight 42 52 new InputRule(/\=\=([^*]+)\=\=$/, (state, match, start, end) => { 53 + if (anchorInCodeMark(state, start, end)) return null; 43 54 const [fullMatch, content] = match; 44 55 const { tr } = state; 45 56 if (content) { ··· 59 70 60 71 //Bold 61 72 new InputRule(/\*\*([^*]+)\*\*$/, (state, match, start, end) => { 73 + if (anchorInCodeMark(state, start, end)) return null; 62 74 const [fullMatch, content] = match; 63 75 const { tr } = state; 64 76 if (content) { ··· 76 88 77 89 //Code 78 90 new InputRule(/\`([^`]+)\`$/, (state, match, start, end) => { 91 + if (anchorInCodeMark(state, start, end)) return null; 79 92 const [fullMatch, content] = match; 80 93 const { tr } = state; 81 94 if (content) { ··· 94 107 95 108 //Italic 96 109 new InputRule(/(?:^|[^*])\*([^*]+)\*$/, (state, match, start, end) => { 110 + if (anchorInCodeMark(state, start, end)) return null; 97 111 const [fullMatch, content] = match; 98 112 const { tr } = state; 99 113 if (content) { ··· 231 245 232 246 // Footnote - [^ triggers footnote insertion 233 247 new InputRule(/\[\^$/, (state, match, start, end) => { 248 + if (anchorInCodeMark(state, start, end)) return null; 234 249 let tr = state.tr.delete(start, end); 235 250 setTimeout(() => { 236 251 let view = useEditorStates.getState().editorStates[propsRef.current.entityID]?.view; ··· 248 263 // Mention - @ at start of line, after space, or after hard break 249 264 new InputRule(/(?:^|\s)@$/, (state, match, start, end) => { 250 265 if (!openMentionAutocomplete) return null; 266 + if (anchorInCodeMark(state, start, end)) return null; 251 267 // Schedule opening the autocomplete after the transaction is applied 252 268 setTimeout(() => openMentionAutocomplete(), 0); 253 269 return null; // Let the @ be inserted normally ··· 255 271 // Mention - @ immediately after a hard break (hard breaks are nodes, not text) 256 272 new InputRule(/@$/, (state, match, start, end) => { 257 273 if (!openMentionAutocomplete) return null; 274 + if (anchorInCodeMark(state, start, end)) return null; 258 275 // Check if the character before @ is a hard break node 259 276 const $pos = state.doc.resolve(start); 260 277 const nodeBefore = $pos.nodeBefore;
+1
app/(home-pages)/(writer)/home/HomeLayout.tsx
··· 212 212 ...leaflet, 213 213 leaflets_in_publications: leaflet.leaflets_in_publications || [], 214 214 leaflets_to_documents: leaflet.leaflets_to_documents || [], 215 + publication_pages: [], 215 216 blocked_by_admin: null, 216 217 custom_domain_routes: [], 217 218 }}
+5
app/(home-pages)/tag/[tag]/getDocumentsByTag.ts
··· 45 45 return null; 46 46 } 47 47 48 + // Skip if document has no sort_date 49 + if (!doc.sort_date) { 50 + return null; 51 + } 52 + 48 53 // Normalize the document data - skip unrecognized formats 49 54 const normalizedData = normalizeDocumentRecord(doc.data, doc.uri); 50 55 if (!normalizedData) {
+24 -3
app/api/inngest/functions/send_post_broadcast.ts
··· 45 45 async ({ event, step }) => { 46 46 const { publication_uri, document_uri } = event.data; 47 47 48 + const authorDid = new AtUri(document_uri).host; 49 + 48 50 const loaded = await step.run("load-pub-and-doc", async () => { 49 - const [pubRes, docRes] = await Promise.all([ 51 + const [pubRes, docRes, profileRes] = await Promise.all([ 50 52 supabaseServerClient 51 53 .from("publications") 52 54 .select( ··· 59 61 .select("data") 60 62 .eq("uri", document_uri) 61 63 .maybeSingle(), 64 + supabaseServerClient 65 + .from("bsky_profiles") 66 + .select("handle") 67 + .eq("did", authorDid) 68 + .maybeSingle(), 62 69 ]); 63 - return { pub: pubRes.data, doc: docRes.data }; 70 + return { 71 + pub: pubRes.data, 72 + doc: docRes.data, 73 + profile: profileRes.data, 74 + }; 64 75 }); 65 76 66 77 const settings = loaded.pub?.publication_newsletter_settings; ··· 108 119 } 109 120 const fromHeader = buildFromHeader(pubRecord?.name, fromDomain); 110 121 const replyToEmail = resolveReplyToEmail(settings); 111 - const did = new AtUri(document_uri).host; 122 + const did = authorDid; 123 + const authorName = loaded.profile?.handle ?? undefined; 124 + const publishedAtLabel = docRecord?.publishedAt 125 + ? new Date(docRecord.publishedAt).toLocaleDateString("en-US", { 126 + month: "short", 127 + day: "numeric", 128 + year: "numeric", 129 + }) 130 + : undefined; 112 131 113 132 // The first page is the document body. Canvas pages don't map to a linear 114 133 // email body — the email renders an empty postContent section and falls ··· 160 179 postTitle, 161 180 postDescription, 162 181 postUrl, 182 + authorName, 183 + publishedAtLabel, 163 184 blocks, 164 185 did, 165 186 assetsBaseUrl: `${assetsBaseUrl}/`,
+66 -10
app/api/oauth/[route]/route.ts
··· 1 - import { subscribeToPublication } from "app/lish/subscribeToPublication"; 1 + import { 2 + backfillAtprotoSubscriptionsForIdentity, 3 + subscribeToPublication, 4 + } from "app/lish/subscribeToPublication"; 2 5 import { cookies } from "next/headers"; 3 6 import { redirect } from "next/navigation"; 4 7 import { NextRequest, NextResponse } from "next/server"; ··· 16 19 parseActionFromSearchParam, 17 20 } from "./afterSignInActions"; 18 21 import { inngest } from "app/api/inngest/client"; 22 + import { mergeEmailIdentityIntoAtpIdentity } from "src/mergeIdentity"; 19 23 20 24 type OauthRequestClientState = { 21 25 redirect: string | null; 22 26 action: ActionAfterSignIn | null; 23 27 link?: boolean; 28 + // Auto-confirm a cross-identity merge instead of routing to /merge-accounts. 29 + // Set when the caller already showed an in-context "link this account?" 30 + // confirmation (e.g. the subscribe-flow LinkIdentityModal). 31 + autoMerge?: boolean; 24 32 }; 25 33 26 34 export async function GET( ··· 39 47 const handle = searchParams.get("handle") as string; 40 48 const signup = searchParams.get("signup") === "true"; 41 49 const link = searchParams.get("link") === "true"; 50 + const autoMerge = searchParams.get("autoMerge") === "true"; 42 51 // Put originating page here! 43 52 let redirect = searchParams.get("redirect_url"); 44 53 if (redirect) redirect = decodeURIComponent(redirect); 45 54 let action = parseActionFromSearchParam(searchParams.get("action")); 46 - let state: OauthRequestClientState = { redirect, action, link }; 55 + let state: OauthRequestClientState = { 56 + redirect, 57 + action, 58 + link, 59 + autoMerge, 60 + }; 47 61 48 62 // Revoke any pending authentication requests if the connection is closed (optional) 49 63 const ac = new AbortController(); ··· 90 104 91 105 // Explicit link flow from the LoginModal for an email-only user. Never 92 106 // fall through to a normal DID login — we must either attach the atp_did 93 - // to the existing email identity or route to /merge-accounts. 107 + // to the existing email identity or route to /merge-accounts (or merge 108 + // inline if autoMerge is set). 94 109 if ( 95 110 s.link && 96 111 currentIdentity && ··· 102 117 .from("identities") 103 118 .update({ atp_did: session.did }) 104 119 .eq("id", currentIdentity.id); 120 + await backfillAtprotoSubscriptionsForIdentity( 121 + currentIdentity.id, 122 + session.did, 123 + ); 105 124 return handleAction(s.action, redirectPath); 106 125 } 107 126 if (identity.id !== currentIdentity.id) { 108 - await stagePendingMerge(identity.id, redirectPath); 109 - // Only reached if the token insert failed. Fall through to the 110 - // normal sign-in flow rather than blocking the user. 127 + if (s.autoMerge) { 128 + const merged = await mergeEmailIdentityIntoAtpIdentity({ 129 + sourceId: currentIdentity.id, 130 + targetId: identity.id, 131 + }); 132 + if (merged.ok) { 133 + // Source identity is gone; clear it so the cross-identity 134 + // merge block below doesn't try to merge a deleted row. 135 + currentIdentity = null; 136 + } else { 137 + console.error( 138 + "[oauth/callback] autoMerge failed:", 139 + merged.error, 140 + ); 141 + await stagePendingMerge(identity.id, redirectPath); 142 + } 143 + } else { 144 + await stagePendingMerge(identity.id, redirectPath); 145 + // Only reached if the token insert failed. Fall through to the 146 + // normal sign-in flow rather than blocking the user. 147 + } 111 148 } 112 149 // Same identity already linked — fall through to refresh session. 113 150 } ··· 118 155 .from("identities") 119 156 .update({ atp_did: session.did }) 120 157 .eq("id", currentIdentity.id); 158 + await backfillAtprotoSubscriptionsForIdentity( 159 + currentIdentity.id, 160 + session.did, 161 + ); 121 162 return handleAction(s.action, redirectPath); 122 163 } 123 164 const { data } = await supabaseServerClient ··· 133 174 currentIdentity.email 134 175 ) { 135 176 // DID already has an identity row. Caller is currently signed in as a 136 - // *different* email-only identity. Stage a pending merge and let the 137 - // user confirm on /merge-accounts before we touch either account. 138 - await stagePendingMerge(identity.id, redirectPath); 139 - // Only reached if the token insert failed — fall through. 177 + // *different* email-only identity. Either merge inline (autoMerge 178 + // means the caller already collected the user's confirmation) or 179 + // stage a pending merge for /merge-accounts. 180 + if (s.autoMerge) { 181 + const merged = await mergeEmailIdentityIntoAtpIdentity({ 182 + sourceId: currentIdentity.id, 183 + targetId: identity.id, 184 + }); 185 + if (!merged.ok) { 186 + console.error( 187 + "[oauth/callback] autoMerge failed:", 188 + merged.error, 189 + ); 190 + await stagePendingMerge(identity.id, redirectPath); 191 + } 192 + } else { 193 + await stagePendingMerge(identity.id, redirectPath); 194 + // Only reached if the token insert failed — fall through. 195 + } 140 196 } 141 197 142 198 // Trigger migration if identity needs it
+4 -2
app/api/rpc/[command]/get_leaflet_data.ts
··· 8 8 9 9 const leaflets_in_publications_query = `leaflets_in_publications(*, publications(*), documents(*))`; 10 10 const leaflets_to_documents_query = `leaflets_to_documents(*, documents(*))`; 11 + const publication_pages_query = `publication_pages!publication_pages_leaflet_src_fkey(*, publications(*))`; 11 12 export const get_leaflet_data = makeRoute({ 12 13 route: "get_leaflet_data", 13 14 input: z.object({ ··· 19 20 .from("permission_tokens") 20 21 .select( 21 22 `*, 22 - permission_token_rights(*, entity_sets(permission_tokens(${leaflets_in_publications_query}, ${leaflets_to_documents_query}))), 23 + permission_token_rights(*, entity_sets(permission_tokens(${leaflets_in_publications_query}, ${leaflets_to_documents_query}, ${publication_pages_query}))), 23 24 custom_domain_routes!custom_domain_routes_edit_permission_token_fkey(*), 24 25 ${leaflets_in_publications_query}, 25 - ${leaflets_to_documents_query}`, 26 + ${leaflets_to_documents_query}, 27 + ${publication_pages_query}`, 26 28 ) 27 29 .eq("id", token_id) 28 30 .single();
+47 -29
app/api/rpc/[command]/get_publication_subscribers_timeseries.ts
··· 34 34 return { error: "not_found" as const }; 35 35 } 36 36 37 - let query = supabase 38 - .from("publication_subscriptions") 39 - .select("created_at") 37 + // Fetch all atproto subscriptions and confirmed email subscriptions in 38 + // parallel. We dedupe in memory (mirroring PublicationSubscribers.tsx) so 39 + // we need the full sets — date filtering is applied after. 40 + const { data: newsletterSettings } = await supabase 41 + .from("publication_newsletter_settings") 42 + .select("enabled") 40 43 .eq("publication", publication_uri) 41 - .order("created_at", { ascending: true }); 44 + .maybeSingle(); 45 + const newsletterEnabled = !!newsletterSettings?.enabled; 42 46 43 - if (from) { 44 - query = query.gte("created_at", from); 45 - } 46 - if (to) { 47 - query = query.lte("created_at", to); 48 - } 49 - 50 - const { data: subscriptions } = await query; 51 - 52 - // Bucket subscriptions by day and compute cumulative count 53 - const dailyCounts: Record<string, number> = {}; 54 - for (const sub of subscriptions || []) { 55 - const day = sub.created_at.slice(0, 10); 56 - dailyCounts[day] = (dailyCounts[day] || 0) + 1; 57 - } 58 - 59 - let cumulative = 0; 60 - 61 - // If we have a from filter, get the count of subscriptions before that date 62 - if (from) { 63 - const { count } = await supabase 47 + const [{ data: atprotoSubs }, { data: emailSubs }] = await Promise.all([ 48 + supabase 64 49 .from("publication_subscriptions") 65 - .select("*", { count: "exact", head: true }) 66 - .eq("publication", publication_uri) 67 - .lt("created_at", from); 68 - cumulative = count || 0; 50 + .select("created_at, identities(bsky_profiles(did))") 51 + .eq("publication", publication_uri), 52 + newsletterEnabled 53 + ? supabase 54 + .from("publication_email_subscribers") 55 + .select("id, created_at, identities(atp_did)") 56 + .eq("publication", publication_uri) 57 + .eq("state", "confirmed") 58 + : Promise.resolve({ data: [] as const }), 59 + ]); 60 + 61 + // Build dedup map keyed by DID (atproto identity) or email-sub id. 62 + // Atproto sub's created_at wins when both channels exist for the same DID, 63 + // matching the UI merge in PublicationSubscribers.tsx. 64 + const subscribers = new Map<string, string>(); 65 + for (const s of atprotoSubs || []) { 66 + const did = s.identities?.bsky_profiles?.did; 67 + if (!did) continue; 68 + subscribers.set(`did:${did}`, s.created_at); 69 + } 70 + for (const s of emailSubs || []) { 71 + const linkedDid = s.identities?.atp_did ?? undefined; 72 + if (linkedDid && subscribers.has(`did:${linkedDid}`)) continue; 73 + subscribers.set(`email:${s.id}`, s.created_at); 74 + } 75 + 76 + // Bucket the deduped subscribers' creation dates. 77 + const dailyCounts: Record<string, number> = {}; 78 + let cumulative = 0; 79 + for (const createdAt of subscribers.values()) { 80 + if (from && createdAt < from) { 81 + cumulative += 1; 82 + continue; 83 + } 84 + if (to && createdAt > to) continue; 85 + const day = createdAt.slice(0, 10); 86 + dailyCounts[day] = (dailyCounts[day] || 0) + 1; 69 87 } 70 88 71 89 // Build timeseries over the full date range, filling gaps with the
+9 -7
app/lish/[did]/[publication]/PublicationContent.tsx
··· 80 80 ) : undefined 81 81 } 82 82 subscribeButton={ 83 - <SubscribeInput 84 - publicationUri={publication.uri} 85 - publicationUrl={record?.url} 86 - publicationName={record?.name ?? publication.name} 87 - publicationDescription={record?.description} 88 - newsletterMode={newsletterMode} 89 - /> 83 + <div className="max-w-sm mx-auto"> 84 + <SubscribeInput 85 + publicationUri={publication.uri} 86 + publicationUrl={record?.url} 87 + publicationName={record?.name ?? publication.name} 88 + publicationDescription={record?.description} 89 + newsletterMode={newsletterMode} 90 + /> 91 + </div> 90 92 } 91 93 /> 92 94 <div className="publicationPostList w-full flex flex-col gap-4">
+5 -1
app/lish/[did]/[publication]/UpgradeModal.tsx
··· 33 33 <div className="">Subscriber Counts</div> 34 34 <div className="">Top Referrers</div> 35 35 <hr className="my-4 border-border-light" /> 36 + <div className="font-bold text-primary">Emails</div> 37 + <div className="text-sm italic"> 38 + First 1,000 subscribers included, $5/1K after that 39 + </div> 40 + <hr className="my-4 border-border-light" /> 36 41 <div className="font-bold text-primary">Coming ASAP</div> 37 - <div className="">Emails</div> 38 42 <div className="">Paid Membership</div> 39 43 </div> 40 44 <div className="sm:w-64 w-full accent-container flex justify-center items-center">
+26
lexicons/site/standard/graph/recommend.json
··· 1 + { 2 + "id": "site.standard.graph.recommend", 3 + "defs": { 4 + "main": { 5 + "key": "tid", 6 + "type": "record", 7 + "record": { 8 + "type": "object", 9 + "required": ["document", "createdAt"], 10 + "properties": { 11 + "document": { 12 + "type": "string", 13 + "format": "at-uri", 14 + "description": "AT-URI reference to the document record being recommended (ex: at://did:plc:abc123/site.standard.document/xyz789)." 15 + }, 16 + "createdAt": { 17 + "type": "string", 18 + "format": "datetime" 19 + } 20 + } 21 + }, 22 + "description": "Record declaring a recommendation of a document." 23 + } 24 + }, 25 + "lexicon": 1 26 + }
+3 -5
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
··· 23 23 } from "./Footnotes/PublishedFootnotes"; 24 24 import { PublishedFootnoteSideColumn } from "./Footnotes/PublishedFootnoteSideColumn"; 25 25 import { PublishedFootnotePopover } from "./Footnotes/PublishedFootnotePopover"; 26 - import { PostPubInfo } from "./PostPubInfo"; 26 + import { SubscribePanel } from "components/Subscribe/SubscribeButton"; 27 27 28 28 export function LinearDocumentPage({ 29 29 blocks, ··· 107 107 /> 108 108 <div className={`spacer h-4 w-full`} /> 109 109 {document.publication?.uri && ( 110 - <PostPubInfo 110 + <SubscribePanel 111 111 publicationUri={document.publication.uri} 112 112 publicationUrl={props.pubRecord?.url} 113 - publicationName={ 114 - props.pubRecord?.name ?? document.publication.name 115 - } 113 + publicationName={props.pubRecord?.name ?? document.publication.name} 116 114 publicationDescription={props.pubRecord?.description} 117 115 newsletterMode={document.publication.newsletterMode} 118 116 />
-1
app/lish/[did]/[publication]/[rkey]/PostPubInfo.tsx
··· 1 - export { SubscribePanel as PostPubInfo } from "components/Subscribe/SubscribeButton";
+65 -5
app/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx
··· 7 7 import { Separator } from "components/Layout"; 8 8 import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny"; 9 9 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 10 + import { useDashboardState } from "components/PageLayouts/dashboardState"; 10 11 11 12 type subscriber = { email: string | undefined; did: string | undefined }; 13 + 14 + type SubscriberStatus = "subscribed" | "unconfirmed" | "unsubscribed"; 12 15 13 16 type MergedSubscriber = { 14 17 key: string; ··· 16 19 handle: string | undefined; 17 20 email: string | undefined; 18 21 created_at: string; 22 + status: SubscriberStatus; 19 23 }; 20 24 21 25 export function PublicationSubscribers(props: { ··· 23 27 }) { 24 28 let smoker = useSmoker(); 25 29 let { data: publication } = usePublicationData(); 30 + let { subscriberStatus } = useDashboardState(); 26 31 27 32 if (!publication) return <div>null</div>; 28 - let atprotoSubs = publication.publication?.publication_subscriptions || []; 33 + // ATProto subscribers have no email lifecycle state — they're just present 34 + // or absent, so they only count under the "subscribed" status filter. 35 + let atprotoSubs = subscriberStatus.subscribed 36 + ? publication.publication?.publication_subscriptions || [] 37 + : []; 29 38 let newsletterEnabled = 30 39 !!publication.publication?.publication_newsletter_settings?.enabled; 31 40 let emailSubs = newsletterEnabled 32 41 ? (publication.publication?.publication_email_subscribers || []).filter( 33 - (s) => s.state === "confirmed", 42 + (s) => { 43 + if (s.state === "confirmed") return subscriberStatus.subscribed; 44 + if (s.state === "pending") return subscriberStatus.unconfirmed; 45 + if (s.state === "unsubscribed") return subscriberStatus.unsubscribed; 46 + return false; 47 + }, 34 48 ) 35 49 : []; 36 50 ··· 45 59 handle: s.identities?.bsky_profiles?.handle ?? undefined, 46 60 email: undefined, 47 61 created_at: s.created_at, 62 + status: "subscribed", 48 63 }); 49 64 } 50 65 for (let s of emailSubs) { 66 + let status: SubscriberStatus = 67 + s.state === "pending" 68 + ? "unconfirmed" 69 + : s.state === "unsubscribed" 70 + ? "unsubscribed" 71 + : "subscribed"; 51 72 let linkedDid = s.identities?.atp_did ?? undefined; 52 73 let existing = linkedDid ? byDid.get(linkedDid) : undefined; 53 - if (existing) { 74 + if (existing && status === "subscribed") { 54 75 existing.email = s.email; 55 76 continue; 56 77 } ··· 60 81 handle: s.identities?.bsky_profiles?.handle ?? undefined, 61 82 email: s.email, 62 83 created_at: s.created_at, 84 + status, 63 85 }); 64 86 } 65 87 let subscribers: MergedSubscriber[] = [...byDid.values(), ...emailOnly]; ··· 80 102 // } 81 103 // }, [checkedSubscribers]); 82 104 83 - if (subscribers.length === 0) 105 + let activeStatuses = ( 106 + Object.keys(subscriberStatus) as SubscriberStatus[] 107 + ).filter((k) => subscriberStatus[k]); 108 + let isDefaultStatusFilter = 109 + activeStatuses.length === 1 && activeStatuses[0] === "subscribed"; 110 + 111 + if (subscribers.length === 0) { 112 + if (!isDefaultStatusFilter) { 113 + let label = 114 + activeStatuses.length === 0 115 + ? "any status" 116 + : activeStatuses 117 + .map((s) => (s === "unconfirmed" ? "unconfirmed" : s)) 118 + .join(", "); 119 + return ( 120 + <div 121 + className={`italic text-tertiary flex flex-col gap-0 text-center justify-center py-4 border rounded-md ${props.showPageBackground ? "border-border-light p-2" : "border-transparent"}`} 122 + style={ 123 + props.showPageBackground 124 + ? { 125 + backgroundColor: 126 + "rgba(var(--bg-page), var(--bg-page-alpha)) ", 127 + } 128 + : { backgroundColor: "transparent" } 129 + } 130 + > 131 + <p className="font-bold">No subscribers match this filter</p> 132 + <p>Showing: {label}</p> 133 + </div> 134 + ); 135 + } 84 136 return ( 85 137 <div 86 138 className={`italic text-tertiary flex flex-col gap-0 text-center justify-center py-4 border rounded-md ${props.showPageBackground ? "border-border-light p-2" : "border-transparent"}`} ··· 115 167 </ButtonPrimary> 116 168 </div> 117 169 ); 170 + } 118 171 119 172 return ( 120 173 <div ··· 166 219 did={subscriber.did} 167 220 email={subscriber.email} 168 221 createdAt={subscriber.created_at} 222 + status={subscriber.status} 169 223 /> 170 224 ))} 171 225 </div> ··· 178 232 did: string | undefined; 179 233 email: string | undefined; 180 234 createdAt: string; 235 + status: SubscriberStatus; 181 236 }) => { 182 237 return ( 183 238 <div className="flex items-end flex-row gap-2 w-full"> ··· 197 252 <a 198 253 target="_blank" 199 254 href={`mailto:${props.email}`} 200 - className="font-bold text-primary" 255 + className={`font-bold ${props.status === "subscribed" ? "text-primary" : "text-tertiary line-through"}`} 201 256 > 202 257 {props.email} 203 258 </a> 259 + )} 260 + {props.status !== "subscribed" && ( 261 + <span className="text-sm italic text-tertiary"> 262 + {props.status === "unconfirmed" ? "unconfirmed" : "unsubscribed"} 263 + </span> 204 264 )} 205 265 <SubscriberDate createdAt={props.createdAt} /> 206 266 </div>
+1 -1
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
··· 113 113 bsky_like_count: doc.bsky_like_count ?? 0, 114 114 indexed: true, 115 115 recommend_count: doc.recommendsCount ?? 0, 116 - identity_did: null, 117 116 }, 118 117 }, 119 118 ], 120 119 leaflets_to_documents: [], 120 + publication_pages: [], 121 121 blocked_by_admin: null, 122 122 custom_domain_routes: [], 123 123 }}
+15 -10
app/lish/[did]/[publication]/[rkey]/Interactions/recommendAction.ts
··· 1 1 "use server"; 2 2 3 - import { AtpBaseClient, PubLeafletInteractionsRecommend } from "lexicons/api"; 3 + import { AtpBaseClient, SiteStandardGraphRecommend } from "lexicons/api"; 4 4 import { getIdentityData } from "actions/getIdentityData"; 5 5 import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; 6 6 import { TID } from "@atproto/common"; ··· 45 45 credentialSession.fetchHandler.bind(credentialSession), 46 46 ); 47 47 48 - let record: Un$Typed<PubLeafletInteractionsRecommend.Record> = { 49 - subject: args.document, 48 + let record: Un$Typed<SiteStandardGraphRecommend.Record> = { 49 + document: args.document, 50 50 createdAt: new Date().toISOString(), 51 51 }; 52 52 53 53 let rkey = TID.nextStr(); 54 54 let uri = AtUri.make( 55 55 credentialSession.did!, 56 - "pub.leaflet.interactions.recommend", 56 + "site.standard.graph.recommend", 57 57 rkey, 58 58 ); 59 59 60 - await agent.pub.leaflet.interactions.recommend.create( 60 + await agent.site.standard.graph.recommend.create( 61 61 { rkey, repo: credentialSession.did! }, 62 62 record, 63 63 ); ··· 67 67 document: args.document, 68 68 recommender_did: credentialSession.did!, 69 69 record: { 70 - $type: "pub.leaflet.interactions.recommend", 70 + $type: "site.standard.graph.recommend", 71 71 ...record, 72 72 } as unknown as Json, 73 73 }); ··· 139 139 140 140 let uri = new AtUri(existingRecommend.uri); 141 141 142 - await agent.pub.leaflet.interactions.recommend.delete({ 143 - rkey: uri.rkey, 144 - repo: credentialSession.did!, 145 - }); 142 + // Delete from both collections (old and new schema) - one or both may exist 143 + await Promise.all([ 144 + agent.site.standard.graph.recommend 145 + .delete({ rkey: uri.rkey, repo: credentialSession.did! }) 146 + .catch(() => {}), 147 + agent.pub.leaflet.interactions.recommend 148 + .delete({ rkey: uri.rkey, repo: credentialSession.did! }) 149 + .catch(() => {}), 150 + ]); 146 151 147 152 await supabaseServerClient 148 153 .from("recommends_on_documents")
+26 -2
app/lish/[did]/[publication]/dashboard/settings/ProSettings.tsx
··· 66 66 67 67 if (!newsletterMode) { 68 68 return ( 69 - <DashboardContainer section="Newsletter" className="pb-4"> 69 + <DashboardContainer 70 + section={ 71 + <> 72 + Newsletter 73 + <span className="bg-accent-1 rounded-md px-1 text-accent-2 font-bold text-sm"> 74 + alpha 75 + </span> 76 + </> 77 + } 78 + className="pb-4" 79 + > 70 80 <div className="leading-snug text-secondary"> 71 81 Email posts directly to publication subscribers when you publish. 82 + </div> 83 + <div className="leading-snug text-tertiary text-sm"> 84 + Your first 1k email subscribers are included with Leaflet Pro. After 85 + that, it's $5 for each additional 1k subs. Questions? Reach out! 72 86 </div> 73 87 <ButtonPrimary 74 88 className="self-start" ··· 99 113 replyToValue.trim().toLowerCase() !== savedReplyTo.toLowerCase(); 100 114 101 115 return ( 102 - <DashboardContainer section="Newsletter" className="pb-4"> 116 + <DashboardContainer 117 + section={ 118 + <> 119 + Newsletter 120 + <span className="bg-accent-1 rounded-md px-1 text-accent-2 font-bold text-sm"> 121 + alpha 122 + </span> 123 + </> 124 + } 125 + className="pb-4" 126 + > 103 127 <div className="flex flex-col gap-4"> 104 128 <div className="flex items-center justify-between gap-3 flex-wrap"> 105 129 <div className="text-secondary leading-snug">
+6 -9
app/lish/[did]/[publication]/dashboard/settings/SettingsContent.tsx
··· 16 16 import { ThemeSettings } from "./ThemeSettings"; 17 17 import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 18 18 import { ManageProSubscription, NewsletterSettings } from "./ProSettings"; 19 - import { 20 - useIsPro, 21 - useCanSeePro, 22 - useCanSeeNewsletterMode, 23 - } from "src/hooks/useEntitlement"; 19 + import { useIsPro, useCanSeePro } from "src/hooks/useEntitlement"; 24 20 import { InlineUpgradeToPro, UpgradeToProButton } from "../../UpgradeModal"; 25 21 import { Modal } from "components/Modal"; 26 22 import { Input } from "components/Input"; ··· 35 31 let { publication: pubData } = data || {}; 36 32 let isPro = useIsPro(); 37 33 let canSeePro = useCanSeePro(); 38 - let canSeeNewsletterMode = useCanSeeNewsletterMode(); 39 34 let record = useNormalizedPublicationRecord(); 40 35 let [loading, setLoading] = useState(false); 41 36 let toast = useToaster(); ··· 204 199 <DashboardContainer section="Leaflet Pro" className="pb-4"> 205 200 <ManageProSubscription compact /> 206 201 </DashboardContainer> 207 - {canSeeNewsletterMode && <NewsletterSettings />} 202 + <NewsletterSettings /> 208 203 </> 209 204 )} 210 205 <div className="flex flex-col gap-1"> ··· 269 264 export const DashboardContainer = (props: { 270 265 children: React.ReactNode; 271 266 className?: string; 272 - section?: string; 267 + section?: React.ReactNode; 273 268 }) => { 274 269 let cardBorderHidden = useCardBorderHidden(); 275 270 return ( ··· 278 273 > 279 274 {props.section && ( 280 275 <> 281 - <h3 className="font-bold text-primary">{props.section}</h3> 276 + <h3 className="font-bold text-primary flex items-center gap-2"> 277 + {props.section} 278 + </h3> 282 279 <hr className="-mt-1 mb-2 border-border-light" /> 283 280 </> 284 281 )}
+4
app/lish/[did]/[publication]/dashboard/subs/page.tsx
··· 1 1 "use client"; 2 2 3 3 import { DashboardPageLayout } from "components/PageLayouts/DashboardPageLayout"; 4 + import { SubscriberStatusFilter } from "components/PageLayouts/PageSearch"; 4 5 import { PublicationSubscribers } from "../PublicationSubscribers"; 5 6 import { NewDraftActionButton } from "../NewDraftButton"; 6 7 import { ··· 22 23 publication={pubUri} 23 24 showHeader={false} 24 25 > 26 + <div className="flex justify-end text-sm text-tertiary"> 27 + <SubscriberStatusFilter /> 28 + </div> 25 29 <PublicationSubscribers showPageBackground={showPageBackground} /> 26 30 </DashboardPageLayout> 27 31 );
+33
lexicons/api/types/site/standard/graph/recommend.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util' 12 + 13 + const is$typed = _is$typed, 14 + validate = _validate 15 + const id = 'site.standard.graph.recommend' 16 + 17 + export interface Record { 18 + $type: 'site.standard.graph.recommend' 19 + /** AT-URI reference to the document record being recommended (ex: at://did:plc:abc123/site.standard.document/xyz789). */ 20 + document: string 21 + createdAt: string 22 + [k: string]: unknown 23 + } 24 + 25 + const hashRecord = 'main' 26 + 27 + export function isRecord<V>(v: V) { 28 + return is$typed(v, id, hashRecord) 29 + } 30 + 31 + export function validateRecord<V>(v: V) { 32 + return validate<Record & V>(v, id, hashRecord, true) 33 + }