+2
-1
.gitignore
+2
-1
.gitignore
+9
README.md
+9
README.md
···
15
15
16
16
run dev with `npm run dev` (port 3768) and build with `npm run build` (the output is the `dist` folder)
17
17
18
+
19
+
20
+
you probably dont need to change these
21
+
```ts
22
+
const PROD_HANDLE_RESOLVER_PDS = "https://pds-nd.whey.party"
23
+
const DEV_HANDLE_RESOLVER_PDS = "https://bsky.social"
24
+
```
25
+
if you do want to change these, i recommend changing both of these to your own PDS url. i separate the prod and dev urls so that you can change it as needed. here i separated it because if the prod resolver and prod url shares the same domain itll error and prevent logins
26
+
18
27
## useQuery
19
28
Red Dwarf has been upgraded from its original bespoke caching system to Tanstack Query (react query). this migration was done to achieve a more robust and maintainable approach to data fetching and caching and state synchronization. ive seen serious performance gains from this switch!
20
29
+62
-30
oauthdev.mts
+62
-30
oauthdev.mts
···
1
-
import fs from 'fs';
2
-
import path from 'path';
1
+
import fs from "fs";
2
+
import path from "path";
3
3
//import { generateClientMetadata } from './src/helpers/oauthClient'
4
4
export const generateClientMetadata = (appOrigin: string) => {
5
-
const callbackPath = '/callback';
5
+
const callbackPath = "/callback";
6
6
7
7
return {
8
-
"client_id": `${appOrigin}/client-metadata.json`,
9
-
"client_name": "ForumTest",
10
-
"client_uri": appOrigin,
11
-
"logo_uri": `${appOrigin}/logo192.png`,
12
-
"tos_uri": `${appOrigin}/terms-of-service`,
13
-
"policy_uri": `${appOrigin}/privacy-policy`,
14
-
"redirect_uris": [`${appOrigin}${callbackPath}`] as [string, ...string[]],
15
-
"scope": "atproto transition:generic",
16
-
"grant_types": ["authorization_code", "refresh_token"] as ["authorization_code", "refresh_token"],
17
-
"response_types": ["code"] as ["code"],
18
-
"token_endpoint_auth_method": "none" as "none",
19
-
"application_type": "web" as "web",
20
-
"dpop_bound_access_tokens": true
21
-
};
22
-
}
23
-
8
+
client_id: `${appOrigin}/client-metadata.json`,
9
+
client_name: "ForumTest",
10
+
client_uri: appOrigin,
11
+
logo_uri: `${appOrigin}/logo192.png`,
12
+
tos_uri: `${appOrigin}/terms-of-service`,
13
+
policy_uri: `${appOrigin}/privacy-policy`,
14
+
redirect_uris: [`${appOrigin}${callbackPath}`] as [string, ...string[]],
15
+
scope: "atproto transition:generic",
16
+
grant_types: ["authorization_code", "refresh_token"] as [
17
+
"authorization_code",
18
+
"refresh_token",
19
+
],
20
+
response_types: ["code"] as ["code"],
21
+
token_endpoint_auth_method: "none" as "none",
22
+
application_type: "web" as "web",
23
+
dpop_bound_access_tokens: true,
24
+
};
25
+
};
24
26
25
-
export function generateMetadataPlugin({prod, dev}:{prod: string, dev: string}) {
27
+
export function generateMetadataPlugin({
28
+
prod,
29
+
dev,
30
+
prodResolver = "https://bsky.social",
31
+
devResolver = prodResolver,
32
+
}: {
33
+
prod: string;
34
+
dev: string;
35
+
prodResolver?: string;
36
+
devResolver?: string;
37
+
}) {
26
38
return {
27
-
name: 'vite-plugin-generate-metadata',
39
+
name: "vite-plugin-generate-metadata",
28
40
config(_config: any, { mode }: any) {
29
-
let appOrigin;
30
-
if (mode === 'production') {
31
-
appOrigin = prod
32
-
if (!appOrigin || !appOrigin.startsWith('https://')) {
33
-
throw new Error('VITE_APP_ORIGIN environment variable must be set to a valid HTTPS URL for production build.');
41
+
console.log('๐ก vite mode =', mode)
42
+
let appOrigin, resolver;
43
+
if (mode === "production") {
44
+
appOrigin = prod;
45
+
resolver = prodResolver;
46
+
if (!appOrigin || !appOrigin.startsWith("https://")) {
47
+
throw new Error(
48
+
"VITE_APP_ORIGIN environment variable must be set to a valid HTTPS URL for production build."
49
+
);
34
50
}
35
51
} else {
36
52
appOrigin = dev;
53
+
resolver = devResolver;
37
54
}
38
-
39
-
55
+
40
56
const metadata = generateClientMetadata(appOrigin);
41
-
const outputPath = path.resolve(process.cwd(), 'public', 'client-metadata.json');
57
+
const outputPath = path.resolve(
58
+
process.cwd(),
59
+
"public",
60
+
"client-metadata.json"
61
+
);
42
62
43
63
fs.writeFileSync(outputPath, JSON.stringify(metadata, null, 2));
44
64
65
+
const resolvers = {
66
+
resolver: resolver,
67
+
};
68
+
const resolverOutPath = path.resolve(
69
+
process.cwd(),
70
+
"public",
71
+
"resolvers.json"
72
+
);
73
+
74
+
fs.writeFileSync(resolverOutPath, JSON.stringify(resolvers, null, 2));
75
+
76
+
45
77
// /*mass comment*/ console.log(`โ
Generated client-metadata.json for ${appOrigin}`);
46
78
},
47
79
};
48
-
}
80
+
}
+41
-72
package-lock.json
+41
-72
package-lock.json
···
376
376
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.3.tgz",
377
377
"integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==",
378
378
"license": "MIT",
379
+
"peer": true,
379
380
"dependencies": {
380
381
"@ampproject/remapping": "^2.2.0",
381
382
"@babel/code-frame": "^7.27.1",
···
883
884
}
884
885
],
885
886
"license": "MIT",
887
+
"peer": true,
886
888
"engines": {
887
889
"node": ">=18"
888
890
},
···
906
908
}
907
909
],
908
910
"license": "MIT",
911
+
"peer": true,
909
912
"engines": {
910
913
"node": ">=18"
911
914
}
···
1494
1497
"integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==",
1495
1498
"dev": true,
1496
1499
"license": "Apache-2.0",
1497
-
"peer": true,
1498
1500
"dependencies": {
1499
1501
"@eslint/object-schema": "^2.1.6",
1500
1502
"debug": "^4.3.1",
···
1510
1512
"integrity": "sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==",
1511
1513
"dev": true,
1512
1514
"license": "Apache-2.0",
1513
-
"peer": true,
1514
1515
"dependencies": {
1515
1516
"@eslint/core": "^0.16.0"
1516
1517
},
···
1524
1525
"integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==",
1525
1526
"dev": true,
1526
1527
"license": "Apache-2.0",
1527
-
"peer": true,
1528
1528
"dependencies": {
1529
1529
"@types/json-schema": "^7.0.15"
1530
1530
},
···
1538
1538
"integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==",
1539
1539
"dev": true,
1540
1540
"license": "MIT",
1541
-
"peer": true,
1542
1541
"dependencies": {
1543
1542
"ajv": "^6.12.4",
1544
1543
"debug": "^4.3.2",
···
1563
1562
"integrity": "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==",
1564
1563
"dev": true,
1565
1564
"license": "MIT",
1566
-
"peer": true,
1567
1565
"engines": {
1568
1566
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1569
1567
},
···
1577
1575
"integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==",
1578
1576
"dev": true,
1579
1577
"license": "Apache-2.0",
1580
-
"peer": true,
1581
1578
"engines": {
1582
1579
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1583
1580
}
···
1588
1585
"integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==",
1589
1586
"dev": true,
1590
1587
"license": "Apache-2.0",
1591
-
"peer": true,
1592
1588
"dependencies": {
1593
1589
"@eslint/core": "^0.16.0",
1594
1590
"levn": "^0.4.1"
···
1637
1633
"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
1638
1634
"dev": true,
1639
1635
"license": "Apache-2.0",
1640
-
"peer": true,
1641
1636
"engines": {
1642
1637
"node": ">=18.18.0"
1643
1638
}
···
1648
1643
"integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
1649
1644
"dev": true,
1650
1645
"license": "Apache-2.0",
1651
-
"peer": true,
1652
1646
"dependencies": {
1653
1647
"@humanfs/core": "^0.19.1",
1654
1648
"@humanwhocodes/retry": "^0.4.0"
···
1663
1657
"integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
1664
1658
"dev": true,
1665
1659
"license": "Apache-2.0",
1666
-
"peer": true,
1667
1660
"engines": {
1668
1661
"node": ">=12.22"
1669
1662
},
···
1678
1671
"integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
1679
1672
"dev": true,
1680
1673
"license": "Apache-2.0",
1681
-
"peer": true,
1682
1674
"engines": {
1683
1675
"node": ">=18.18"
1684
1676
},
···
3856
3848
"integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==",
3857
3849
"dev": true,
3858
3850
"license": "MIT",
3851
+
"peer": true,
3859
3852
"dependencies": {
3860
3853
"@babel/core": "^7.21.3",
3861
3854
"@svgr/babel-preset": "8.1.0",
···
4330
4323
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.85.6.tgz",
4331
4324
"integrity": "sha512-VUAag4ERjh+qlmg0wNivQIVCZUrYndqYu3/wPCVZd4r0E+1IqotbeyGTc+ICroL/PqbpSaGZg02zSWYfcvxbdA==",
4332
4325
"license": "MIT",
4326
+
"peer": true,
4333
4327
"dependencies": {
4334
4328
"@tanstack/query-core": "5.85.6"
4335
4329
},
···
4363
4357
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.131.28.tgz",
4364
4358
"integrity": "sha512-vWExhrqHJuT9v+6/2DCQ4pVvPaYoLazMNw8WXiLNuzBXh1FuEoIGaW3jw3DEP0OJCmMiWtTi34NzQnakkQZlQg==",
4365
4359
"license": "MIT",
4360
+
"peer": true,
4366
4361
"dependencies": {
4367
4362
"@tanstack/history": "1.131.2",
4368
4363
"@tanstack/react-store": "^0.7.0",
···
4427
4422
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.131.28.tgz",
4428
4423
"integrity": "sha512-f+vdfr3WKSS/BcqgI5s4vZg9xYb7NkvIolkaMELrbz3l+khkw1aTjx8wqCHRY4dqwIAxq+iZBZtMWXA7pztGJg==",
4429
4424
"license": "MIT",
4425
+
"peer": true,
4430
4426
"dependencies": {
4431
4427
"@tanstack/history": "1.131.2",
4432
4428
"@tanstack/store": "^0.7.0",
···
4599
4595
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
4600
4596
"dev": true,
4601
4597
"license": "MIT",
4598
+
"peer": true,
4602
4599
"dependencies": {
4603
4600
"@babel/code-frame": "^7.10.4",
4604
4601
"@babel/runtime": "^7.12.5",
···
4721
4718
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
4722
4719
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
4723
4720
"dev": true,
4724
-
"license": "MIT",
4725
-
"peer": true
4721
+
"license": "MIT"
4726
4722
},
4727
4723
"node_modules/@types/node": {
4728
4724
"version": "24.3.0",
···
4730
4726
"integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==",
4731
4727
"devOptional": true,
4732
4728
"license": "MIT",
4729
+
"peer": true,
4733
4730
"dependencies": {
4734
4731
"undici-types": "~7.10.0"
4735
4732
}
···
4739
4736
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz",
4740
4737
"integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==",
4741
4738
"license": "MIT",
4739
+
"peer": true,
4742
4740
"dependencies": {
4743
4741
"csstype": "^3.0.2"
4744
4742
}
···
4748
4746
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.9.tgz",
4749
4747
"integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==",
4750
4748
"license": "MIT",
4749
+
"peer": true,
4751
4750
"peerDependencies": {
4752
4751
"@types/react": "^19.0.0"
4753
4752
}
···
4765
4764
"integrity": "sha512-rUsLh8PXmBjdiPY+Emjz9NX2yHvhS11v0SR6xNJkm5GM1MO9ea/1GoDKlHHZGrOJclL/cZ2i/vRUYVtjRhrHVQ==",
4766
4765
"dev": true,
4767
4766
"license": "MIT",
4767
+
"peer": true,
4768
4768
"dependencies": {
4769
4769
"@eslint-community/regexpp": "^4.10.0",
4770
4770
"@typescript-eslint/scope-manager": "8.46.1",
···
4805
4805
"integrity": "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==",
4806
4806
"dev": true,
4807
4807
"license": "MIT",
4808
+
"peer": true,
4808
4809
"dependencies": {
4809
4810
"@typescript-eslint/scope-manager": "8.46.1",
4810
4811
"@typescript-eslint/types": "8.46.1",
···
5187
5188
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
5188
5189
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
5189
5190
"license": "MIT",
5191
+
"peer": true,
5190
5192
"bin": {
5191
5193
"acorn": "bin/acorn"
5192
5194
},
···
5200
5202
"integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
5201
5203
"dev": true,
5202
5204
"license": "MIT",
5203
-
"peer": true,
5204
5205
"peerDependencies": {
5205
5206
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
5206
5207
}
···
5221
5222
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
5222
5223
"dev": true,
5223
5224
"license": "MIT",
5224
-
"peer": true,
5225
5225
"dependencies": {
5226
5226
"fast-deep-equal": "^3.1.1",
5227
5227
"fast-json-stable-stringify": "^2.0.0",
···
5627
5627
}
5628
5628
],
5629
5629
"license": "MIT",
5630
+
"peer": true,
5630
5631
"dependencies": {
5631
5632
"caniuse-lite": "^1.0.30001737",
5632
5633
"electron-to-chromium": "^1.5.211",
···
5784
5785
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
5785
5786
"dev": true,
5786
5787
"license": "MIT",
5787
-
"peer": true,
5788
5788
"dependencies": {
5789
5789
"ansi-styles": "^4.1.0",
5790
5790
"supports-color": "^7.1.0"
···
5802
5802
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
5803
5803
"dev": true,
5804
5804
"license": "MIT",
5805
-
"peer": true,
5806
5805
"dependencies": {
5807
5806
"color-convert": "^2.0.1"
5808
5807
},
···
5883
5882
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
5884
5883
"dev": true,
5885
5884
"license": "MIT",
5886
-
"peer": true,
5887
5885
"dependencies": {
5888
5886
"color-name": "~1.1.4"
5889
5887
},
···
5896
5894
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
5897
5895
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
5898
5896
"dev": true,
5899
-
"license": "MIT",
5900
-
"peer": true
5897
+
"license": "MIT"
5901
5898
},
5902
5899
"node_modules/compare-versions": {
5903
5900
"version": "6.1.1",
···
5976
5973
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
5977
5974
"dev": true,
5978
5975
"license": "MIT",
5979
-
"peer": true,
5980
5976
"dependencies": {
5981
5977
"path-key": "^3.1.0",
5982
5978
"shebang-command": "^2.0.0",
···
6004
6000
"version": "3.1.3",
6005
6001
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
6006
6002
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
6007
-
"license": "MIT"
6003
+
"license": "MIT",
6004
+
"peer": true
6008
6005
},
6009
6006
"node_modules/custom-media-element": {
6010
6007
"version": "1.4.5",
···
6147
6144
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
6148
6145
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
6149
6146
"dev": true,
6150
-
"license": "MIT",
6151
-
"peer": true
6147
+
"license": "MIT"
6152
6148
},
6153
6149
"node_modules/define-data-property": {
6154
6150
"version": "1.1.4",
···
6556
6552
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
6557
6553
"dev": true,
6558
6554
"license": "MIT",
6559
-
"peer": true,
6560
6555
"engines": {
6561
6556
"node": ">=10"
6562
6557
},
···
6848
6843
"integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
6849
6844
"dev": true,
6850
6845
"license": "BSD-2-Clause",
6851
-
"peer": true,
6852
6846
"dependencies": {
6853
6847
"esrecurse": "^4.3.0",
6854
6848
"estraverse": "^5.2.0"
···
6879
6873
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
6880
6874
"dev": true,
6881
6875
"license": "ISC",
6882
-
"peer": true,
6883
6876
"dependencies": {
6884
6877
"is-glob": "^4.0.3"
6885
6878
},
···
6893
6886
"integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
6894
6887
"dev": true,
6895
6888
"license": "BSD-2-Clause",
6896
-
"peer": true,
6897
6889
"dependencies": {
6898
6890
"acorn": "^8.15.0",
6899
6891
"acorn-jsx": "^5.3.2",
···
6925
6917
"integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==",
6926
6918
"dev": true,
6927
6919
"license": "BSD-3-Clause",
6928
-
"peer": true,
6929
6920
"dependencies": {
6930
6921
"estraverse": "^5.1.0"
6931
6922
},
···
6939
6930
"integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
6940
6931
"dev": true,
6941
6932
"license": "BSD-2-Clause",
6942
-
"peer": true,
6943
6933
"dependencies": {
6944
6934
"estraverse": "^5.2.0"
6945
6935
},
···
7028
7018
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
7029
7019
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
7030
7020
"dev": true,
7031
-
"license": "MIT",
7032
-
"peer": true
7021
+
"license": "MIT"
7033
7022
},
7034
7023
"node_modules/fast-levenshtein": {
7035
7024
"version": "2.0.6",
7036
7025
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
7037
7026
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
7038
7027
"dev": true,
7039
-
"license": "MIT",
7040
-
"peer": true
7028
+
"license": "MIT"
7041
7029
},
7042
7030
"node_modules/fastq": {
7043
7031
"version": "1.19.1",
···
7055
7043
"integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
7056
7044
"dev": true,
7057
7045
"license": "MIT",
7058
-
"peer": true,
7059
7046
"dependencies": {
7060
7047
"flat-cache": "^4.0.0"
7061
7048
},
···
7081
7068
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
7082
7069
"dev": true,
7083
7070
"license": "MIT",
7084
-
"peer": true,
7085
7071
"dependencies": {
7086
7072
"locate-path": "^6.0.0",
7087
7073
"path-exists": "^4.0.0"
···
7099
7085
"integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
7100
7086
"dev": true,
7101
7087
"license": "MIT",
7102
-
"peer": true,
7103
7088
"dependencies": {
7104
7089
"flatted": "^3.2.9",
7105
7090
"keyv": "^4.5.4"
···
7113
7098
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
7114
7099
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
7115
7100
"dev": true,
7116
-
"license": "ISC",
7117
-
"peer": true
7101
+
"license": "ISC"
7118
7102
},
7119
7103
"node_modules/for-each": {
7120
7104
"version": "0.3.5",
···
7301
7285
"integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
7302
7286
"dev": true,
7303
7287
"license": "MIT",
7304
-
"peer": true,
7305
7288
"engines": {
7306
7289
"node": ">=18"
7307
7290
},
···
7379
7362
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
7380
7363
"dev": true,
7381
7364
"license": "MIT",
7382
-
"peer": true,
7383
7365
"engines": {
7384
7366
"node": ">=8"
7385
7367
}
···
7592
7574
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
7593
7575
"dev": true,
7594
7576
"license": "MIT",
7595
-
"peer": true,
7596
7577
"engines": {
7597
7578
"node": ">= 4"
7598
7579
}
···
7635
7616
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
7636
7617
"dev": true,
7637
7618
"license": "MIT",
7638
-
"peer": true,
7639
7619
"engines": {
7640
7620
"node": ">=0.8.19"
7641
7621
}
···
8141
8121
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
8142
8122
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
8143
8123
"dev": true,
8144
-
"license": "ISC",
8145
-
"peer": true
8124
+
"license": "ISC"
8146
8125
},
8147
8126
"node_modules/iso-datestring-validator": {
8148
8127
"version": "2.2.2",
···
8240
8219
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
8241
8220
"dev": true,
8242
8221
"license": "MIT",
8222
+
"peer": true,
8243
8223
"dependencies": {
8244
8224
"cssstyle": "^4.2.1",
8245
8225
"data-urls": "^5.0.0",
···
8291
8271
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
8292
8272
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
8293
8273
"dev": true,
8294
-
"license": "MIT",
8295
-
"peer": true
8274
+
"license": "MIT"
8296
8275
},
8297
8276
"node_modules/json-parse-even-better-errors": {
8298
8277
"version": "2.3.1",
···
8306
8285
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
8307
8286
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
8308
8287
"dev": true,
8309
-
"license": "MIT",
8310
-
"peer": true
8288
+
"license": "MIT"
8311
8289
},
8312
8290
"node_modules/json-stable-stringify-without-jsonify": {
8313
8291
"version": "1.0.1",
8314
8292
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
8315
8293
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
8316
8294
"dev": true,
8317
-
"license": "MIT",
8318
-
"peer": true
8295
+
"license": "MIT"
8319
8296
},
8320
8297
"node_modules/json5": {
8321
8298
"version": "2.2.3",
···
8351
8328
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
8352
8329
"dev": true,
8353
8330
"license": "MIT",
8354
-
"peer": true,
8355
8331
"dependencies": {
8356
8332
"json-buffer": "3.0.1"
8357
8333
}
···
8369
8345
"integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
8370
8346
"dev": true,
8371
8347
"license": "MIT",
8372
-
"peer": true,
8373
8348
"dependencies": {
8374
8349
"prelude-ls": "^1.2.1",
8375
8350
"type-check": "~0.4.0"
···
8655
8630
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
8656
8631
"dev": true,
8657
8632
"license": "MIT",
8658
-
"peer": true,
8659
8633
"dependencies": {
8660
8634
"p-locate": "^5.0.0"
8661
8635
},
···
8677
8651
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
8678
8652
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
8679
8653
"dev": true,
8680
-
"license": "MIT",
8681
-
"peer": true
8654
+
"license": "MIT"
8682
8655
},
8683
8656
"node_modules/loose-envify": {
8684
8657
"version": "1.4.0",
···
11138
11111
"version": "4.0.3",
11139
11112
"inBundle": true,
11140
11113
"license": "MIT",
11114
+
"peer": true,
11141
11115
"engines": {
11142
11116
"node": ">=12"
11143
11117
},
···
11471
11445
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
11472
11446
"dev": true,
11473
11447
"license": "MIT",
11474
-
"peer": true,
11475
11448
"dependencies": {
11476
11449
"deep-is": "^0.1.3",
11477
11450
"fast-levenshtein": "^2.0.6",
···
11508
11481
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
11509
11482
"dev": true,
11510
11483
"license": "MIT",
11511
-
"peer": true,
11512
11484
"dependencies": {
11513
11485
"yocto-queue": "^0.1.0"
11514
11486
},
···
11525
11497
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
11526
11498
"dev": true,
11527
11499
"license": "MIT",
11528
-
"peer": true,
11529
11500
"dependencies": {
11530
11501
"p-limit": "^3.0.2"
11531
11502
},
···
11600
11571
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
11601
11572
"dev": true,
11602
11573
"license": "MIT",
11603
-
"peer": true,
11604
11574
"engines": {
11605
11575
"node": ">=8"
11606
11576
}
···
11611
11581
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
11612
11582
"dev": true,
11613
11583
"license": "MIT",
11614
-
"peer": true,
11615
11584
"engines": {
11616
11585
"node": ">=8"
11617
11586
}
···
11740
11709
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
11741
11710
"dev": true,
11742
11711
"license": "MIT",
11743
-
"peer": true,
11744
11712
"engines": {
11745
11713
"node": ">= 0.8.0"
11746
11714
}
···
11921
11889
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
11922
11890
"integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==",
11923
11891
"license": "MIT",
11892
+
"peer": true,
11924
11893
"engines": {
11925
11894
"node": ">=0.10.0"
11926
11895
}
···
11930
11899
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz",
11931
11900
"integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==",
11932
11901
"license": "MIT",
11902
+
"peer": true,
11933
11903
"dependencies": {
11934
11904
"scheduler": "^0.26.0"
11935
11905
},
···
12350
12320
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.2.tgz",
12351
12321
"integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==",
12352
12322
"license": "MIT",
12323
+
"peer": true,
12353
12324
"engines": {
12354
12325
"node": ">=10"
12355
12326
}
···
12421
12392
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
12422
12393
"dev": true,
12423
12394
"license": "MIT",
12424
-
"peer": true,
12425
12395
"dependencies": {
12426
12396
"shebang-regex": "^3.0.0"
12427
12397
},
···
12435
12405
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
12436
12406
"dev": true,
12437
12407
"license": "MIT",
12438
-
"peer": true,
12439
12408
"engines": {
12440
12409
"node": ">=8"
12441
12410
}
···
12539
12508
"resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.9.tgz",
12540
12509
"integrity": "sha512-A0ZBPJQldAeGCTW0YRYJmt7RCeh5rbFfPZ2aOttgYnctHE7HgKeHCBB/PVc2P7eOfmNXqMFFFoYYdm3S4dcbkA==",
12541
12510
"license": "MIT",
12511
+
"peer": true,
12542
12512
"dependencies": {
12543
12513
"csstype": "^3.1.0",
12544
12514
"seroval": "~1.3.0",
···
12708
12678
"integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
12709
12679
"dev": true,
12710
12680
"license": "MIT",
12711
-
"peer": true,
12712
12681
"engines": {
12713
12682
"node": ">=8"
12714
12683
},
···
12748
12717
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
12749
12718
"dev": true,
12750
12719
"license": "MIT",
12751
-
"peer": true,
12752
12720
"dependencies": {
12753
12721
"has-flag": "^4.0.0"
12754
12722
},
···
12848
12816
"version": "1.3.3",
12849
12817
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
12850
12818
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
12851
-
"license": "MIT"
12819
+
"license": "MIT",
12820
+
"peer": true
12852
12821
},
12853
12822
"node_modules/tiny-warning": {
12854
12823
"version": "1.0.3",
···
12908
12877
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
12909
12878
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
12910
12879
"license": "MIT",
12880
+
"peer": true,
12911
12881
"engines": {
12912
12882
"node": ">=12"
12913
12883
},
···
13105
13075
"integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
13106
13076
"dev": true,
13107
13077
"license": "MIT",
13108
-
"peer": true,
13109
13078
"dependencies": {
13110
13079
"prelude-ls": "^1.2.1"
13111
13080
},
···
13197
13166
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
13198
13167
"dev": true,
13199
13168
"license": "Apache-2.0",
13169
+
"peer": true,
13200
13170
"bin": {
13201
13171
"tsc": "bin/tsc",
13202
13172
"tsserver": "bin/tsserver"
···
13533
13503
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
13534
13504
"dev": true,
13535
13505
"license": "BSD-2-Clause",
13536
-
"peer": true,
13537
13506
"dependencies": {
13538
13507
"punycode": "^2.1.0"
13539
13508
}
···
13602
13571
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
13603
13572
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
13604
13573
"license": "MIT",
13574
+
"peer": true,
13605
13575
"dependencies": {
13606
13576
"esbuild": "^0.25.0",
13607
13577
"fdir": "^6.4.4",
···
13716
13686
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
13717
13687
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
13718
13688
"license": "MIT",
13689
+
"peer": true,
13719
13690
"engines": {
13720
13691
"node": ">=12"
13721
13692
},
···
13897
13868
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
13898
13869
"dev": true,
13899
13870
"license": "ISC",
13900
-
"peer": true,
13901
13871
"dependencies": {
13902
13872
"isexe": "^2.0.0"
13903
13873
},
···
14029
13999
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
14030
14000
"dev": true,
14031
14001
"license": "MIT",
14032
-
"peer": true,
14033
14002
"engines": {
14034
14003
"node": ">=0.10.0"
14035
14004
}
···
14084
14053
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
14085
14054
"dev": true,
14086
14055
"license": "MIT",
14087
-
"peer": true,
14088
14056
"engines": {
14089
14057
"node": ">=10"
14090
14058
},
···
14103
14071
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
14104
14072
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
14105
14073
"license": "MIT",
14074
+
"peer": true,
14106
14075
"funding": {
14107
14076
"url": "https://github.com/sponsors/colinhacks"
14108
14077
}
+3
src/auto-imports.d.ts
+3
src/auto-imports.d.ts
···
18
18
const IconMaterialSymbolsSettingsOutline: typeof import('~icons/material-symbols/settings-outline.jsx').default
19
19
const IconMaterialSymbolsTag: typeof import('~icons/material-symbols/tag.jsx').default
20
20
const IconMdiAccountCircle: typeof import('~icons/mdi/account-circle.jsx').default
21
+
const IconMdiAccountPlus: typeof import('~icons/mdi/account-plus.jsx').default
22
+
const IconMdiCheck: typeof import('~icons/mdi/check.jsx').default
23
+
const IconMdiMessageReplyTextOutline: typeof import('~icons/mdi/message-reply-text-outline.jsx').default
21
24
const IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline.jsx').default
22
25
}
+117
-164
src/components/Composer.tsx
+117
-164
src/components/Composer.tsx
···
8
8
import { useQueryPost } from "~/utils/useQuery";
9
9
10
10
import { ProfileThing } from "./Login";
11
-
import { Button } from "./radix-m3-rd/Button";
12
11
import { UniversalPostRendererATURILoader } from "./UniversalPostRenderer";
13
12
14
13
const MAX_POST_LENGTH = 300;
15
14
16
15
export function Composer() {
17
16
const [composerState, setComposerState] = useAtom(composerAtom);
18
-
const [closeConfirmState, setCloseConfirmState] = useState<boolean>(false);
19
17
const { agent } = useAuth();
20
18
21
19
const [postText, setPostText] = useState("");
···
114
112
setPosting(false);
115
113
}
116
114
}
115
+
// if (composerState.kind === "closed") {
116
+
// return null;
117
+
// }
117
118
118
119
const getPlaceholder = () => {
119
120
switch (composerState.kind) {
···
131
132
const isPostButtonDisabled =
132
133
posting || !postText.trim() || isParentLoading || charsLeft < 0;
133
134
134
-
function handleAttemptClose() {
135
-
if (postText.trim() && !posting) {
136
-
setCloseConfirmState(true);
137
-
} else {
138
-
setComposerState({ kind: "closed" });
139
-
}
140
-
}
141
-
142
-
function handleConfirmClose() {
143
-
setComposerState({ kind: "closed" });
144
-
setCloseConfirmState(false);
145
-
setPostText("");
146
-
}
147
-
148
135
return (
149
-
<>
150
-
<Dialog.Root
151
-
open={composerState.kind !== "closed"}
152
-
onOpenChange={(open) => {
153
-
if (!open) handleAttemptClose();
154
-
}}
155
-
>
156
-
<Dialog.Portal>
157
-
<Dialog.Overlay className="disablegutter fixed inset-0 z-50 bg-black/40 dark:bg-black/50 data-[state=open]:animate-fadeIn" />
136
+
<Dialog.Root
137
+
open={composerState.kind !== "closed"}
138
+
onOpenChange={(open) => {
139
+
if (!open) setComposerState({ kind: "closed" });
140
+
}}
141
+
>
142
+
<Dialog.Portal>
143
+
<Dialog.Overlay className="fixed disablegutter inset-0 z-50 bg-black/40 dark:bg-black/50 data-[state=open]:animate-fadeIn" />
158
144
159
-
<Dialog.Content className="fixed overflow-y-auto gutter inset-0 z-50 flex items-start justify-center pt-10 sm:pt-20 pb-[50dvh] sm:pb-[50dvh]">
160
-
<div className="bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-700 rounded-2xl shadow-xl w-full max-w-xl relative mx-4">
161
-
<div className="flex flex-row justify-between p-2">
162
-
<Dialog.Close asChild>
163
-
<button
164
-
className="h-8 w-8 flex items-center justify-center rounded-full text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800"
165
-
disabled={posting}
166
-
aria-label="Close"
167
-
onClick={handleAttemptClose}
145
+
<Dialog.Content className="fixed gutter overflow-y-scroll inset-0 z-50 flex items-start justify-center pt-10 sm:pt-20 pb-[50dvh] sm:pb-[50dvh]">
146
+
<div className="bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-700 rounded-2xl shadow-xl w-full max-w-xl relative mx-4">
147
+
<div className="flex flex-row justify-between p-2">
148
+
<Dialog.Close asChild>
149
+
<button
150
+
className="h-8 w-8 flex items-center justify-center rounded-full text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800"
151
+
disabled={posting}
152
+
aria-label="Close"
153
+
>
154
+
<svg
155
+
xmlns="http://www.w3.org/2000/svg"
156
+
width="20"
157
+
height="20"
158
+
viewBox="0 0 24 24"
159
+
fill="none"
160
+
stroke="currentColor"
161
+
strokeWidth="2.5"
162
+
strokeLinecap="round"
163
+
strokeLinejoin="round"
168
164
>
169
-
<svg
170
-
xmlns="http://www.w3.org/2000/svg"
171
-
width="20"
172
-
height="20"
173
-
viewBox="0 0 24 24"
174
-
fill="none"
175
-
stroke="currentColor"
176
-
strokeWidth="2.5"
177
-
strokeLinecap="round"
178
-
strokeLinejoin="round"
179
-
>
180
-
<line x1="18" y1="6" x2="6" y2="18"></line>
181
-
<line x1="6" y1="6" x2="18" y2="18"></line>
182
-
</svg>
183
-
</button>
184
-
</Dialog.Close>
165
+
<line x1="18" y1="6" x2="6" y2="18"></line>
166
+
<line x1="6" y1="6" x2="18" y2="18"></line>
167
+
</svg>
168
+
</button>
169
+
</Dialog.Close>
185
170
186
-
<div className="flex-1" />
187
-
<div className="flex items-center gap-4">
188
-
<span
189
-
className={`text-sm ${charsLeft < 0 ? "text-red-500" : "text-gray-500"}`}
190
-
>
191
-
{charsLeft}
192
-
</span>
193
-
<Button
194
-
onClick={handlePost}
195
-
disabled={isPostButtonDisabled}
196
-
>
197
-
{posting ? "Posting..." : "Post"}
198
-
</Button>
199
-
</div>
171
+
<div className="flex-1" />
172
+
<div className="flex items-center gap-4">
173
+
<span
174
+
className={`text-sm ${charsLeft < 0 ? "text-red-500" : "text-gray-500"}`}
175
+
>
176
+
{charsLeft}
177
+
</span>
178
+
<button
179
+
className="bg-gray-600 hover:bg-gray-700 text-white font-bold py-1 px-4 rounded-full disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
180
+
onClick={handlePost}
181
+
disabled={isPostButtonDisabled}
182
+
>
183
+
{posting ? "Posting..." : "Post"}
184
+
</button>
200
185
</div>
186
+
</div>
201
187
202
-
{postSuccess ? (
203
-
<div className="flex flex-col items-center justify-center py-16">
204
-
<span className="text-gray-500 text-6xl mb-4">โ</span>
205
-
<span className="text-xl font-bold text-black dark:text-white">
206
-
Posted!
207
-
</span>
208
-
</div>
209
-
) : (
210
-
<div className="px-4">
211
-
{composerState.kind === "reply" && (
212
-
<div className="mb-1 -mx-4">
213
-
{isParentLoading ? (
214
-
<div className="text-sm text-gray-500 animate-pulse">
215
-
Loading parent post...
216
-
</div>
217
-
) : parentUri ? (
218
-
<UniversalPostRendererATURILoader
219
-
atUri={parentUri}
220
-
bottomReplyLine
221
-
bottomBorder={false}
222
-
/>
223
-
) : (
224
-
<div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3">
225
-
Could not load parent post.
226
-
</div>
227
-
)}
228
-
</div>
229
-
)}
230
-
231
-
<div className="flex w-full gap-1 flex-col">
232
-
<ProfileThing agent={agent} large />
233
-
<div className="flex pl-[50px]">
234
-
<AutoGrowTextarea
235
-
className="w-full text-lg bg-transparent focus:outline-none resize-none placeholder:text-gray-500 text-black dark:text-white pb-2"
236
-
rows={5}
237
-
placeholder={getPlaceholder()}
238
-
value={postText}
239
-
onChange={(e) => setPostText(e.target.value)}
240
-
disabled={posting}
241
-
autoFocus
188
+
{postSuccess ? (
189
+
<div className="flex flex-col items-center justify-center py-16">
190
+
<span className="text-gray-500 text-6xl mb-4">โ</span>
191
+
<span className="text-xl font-bold text-black dark:text-white">
192
+
Posted!
193
+
</span>
194
+
</div>
195
+
) : (
196
+
<div className="px-4">
197
+
{composerState.kind === "reply" && (
198
+
<div className="mb-1 -mx-4">
199
+
{isParentLoading ? (
200
+
<div className="text-sm text-gray-500 animate-pulse">
201
+
Loading parent post...
202
+
</div>
203
+
) : parentUri ? (
204
+
<UniversalPostRendererATURILoader
205
+
atUri={parentUri}
206
+
bottomReplyLine
207
+
bottomBorder={false}
242
208
/>
243
-
</div>
209
+
) : (
210
+
<div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3">
211
+
Could not load parent post.
212
+
</div>
213
+
)}
244
214
</div>
215
+
)}
245
216
246
-
{composerState.kind === "quote" && (
247
-
<div className="mb-4 ml-[50px] rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
248
-
{isParentLoading ? (
249
-
<div className="text-sm text-gray-500 animate-pulse">
250
-
Loading parent post...
251
-
</div>
252
-
) : parentUri ? (
253
-
<UniversalPostRendererATURILoader
254
-
atUri={parentUri}
255
-
isQuote
256
-
/>
257
-
) : (
258
-
<div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3">
259
-
Could not load parent post.
260
-
</div>
261
-
)}
262
-
</div>
263
-
)}
264
-
265
-
{postError && (
266
-
<div className="text-red-500 text-sm my-2 text-center">
267
-
{postError}
268
-
</div>
269
-
)}
217
+
<div className="flex w-full gap-1 flex-col">
218
+
<ProfileThing agent={agent} large />
219
+
<div className="flex pl-[50px]">
220
+
<AutoGrowTextarea
221
+
className="w-full text-lg bg-transparent focus:outline-none resize-none placeholder:text-gray-500 text-black dark:text-white pb-2"
222
+
rows={5}
223
+
placeholder={getPlaceholder()}
224
+
value={postText}
225
+
onChange={(e) => setPostText(e.target.value)}
226
+
disabled={posting}
227
+
autoFocus
228
+
/>
229
+
</div>
270
230
</div>
271
-
)}
272
-
</div>
273
-
</Dialog.Content>
274
-
</Dialog.Portal>
275
-
</Dialog.Root>
276
231
277
-
{/* Close confirmation dialog */}
278
-
<Dialog.Root open={closeConfirmState} onOpenChange={setCloseConfirmState}>
279
-
<Dialog.Portal>
280
-
281
-
<Dialog.Overlay className="disablegutter fixed inset-0 z-50 bg-black/40 dark:bg-black/50 data-[state=open]:animate-fadeIn" />
232
+
{composerState.kind === "quote" && (
233
+
<div className="mb-4 ml-[50px] rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
234
+
{isParentLoading ? (
235
+
<div className="text-sm text-gray-500 animate-pulse">
236
+
Loading parent post...
237
+
</div>
238
+
) : parentUri ? (
239
+
<UniversalPostRendererATURILoader
240
+
atUri={parentUri}
241
+
isQuote
242
+
/>
243
+
) : (
244
+
<div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3">
245
+
Could not load parent post.
246
+
</div>
247
+
)}
248
+
</div>
249
+
)}
282
250
283
-
<Dialog.Content className="fixed gutter inset-0 z-50 flex items-start justify-center pt-30 sm:pt-40">
284
-
<div className="bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-700 rounded-2xl shadow-xl w-full max-w-md relative mx-4 py-6">
285
-
<div className="text-xl mb-4 text-center">
286
-
Discard your post?
287
-
</div>
288
-
<div className="text-md mb-4 text-center">
289
-
You will lose your draft
251
+
{postError && (
252
+
<div className="text-red-500 text-sm my-2 text-center">
253
+
{postError}
254
+
</div>
255
+
)}
290
256
</div>
291
-
<div className="flex justify-end gap-2 px-6">
292
-
<Button
293
-
onClick={handleConfirmClose}
294
-
>
295
-
Discard
296
-
</Button>
297
-
<Button
298
-
variant={"outlined"}
299
-
onClick={() => setCloseConfirmState(false)}
300
-
>
301
-
Cancel
302
-
</Button>
303
-
</div>
304
-
</div>
305
-
</Dialog.Content>
306
-
</Dialog.Portal>
307
-
</Dialog.Root>
308
-
</>
257
+
)}
258
+
</div>
259
+
</Dialog.Content>
260
+
</Dialog.Portal>
261
+
</Dialog.Root>
309
262
);
310
263
}
311
264
+4
-2
src/components/Header.tsx
+4
-2
src/components/Header.tsx
···
5
5
6
6
export function Header({
7
7
backButtonCallback,
8
-
title
8
+
title,
9
+
bottomBorderDisabled,
9
10
}: {
10
11
backButtonCallback?: () => void;
11
12
title?: string;
13
+
bottomBorderDisabled?: boolean;
12
14
}) {
13
15
const router = useRouter();
14
16
const [isAtTop] = useAtom(isAtTopAtom);
15
17
//const what = router.history.
16
18
return (
17
-
<div className={`flex items-center gap-3 px-3 py-3 h-[52px] sticky top-0 bg-[var(--header-bg-light)] dark:bg-[var(--header-bg-dark)] z-10 border-0 sm:border-b ${!isAtTop && "shadow-sm"} sm:shadow-none sm:dark:bg-gray-950 sm:bg-white border-gray-200 dark:border-gray-700`}>
19
+
<div className={`flex items-center gap-3 px-3 py-3 h-[52px] sticky top-0 bg-[var(--header-bg-light)] dark:bg-[var(--header-bg-dark)] z-10 border-0 ${!bottomBorderDisabled && "sm:border-b"} ${!isAtTop && !bottomBorderDisabled && "shadow-sm"} sm:shadow-none sm:dark:bg-gray-950 sm:bg-white border-gray-200 dark:border-gray-700`}>
18
20
{backButtonCallback ? (<Link
19
21
to=".."
20
22
//className="px-3 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-900 font-bold text-lg"
+6
-1
src/components/InfiniteCustomFeed.tsx
+6
-1
src/components/InfiniteCustomFeed.tsx
···
14
14
feedUri: string;
15
15
pdsUrl?: string;
16
16
feedServiceDid?: string;
17
+
authedOverride?: boolean;
18
+
unauthedfeedurl?: string;
17
19
}
18
20
19
21
export function InfiniteCustomFeed({
20
22
feedUri,
21
23
pdsUrl,
22
24
feedServiceDid,
25
+
authedOverride,
26
+
unauthedfeedurl,
23
27
}: InfiniteCustomFeedProps) {
24
28
const { agent } = useAuth();
25
-
const authed = !!agent?.did;
29
+
const authed = authedOverride || !!agent?.did;
26
30
27
31
// const identityresultmaybe = useQueryIdentity(agent?.did);
28
32
// const identity = identityresultmaybe?.data;
···
45
49
isAuthed: authed ?? false,
46
50
pdsUrl: pdsUrl,
47
51
feedServiceDid: feedServiceDid,
52
+
unauthedfeedurl: unauthedfeedurl,
48
53
});
49
54
const queryClient = useQueryClient();
50
55
+3
-5
src/components/Login.tsx
+3
-5
src/components/Login.tsx
···
7
7
import { imgCDNAtom } from "~/utils/atoms";
8
8
import { useQueryIdentity, useQueryProfile } from "~/utils/useQuery";
9
9
10
-
import { Button } from "./radix-m3-rd/Button";
11
-
12
10
// --- 1. The Main Component (Orchestrator with `compact` prop) ---
13
11
export default function Login({
14
12
compact = false,
···
51
49
You are logged in!
52
50
</p>
53
51
<ProfileThing agent={agent} large />
54
-
<Button
52
+
<button
55
53
onClick={logout}
56
-
className="mt-4"
54
+
className="bg-gray-600 mt-4 hover:bg-gray-700 text-white rounded-full px-6 py-2 font-semibold text-base transition-colors"
57
55
>
58
56
Log out
59
-
</Button>
57
+
</button>
60
58
</div>
61
59
</div>
62
60
);
+124
src/components/ReusableTabRoute.tsx
+124
src/components/ReusableTabRoute.tsx
···
1
+
import * as TabsPrimitive from "@radix-ui/react-tabs";
2
+
import { useAtom } from "jotai";
3
+
import { useEffect, useLayoutEffect } from "react";
4
+
5
+
import { isAtTopAtom, reusableTabRouteScrollAtom } from "~/utils/atoms";
6
+
7
+
/**
8
+
* Please wrap your Route in a div, do not return a top-level fragment,
9
+
* it will break navigation scroll restoration
10
+
*/
11
+
export function ReusableTabRoute({
12
+
route,
13
+
tabs,
14
+
}: {
15
+
route: string;
16
+
tabs: Record<string, React.ReactNode>;
17
+
}) {
18
+
const [reusableTabState, setReusableTabState] = useAtom(
19
+
reusableTabRouteScrollAtom
20
+
);
21
+
const [isAtTop] = useAtom(isAtTopAtom);
22
+
23
+
const routeState = reusableTabState?.[route] ?? {
24
+
activeTab: Object.keys(tabs)[0],
25
+
scrollPositions: {},
26
+
};
27
+
const activeTab = routeState.activeTab;
28
+
29
+
const handleValueChange = (newTab: string) => {
30
+
setReusableTabState((prev) => {
31
+
const current = prev?.[route] ?? routeState;
32
+
return {
33
+
...prev,
34
+
[route]: {
35
+
...current,
36
+
scrollPositions: {
37
+
...current.scrollPositions,
38
+
[current.activeTab]: window.scrollY,
39
+
},
40
+
activeTab: newTab,
41
+
},
42
+
};
43
+
});
44
+
};
45
+
46
+
// // todo, warning experimental, usually this doesnt work,
47
+
// // like at all, and i usually do this for each tab
48
+
// useLayoutEffect(() => {
49
+
// const savedScroll = routeState.scrollPositions[activeTab] ?? 0;
50
+
// window.scrollTo({ top: savedScroll });
51
+
// // eslint-disable-next-line react-hooks/exhaustive-deps
52
+
// }, [activeTab, route]);
53
+
54
+
useLayoutEffect(() => {
55
+
return () => {
56
+
setReusableTabState((prev) => {
57
+
const current = prev?.[route] ?? routeState;
58
+
return {
59
+
...prev,
60
+
[route]: {
61
+
...current,
62
+
scrollPositions: {
63
+
...current.scrollPositions,
64
+
[current.activeTab]: window.scrollY,
65
+
},
66
+
},
67
+
};
68
+
});
69
+
};
70
+
// eslint-disable-next-line react-hooks/exhaustive-deps
71
+
}, []);
72
+
73
+
return (
74
+
<TabsPrimitive.Root
75
+
value={activeTab}
76
+
onValueChange={handleValueChange}
77
+
className={`w-full`}
78
+
>
79
+
<TabsPrimitive.List
80
+
className={`flex sticky top-[52px] bg-[var(--header-bg-light)] dark:bg-[var(--header-bg-dark)] z-[9] border-0 sm:border-b ${!isAtTop && "shadow-sm"} sm:shadow-none sm:dark:bg-gray-950 sm:bg-white border-gray-200 dark:border-gray-700`}
81
+
>
82
+
{Object.entries(tabs).map(([key]) => (
83
+
<TabsPrimitive.Trigger key={key} value={key} className="m3tab">
84
+
{key}
85
+
</TabsPrimitive.Trigger>
86
+
))}
87
+
</TabsPrimitive.List>
88
+
89
+
{Object.entries(tabs).map(([key, node]) => (
90
+
<TabsPrimitive.Content key={key} value={key} className="flex-1 min-h-[80dvh]">
91
+
{activeTab === key && node}
92
+
</TabsPrimitive.Content>
93
+
))}
94
+
</TabsPrimitive.Root>
95
+
);
96
+
}
97
+
98
+
export function useReusableTabScrollRestore(route: string) {
99
+
const [reusableTabState] = useAtom(
100
+
reusableTabRouteScrollAtom
101
+
);
102
+
103
+
const routeState = reusableTabState?.[route];
104
+
const activeTab = routeState?.activeTab;
105
+
106
+
useEffect(() => {
107
+
const savedScroll = activeTab ? routeState?.scrollPositions[activeTab] ?? 0 : 0;
108
+
//window.scrollTo(0, savedScroll);
109
+
window.scrollTo({ top: savedScroll });
110
+
// eslint-disable-next-line react-hooks/exhaustive-deps
111
+
}, []);
112
+
}
113
+
114
+
115
+
/*
116
+
117
+
const [notifState] = useAtom(notificationsScrollAtom);
118
+
const activeTab = notifState.activeTab;
119
+
useEffect(() => {
120
+
const savedY = notifState.scrollPositions[activeTab] ?? 0;
121
+
window.scrollTo(0, savedY);
122
+
}, [activeTab, notifState.scrollPositions]);
123
+
124
+
*/
+183
-54
src/components/UniversalPostRenderer.tsx
+183
-54
src/components/UniversalPostRenderer.tsx
···
1
+
import * as ATPAPI from "@atproto/api";
1
2
import { useNavigate } from "@tanstack/react-router";
2
3
import DOMPurify from "dompurify";
3
4
import { useAtom } from "jotai";
···
9
10
import {
10
11
composerAtom,
11
12
constellationURLAtom,
13
+
enableBridgyTextAtom,
14
+
enableWafrnTextAtom,
12
15
imgCDNAtom,
13
-
likedPostsAtom,
14
16
} from "~/utils/atoms";
15
17
import { useHydratedEmbed } from "~/utils/useHydrated";
16
18
import {
···
38
40
feedviewpost?: boolean;
39
41
repostedby?: string;
40
42
style?: React.CSSProperties;
41
-
ref?: React.Ref<HTMLDivElement>;
43
+
ref?: React.RefObject<HTMLDivElement>;
42
44
dataIndexPropPass?: number;
43
45
nopics?: boolean;
46
+
concise?: boolean;
44
47
lightboxCallback?: (d: LightboxProps) => void;
45
48
maxReplies?: number;
46
49
isQuote?: boolean;
50
+
filterNoReplies?: boolean;
51
+
filterMustHaveMedia?: boolean;
52
+
filterMustBeReply?: boolean;
47
53
}
48
54
49
55
// export async function cachedGetRecord({
···
152
158
ref,
153
159
dataIndexPropPass,
154
160
nopics,
161
+
concise,
155
162
lightboxCallback,
156
163
maxReplies,
157
164
isQuote,
165
+
filterNoReplies,
166
+
filterMustHaveMedia,
167
+
filterMustBeReply,
158
168
}: UniversalPostRendererATURILoaderProps) {
159
169
// todo remove this once tree rendering is implemented, use a prop like isTree
160
170
const TEMPLINEAR = true;
···
518
528
? true
519
529
: maxReplies && !oldestOpsReplyElseNewestNonOpsReply
520
530
? false
521
-
: (maxReplies === 0 && (!replies || (!!replies && replies === 0))) ? false : bottomReplyLine
531
+
: maxReplies === 0 && (!replies || (!!replies && replies === 0))
532
+
? false
533
+
: bottomReplyLine
522
534
}
523
535
topReplyLine={topReplyLine}
524
536
//bottomBorder={maxReplies&&oldestOpsReplyElseNewestNonOpsReply ? false : bottomBorder}
···
536
548
ref={ref}
537
549
dataIndexPropPass={dataIndexPropPass}
538
550
nopics={nopics}
551
+
concise={concise}
539
552
lightboxCallback={lightboxCallback}
540
553
maxReplies={maxReplies}
541
554
isQuote={isQuote}
555
+
filterNoReplies={filterNoReplies}
556
+
filterMustHaveMedia={filterMustHaveMedia}
557
+
filterMustBeReply={filterMustBeReply}
542
558
/>
543
559
<>
544
-
{(maxReplies && maxReplies === 0 && replies && replies > 0) ? (
560
+
{maxReplies && maxReplies === 0 && replies && replies > 0 ? (
545
561
<>
546
-
{/* <div>hello</div> */}
547
-
<MoreReplies atUri={atUri} />
562
+
{/* <div>hello</div> */}
563
+
<MoreReplies atUri={atUri} />
548
564
</>
549
-
) : (<></>)}
565
+
) : (
566
+
<></>
567
+
)}
550
568
</>
551
569
{!isQuote && oldestOpsReplyElseNewestNonOpsReply && (
552
570
<>
···
567
585
ref={ref}
568
586
dataIndexPropPass={dataIndexPropPass}
569
587
nopics={nopics}
588
+
concise={concise}
570
589
lightboxCallback={lightboxCallback}
571
590
maxReplies={
572
591
maxReplies && maxReplies > 0 ? maxReplies - 1 : undefined
···
636
655
ref,
637
656
dataIndexPropPass,
638
657
nopics,
658
+
concise,
639
659
lightboxCallback,
640
660
maxReplies,
641
661
isQuote,
662
+
filterNoReplies,
663
+
filterMustHaveMedia,
664
+
filterMustBeReply,
642
665
}: {
643
666
postRecord: any;
644
667
profileRecord: any;
···
654
677
feedviewpost?: boolean;
655
678
repostedby?: string;
656
679
style?: React.CSSProperties;
657
-
ref?: React.Ref<HTMLDivElement>;
680
+
ref?: React.RefObject<HTMLDivElement>;
658
681
dataIndexPropPass?: number;
659
682
nopics?: boolean;
683
+
concise?: boolean;
660
684
lightboxCallback?: (d: LightboxProps) => void;
661
685
maxReplies?: number;
662
686
isQuote?: boolean;
687
+
filterNoReplies?: boolean;
688
+
filterMustHaveMedia?: boolean;
689
+
filterMustBeReply?: boolean;
663
690
}) {
664
691
// /*mass comment*/ console.log(`received aturi: ${aturi} of post content: ${postRecord}`);
665
692
const navigate = useNavigate();
···
729
756
730
757
// run();
731
758
// }, [postRecord, resolved?.did]);
759
+
760
+
const hasEmbed = (postRecord?.value as ATPAPI.AppBskyFeedPost.Record)?.embed;
761
+
const hasImages = hasEmbed?.$type === "app.bsky.embed.images";
762
+
const hasVideo = hasEmbed?.$type === "app.bsky.embed.video";
763
+
const isquotewithmedia = hasEmbed?.$type === "app.bsky.embed.recordWithMedia";
764
+
const isQuotewithImages =
765
+
isquotewithmedia &&
766
+
(hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type ===
767
+
"app.bsky.embed.images";
768
+
const isQuotewithVideo =
769
+
isquotewithmedia &&
770
+
(hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type ===
771
+
"app.bsky.embed.video";
772
+
773
+
const hasMedia =
774
+
hasEmbed &&
775
+
(hasImages || hasVideo || isQuotewithImages || isQuotewithVideo);
732
776
733
777
const {
734
778
data: hydratedEmbed,
···
824
868
// }, [fakepost, get, set]);
825
869
const thereply = (fakepost?.record as AppBskyFeedPost.Record)?.reply?.parent
826
870
?.uri;
827
-
const feedviewpostreplydid = thereply ? new AtUri(thereply).host : undefined;
871
+
const feedviewpostreplydid =
872
+
thereply && !filterNoReplies ? new AtUri(thereply).host : undefined;
828
873
const replyhookvalue = useQueryIdentity(
829
874
feedviewpost ? feedviewpostreplydid : undefined
830
875
);
···
835
880
repostedby ? aturirepostbydid : undefined
836
881
);
837
882
const feedviewpostrepostedbyhandle = repostedbyhookvalue?.data?.handle;
883
+
884
+
if (filterNoReplies && thereply) return null;
885
+
886
+
if (filterMustHaveMedia && !hasMedia) return null;
887
+
888
+
if (filterMustBeReply && !thereply) return null;
889
+
838
890
return (
839
891
<>
840
892
{/* <p>
841
893
{postRecord?.value?.embed.$type + " " + JSON.stringify(hydratedEmbed)}
842
894
</p> */}
895
+
{/* <span>filtermustbereply is {filterMustBeReply ? "true" : "false"}</span>
896
+
<span>thereply is {thereply ? "true" : "false"}</span> */}
843
897
<UniversalPostRenderer
844
898
expanded={detailed}
845
899
onPostClick={() =>
···
874
928
ref={ref}
875
929
dataIndexPropPass={dataIndexPropPass}
876
930
nopics={nopics}
931
+
concise={concise}
877
932
lightboxCallback={lightboxCallback}
878
933
maxReplies={maxReplies}
879
934
isQuote={isQuote}
···
1197
1252
1198
1253
import defaultpfp from "~/../public/favicon.png";
1199
1254
import { useAuth } from "~/providers/UnifiedAuthProvider";
1200
-
import { FollowButton, Mutual } from "~/routes/profile.$did";
1255
+
import {
1256
+
FeedItemRenderAturiLoader,
1257
+
FollowButton,
1258
+
Mutual,
1259
+
} from "~/routes/profile.$did";
1201
1260
import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i";
1261
+
import { useFastLike } from "~/utils/likeMutationQueue";
1202
1262
// import type { OutputSchema } from "@atproto/api/dist/client/types/app/bsky/feed/getFeed";
1203
1263
// import type {
1204
1264
// ViewRecord,
···
1327
1387
ref,
1328
1388
dataIndexPropPass,
1329
1389
nopics,
1390
+
concise,
1330
1391
lightboxCallback,
1331
1392
maxReplies,
1332
1393
}: {
···
1350
1411
depth?: number;
1351
1412
repostedby?: string;
1352
1413
style?: React.CSSProperties;
1353
-
ref?: React.Ref<HTMLDivElement>;
1414
+
ref?: React.RefObject<HTMLDivElement>;
1354
1415
dataIndexPropPass?: number;
1355
1416
nopics?: boolean;
1417
+
concise?: boolean;
1356
1418
lightboxCallback?: (d: LightboxProps) => void;
1357
1419
maxReplies?: number;
1358
1420
}) {
1359
1421
const parsed = new AtUri(post.uri);
1360
1422
const navigate = useNavigate();
1361
-
const [likedPosts, setLikedPosts] = useAtom(likedPostsAtom);
1362
1423
const [hasRetweeted, setHasRetweeted] = useState<boolean>(
1363
1424
post.viewer?.repost ? true : false
1364
1425
);
1365
-
const [hasLiked, setHasLiked] = useState<boolean>(
1366
-
post.uri in likedPosts || post.viewer?.like ? true : false
1367
-
);
1368
1426
const [, setComposerPost] = useAtom(composerAtom);
1369
1427
const { agent } = useAuth();
1370
-
const [likeUri, setLikeUri] = useState<string | undefined>(post.viewer?.like);
1371
1428
const [retweetUri, setRetweetUri] = useState<string | undefined>(
1372
1429
post.viewer?.repost
1373
1430
);
1374
-
1375
-
const likeOrUnlikePost = async () => {
1376
-
const newLikedPosts = { ...likedPosts };
1377
-
if (!agent) {
1378
-
console.error("Agent is null or undefined");
1379
-
return;
1380
-
}
1381
-
if (hasLiked) {
1382
-
if (post.uri in likedPosts) {
1383
-
const likeUri = likedPosts[post.uri];
1384
-
setLikeUri(likeUri);
1385
-
}
1386
-
if (likeUri) {
1387
-
await agent.deleteLike(likeUri);
1388
-
setHasLiked(false);
1389
-
delete newLikedPosts[post.uri];
1390
-
}
1391
-
} else {
1392
-
const { uri } = await agent.like(post.uri, post.cid);
1393
-
setLikeUri(uri);
1394
-
setHasLiked(true);
1395
-
newLikedPosts[post.uri] = uri;
1396
-
}
1397
-
setLikedPosts(newLikedPosts);
1398
-
};
1431
+
const { liked, toggle, backfill } = useFastLike(post.uri, post.cid);
1432
+
// const bovref = useBackfillOnView(post.uri, post.cid);
1433
+
// React.useLayoutEffect(()=>{
1434
+
// if (expanded && !isQuote) {
1435
+
// backfill();
1436
+
// }
1437
+
// },[backfill, expanded, isQuote])
1399
1438
1400
1439
const repostOrUnrepostPost = async () => {
1401
1440
if (!agent) {
···
1426
1465
: undefined;
1427
1466
1428
1467
const emergencySalt = randomString();
1429
-
const fedi = (post.record as { bridgyOriginalText?: string })
1468
+
1469
+
const [showBridgyText] = useAtom(enableBridgyTextAtom);
1470
+
const [showWafrnText] = useAtom(enableWafrnTextAtom);
1471
+
1472
+
const unfedibridgy = (post.record as { bridgyOriginalText?: string })
1430
1473
.bridgyOriginalText;
1474
+
const unfediwafrnPartial = (post.record as { fullText?: string }).fullText;
1475
+
const unfediwafrnTags = (post.record as { fullTags?: string }).fullTags;
1476
+
const unfediwafrnUnHost = (post.record as { fediverseId?: string })
1477
+
.fediverseId;
1478
+
1479
+
const undfediwafrnHost = unfediwafrnUnHost
1480
+
? new URL(unfediwafrnUnHost).hostname
1481
+
: undefined;
1482
+
1483
+
const tags = unfediwafrnTags
1484
+
? unfediwafrnTags
1485
+
.split("\n")
1486
+
.map((t) => t.trim())
1487
+
.filter(Boolean)
1488
+
: undefined;
1489
+
1490
+
const links = tags
1491
+
? tags
1492
+
.map((tag) => {
1493
+
const encoded = encodeURIComponent(tag);
1494
+
return `<a href="https://${undfediwafrnHost}/search/${encoded}" target="_blank">#${tag.replaceAll(' ','-')}</a>`;
1495
+
})
1496
+
.join("<br>")
1497
+
: "";
1498
+
1499
+
const unfediwafrn = unfediwafrnPartial
1500
+
? unfediwafrnPartial + (links ? `<br>${links}` : "")
1501
+
: undefined;
1502
+
1503
+
const fedi =
1504
+
(showBridgyText ? unfedibridgy : undefined) ??
1505
+
(showWafrnText ? unfediwafrn : undefined);
1431
1506
1432
1507
/* fuck you */
1433
1508
const isMainItem = false;
1434
1509
const setMainItem = (any: any) => {};
1435
1510
// eslint-disable-next-line react-hooks/refs
1436
-
console.log("Received ref in UniversalPostRenderer:", ref);
1511
+
//console.log("Received ref in UniversalPostRenderer:", usedref);
1437
1512
return (
1438
1513
<div ref={ref} style={style} data-index={dataIndexPropPass}>
1439
1514
<div
···
1557
1632
className="rounded-full w-[58px] h-[58px] object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600"
1558
1633
/>
1559
1634
<div className=" flex-1 flex flex-row align-middle justify-end">
1560
-
<div className=" flex flex-col justify-start">
1561
1635
<FollowButton targetdidorhandle={post.author.did} />
1562
-
</div>
1563
1636
</div>
1564
1637
</div>
1565
1638
<div className="flex flex-col gap-3">
···
1568
1641
{post.author.displayName || post.author.handle}{" "}
1569
1642
</div>
1570
1643
<div className="text-gray-500 dark:text-gray-400 text-md flex flex-row gap-1">
1571
-
<Mutual targetdidorhandle={post.author.did} />@{post.author.handle}{" "}
1644
+
<Mutual targetdidorhandle={post.author.did} />@
1645
+
{post.author.handle}{" "}
1572
1646
</div>
1573
1647
</div>
1574
1648
{uprrrsauthor?.description && (
···
1761
1835
<div
1762
1836
style={{
1763
1837
fontSize: 16,
1764
-
marginBottom: !post.embed /*|| depth > 0*/ ? 0 : 8,
1838
+
marginBottom: !post.embed || concise ? 0 : 8,
1765
1839
whiteSpace: "pre-wrap",
1766
1840
textAlign: "left",
1767
1841
overflowWrap: "anywhere",
1768
1842
wordBreak: "break-word",
1769
-
//color: theme.text,
1843
+
...(concise && {
1844
+
display: "-webkit-box",
1845
+
WebkitBoxOrient: "vertical",
1846
+
WebkitLineClamp: 2,
1847
+
overflow: "hidden",
1848
+
}),
1770
1849
}}
1771
1850
className="text-gray-900 dark:text-gray-100"
1772
1851
>
···
1789
1868
</>
1790
1869
)}
1791
1870
</div>
1792
-
{post.embed && depth < 1 ? (
1871
+
{post.embed && depth < 1 && !concise ? (
1793
1872
<PostEmbeds
1794
1873
embed={post.embed}
1795
1874
//moderation={moderation}
···
1811
1890
</div>
1812
1891
</>
1813
1892
)}
1814
-
<div style={{ paddingTop: post.embed && depth < 1 ? 4 : 0 }}>
1893
+
<div
1894
+
style={{
1895
+
paddingTop: post.embed && !concise && depth < 1 ? 4 : 0,
1896
+
}}
1897
+
>
1815
1898
<>
1816
1899
{expanded && (
1817
1900
<div
···
1907
1990
</DropdownMenu.Root>
1908
1991
<HitSlopButton
1909
1992
onClick={() => {
1910
-
likeOrUnlikePost();
1993
+
toggle();
1911
1994
}}
1912
1995
style={{
1913
1996
...btnstyle,
1914
-
...(hasLiked ? { color: "#EC4899" } : {}),
1997
+
...(liked ? { color: "#EC4899" } : {}),
1915
1998
}}
1916
1999
>
1917
-
{hasLiked ? <MdiCardsHeart /> : <MdiCardsHeartOutline />}
1918
-
{(post.likeCount || 0) + (hasLiked ? 1 : 0)}
2000
+
{liked ? <MdiCardsHeart /> : <MdiCardsHeartOutline />}
2001
+
{(post.likeCount || 0) + (liked ? 1 : 0)}
1919
2002
</HitSlopButton>
1920
2003
<div style={{ display: "flex", gap: 8 }}>
1921
2004
<HitSlopButton
···
2167
2250
}
2168
2251
2169
2252
if (AppBskyEmbedRecord.isView(embed)) {
2253
+
// hey im really lazy and im gonna do it the bad way
2254
+
const reallybaduri = (embed?.record as any)?.uri as string | undefined;
2255
+
const reallybadaturi = reallybaduri ? new AtUri(reallybaduri) : undefined;
2256
+
2170
2257
// custom feed embed (i.e. generator view)
2171
2258
if (AppBskyFeedDefs.isGeneratorView(embed.record)) {
2172
2259
// stopgap sorry
···
2176
2263
// <MaybeFeedCard view={embed.record} />
2177
2264
// </div>
2178
2265
// )
2266
+
} else if (
2267
+
!!reallybaduri &&
2268
+
!!reallybadaturi &&
2269
+
reallybadaturi.collection === "app.bsky.feed.generator"
2270
+
) {
2271
+
return (
2272
+
<div className="rounded-xl border">
2273
+
<FeedItemRenderAturiLoader aturi={reallybaduri} disableBottomBorder />
2274
+
</div>
2275
+
);
2179
2276
}
2180
2277
2181
2278
// list embed
···
2187
2284
// <MaybeListCard view={embed.record} />
2188
2285
// </div>
2189
2286
// )
2287
+
} else if (
2288
+
!!reallybaduri &&
2289
+
!!reallybadaturi &&
2290
+
reallybadaturi.collection === "app.bsky.graph.list"
2291
+
) {
2292
+
return (
2293
+
<div className="rounded-xl border">
2294
+
<FeedItemRenderAturiLoader
2295
+
aturi={reallybaduri}
2296
+
disableBottomBorder
2297
+
listmode
2298
+
disablePropagation
2299
+
/>
2300
+
</div>
2301
+
);
2190
2302
}
2191
2303
2192
2304
// starter pack embed
···
2198
2310
// <StarterPackCard starterPack={embed.record} />
2199
2311
// </div>
2200
2312
// )
2313
+
} else if (
2314
+
!!reallybaduri &&
2315
+
!!reallybadaturi &&
2316
+
reallybadaturi.collection === "app.bsky.graph.starterpack"
2317
+
) {
2318
+
return (
2319
+
<div className="rounded-xl border">
2320
+
<FeedItemRenderAturiLoader
2321
+
aturi={reallybaduri}
2322
+
disableBottomBorder
2323
+
listmode
2324
+
disablePropagation
2325
+
/>
2326
+
</div>
2327
+
);
2201
2328
}
2202
2329
2203
2330
// quote post
···
2257
2384
</div>
2258
2385
);
2259
2386
} else {
2387
+
console.log("what the hell is a ", embed);
2260
2388
return <>sorry</>;
2261
2389
}
2262
2390
//return <QuotePostRenderer record={embed.record} moderation={moderation} />;
···
2566
2694
// =
2567
2695
if (AppBskyEmbedVideo.isView(embed)) {
2568
2696
// hls playlist
2697
+
if (nopics) return;
2569
2698
const playlist = embed.playlist;
2570
2699
return (
2571
2700
<SmartHLSPlayer
+31
src/components/placeholders/TextPlaceholder.tsx
+31
src/components/placeholders/TextPlaceholder.tsx
···
1
+
import type { ReactNode } from "react";
2
+
3
+
export function TextPlaceholder({
4
+
className,
5
+
pulse = false,
6
+
}: {
7
+
className?: string;
8
+
pulse?: boolean;
9
+
}) {
10
+
return (
11
+
<span
12
+
className={`bg-gray-100 dark:bg-gray-800 rounded h-4 ${pulse ? "animate-pulse" : ""} ${className ?? ""}`}
13
+
></span>
14
+
);
15
+
}
16
+
17
+
export function ConditionalTextPlaceholder({
18
+
show,
19
+
placeholderClassName,
20
+
children,
21
+
}: {
22
+
show: boolean;
23
+
placeholderClassName?: string;
24
+
children?: ReactNode;
25
+
}) {
26
+
return (
27
+
<>
28
+
{show ? children : <TextPlaceholder className={placeholderClassName} />}
29
+
</>
30
+
);
31
+
}
-59
src/components/radix-m3-rd/Button.tsx
-59
src/components/radix-m3-rd/Button.tsx
···
1
-
import { Slot } from "@radix-ui/react-slot";
2
-
import clsx from "clsx";
3
-
import * as React from "react";
4
-
5
-
export type ButtonVariant = "filled" | "outlined" | "text" | "secondary";
6
-
export type ButtonSize = "sm" | "md" | "lg";
7
-
8
-
const variantClasses: Record<ButtonVariant, string> = {
9
-
filled:
10
-
"bg-gray-300 text-gray-900 hover:bg-gray-400 dark:bg-gray-600 dark:text-white dark:hover:bg-gray-500",
11
-
secondary:
12
-
"bg-gray-300 text-gray-900 hover:bg-gray-400 dark:bg-gray-600 dark:text-white dark:hover:bg-gray-500",
13
-
outlined:
14
-
"border border-gray-800 text-gray-800 hover:bg-gray-100 dark:border-gray-200 dark:text-gray-200 dark:hover:bg-gray-800/10",
15
-
text: "bg-transparent text-gray-800 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-800/10",
16
-
};
17
-
18
-
const sizeClasses: Record<ButtonSize, string> = {
19
-
sm: "px-3 py-1.5 text-sm",
20
-
md: "px-4 py-2 text-base",
21
-
lg: "px-6 py-3 text-lg",
22
-
};
23
-
24
-
export function Button({
25
-
variant = "filled",
26
-
size = "md",
27
-
asChild = false,
28
-
ref,
29
-
className,
30
-
children,
31
-
...props
32
-
}: {
33
-
variant?: ButtonVariant;
34
-
size?: ButtonSize;
35
-
asChild?: boolean;
36
-
className?: string;
37
-
children?: React.ReactNode;
38
-
ref?: React.Ref<HTMLButtonElement>;
39
-
} & React.ComponentPropsWithoutRef<"button">) {
40
-
const Comp = asChild ? Slot : "button";
41
-
42
-
return (
43
-
<Comp
44
-
ref={ref}
45
-
className={clsx(
46
-
//focus:outline-none focus:ring-1 focus:ring-offset-1 focus:ring-gray-500 dark:focus:ring-gray-300
47
-
"inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
48
-
variantClasses[variant],
49
-
sizeClasses[size],
50
-
className
51
-
)}
52
-
{...props}
53
-
>
54
-
{children}
55
-
</Comp>
56
-
);
57
-
}
58
-
59
-
Button.displayName = "Button";
+157
src/providers/LikeMutationQueueProvider.tsx
+157
src/providers/LikeMutationQueueProvider.tsx
···
1
+
import { AtUri } from "@atproto/api";
2
+
import { TID } from "@atproto/common-web";
3
+
import { useQueryClient } from "@tanstack/react-query";
4
+
import { useAtom } from "jotai";
5
+
import React, { createContext, use, useCallback, useEffect, useRef } from "react";
6
+
7
+
import { useAuth } from "~/providers/UnifiedAuthProvider";
8
+
import { constellationURLAtom, internalLikedPostsAtom } from "~/utils/atoms";
9
+
import { constructArbitraryQuery, constructConstellationQuery, type linksRecordsResponse } from "~/utils/useQuery";
10
+
11
+
export type LikeRecord = { uri: string; target: string; cid: string };
12
+
export type LikeMutation = { type: 'like'; target: string; cid: string };
13
+
export type UnlikeMutation = { type: 'unlike'; likeRecordUri: string; target: string, originalRecord: LikeRecord };
14
+
export type Mutation = LikeMutation | UnlikeMutation;
15
+
16
+
interface LikeMutationQueueContextType {
17
+
fastState: (target: string) => LikeRecord | null | undefined;
18
+
fastToggle: (target:string, cid:string) => void;
19
+
backfillState: (target: string, user: string) => Promise<void>;
20
+
}
21
+
22
+
const LikeMutationQueueContext = createContext<LikeMutationQueueContextType | undefined>(undefined);
23
+
24
+
export function LikeMutationQueueProvider({ children }: { children: React.ReactNode }) {
25
+
const { agent } = useAuth();
26
+
const queryClient = useQueryClient();
27
+
const [likedPosts, setLikedPosts] = useAtom(internalLikedPostsAtom);
28
+
const [constellationurl] = useAtom(constellationURLAtom);
29
+
30
+
const likedPostsRef = useRef(likedPosts);
31
+
useEffect(() => {
32
+
likedPostsRef.current = likedPosts;
33
+
}, [likedPosts]);
34
+
35
+
const queueRef = useRef<Mutation[]>([]);
36
+
const runningRef = useRef(false);
37
+
38
+
const fastState = (target: string) => likedPosts[target];
39
+
40
+
const setFastState = useCallback(
41
+
(target: string, record: LikeRecord | null) =>
42
+
setLikedPosts((prev) => ({ ...prev, [target]: record })),
43
+
[setLikedPosts]
44
+
);
45
+
46
+
const enqueue = (mutation: Mutation) => queueRef.current.push(mutation);
47
+
48
+
const fastToggle = useCallback((target: string, cid: string) => {
49
+
const likedRecord = likedPostsRef.current[target];
50
+
51
+
if (likedRecord) {
52
+
setFastState(target, null);
53
+
if (likedRecord.uri !== 'pending') {
54
+
enqueue({ type: "unlike", likeRecordUri: likedRecord.uri, target, originalRecord: likedRecord });
55
+
}
56
+
} else {
57
+
setFastState(target, { uri: "pending", target, cid });
58
+
enqueue({ type: "like", target, cid });
59
+
}
60
+
}, [setFastState]);
61
+
62
+
/**
63
+
*
64
+
* @deprecated dont use it yet, will cause infinite rerenders
65
+
*/
66
+
const backfillState = async (target: string, user: string) => {
67
+
const query = constructConstellationQuery({
68
+
constellation: constellationurl,
69
+
method: "/links",
70
+
target,
71
+
collection: "app.bsky.feed.like",
72
+
path: ".subject.uri",
73
+
dids: [user],
74
+
});
75
+
const data = await queryClient.fetchQuery(query);
76
+
const likes = (data as linksRecordsResponse)?.linking_records?.slice(0, 50) ?? [];
77
+
const found = likes.find((r) => r.did === user);
78
+
if (found) {
79
+
const uri = `at://${found.did}/${found.collection}/${found.rkey}`;
80
+
const ciddata = await queryClient.fetchQuery(
81
+
constructArbitraryQuery(uri)
82
+
);
83
+
if (ciddata?.cid)
84
+
setFastState(target, { uri, target, cid: ciddata?.cid });
85
+
} else {
86
+
setFastState(target, null);
87
+
}
88
+
};
89
+
90
+
91
+
useEffect(() => {
92
+
if (!agent?.did) return;
93
+
94
+
const processQueue = async () => {
95
+
if (runningRef.current || queueRef.current.length === 0) return;
96
+
runningRef.current = true;
97
+
98
+
while (queueRef.current.length > 0) {
99
+
const mutation = queueRef.current.shift()!;
100
+
try {
101
+
if (mutation.type === "like") {
102
+
const newRecord = {
103
+
repo: agent.did!,
104
+
collection: "app.bsky.feed.like",
105
+
rkey: TID.next().toString(),
106
+
record: {
107
+
$type: "app.bsky.feed.like",
108
+
subject: { uri: mutation.target, cid: mutation.cid },
109
+
createdAt: new Date().toISOString(),
110
+
},
111
+
};
112
+
const response = await agent.com.atproto.repo.createRecord(newRecord);
113
+
if (!response.success) throw new Error("createRecord failed");
114
+
115
+
const uri = `at://${agent.did}/${newRecord.collection}/${newRecord.rkey}`;
116
+
setFastState(mutation.target, {
117
+
uri,
118
+
target: mutation.target,
119
+
cid: mutation.cid,
120
+
});
121
+
} else if (mutation.type === "unlike") {
122
+
const aturi = new AtUri(mutation.likeRecordUri);
123
+
await agent.com.atproto.repo.deleteRecord({ repo: agent.did!, collection: aturi.collection, rkey: aturi.rkey });
124
+
setFastState(mutation.target, null);
125
+
}
126
+
} catch (err) {
127
+
console.error("Like mutation failed, reverting:", err);
128
+
if (mutation.type === 'like') {
129
+
setFastState(mutation.target, null);
130
+
} else if (mutation.type === 'unlike') {
131
+
setFastState(mutation.target, mutation.originalRecord);
132
+
}
133
+
}
134
+
}
135
+
runningRef.current = false;
136
+
};
137
+
138
+
const interval = setInterval(processQueue, 1000);
139
+
return () => clearInterval(interval);
140
+
}, [agent, setFastState]);
141
+
142
+
const value = { fastState, fastToggle, backfillState };
143
+
144
+
return (
145
+
<LikeMutationQueueContext value={value}>
146
+
{children}
147
+
</LikeMutationQueueContext>
148
+
);
149
+
}
150
+
151
+
export function useLikeMutationQueue() {
152
+
const context = use(LikeMutationQueueContext);
153
+
if (context === undefined) {
154
+
throw new Error('useLikeMutationQueue must be used within a LikeMutationQueueProvider');
155
+
}
156
+
return context;
157
+
}
+129
src/routeTree.gen.ts
+129
src/routeTree.gen.ts
···
18
18
import { Route as CallbackIndexRouteImport } from './routes/callback/index'
19
19
import { Route as PathlessLayoutNestedLayoutRouteImport } from './routes/_pathlessLayout/_nested-layout'
20
20
import { Route as ProfileDidIndexRouteImport } from './routes/profile.$did/index'
21
+
import { Route as ProfileDidFollowsRouteImport } from './routes/profile.$did/follows'
22
+
import { Route as ProfileDidFollowersRouteImport } from './routes/profile.$did/followers'
21
23
import { Route as PathlessLayoutNestedLayoutRouteBRouteImport } from './routes/_pathlessLayout/_nested-layout/route-b'
22
24
import { Route as PathlessLayoutNestedLayoutRouteARouteImport } from './routes/_pathlessLayout/_nested-layout/route-a'
23
25
import { Route as ProfileDidPostRkeyRouteImport } from './routes/profile.$did/post.$rkey'
26
+
import { Route as ProfileDidFeedRkeyRouteImport } from './routes/profile.$did/feed.$rkey'
27
+
import { Route as ProfileDidPostRkeyRepostedByRouteImport } from './routes/profile.$did/post.$rkey.reposted-by'
28
+
import { Route as ProfileDidPostRkeyQuotesRouteImport } from './routes/profile.$did/post.$rkey.quotes'
29
+
import { Route as ProfileDidPostRkeyLikedByRouteImport } from './routes/profile.$did/post.$rkey.liked-by'
24
30
import { Route as ProfileDidPostRkeyImageIRouteImport } from './routes/profile.$did/post.$rkey.image.$i'
25
31
26
32
const SettingsRoute = SettingsRouteImport.update({
···
67
73
path: '/profile/$did/',
68
74
getParentRoute: () => rootRouteImport,
69
75
} as any)
76
+
const ProfileDidFollowsRoute = ProfileDidFollowsRouteImport.update({
77
+
id: '/profile/$did/follows',
78
+
path: '/profile/$did/follows',
79
+
getParentRoute: () => rootRouteImport,
80
+
} as any)
81
+
const ProfileDidFollowersRoute = ProfileDidFollowersRouteImport.update({
82
+
id: '/profile/$did/followers',
83
+
path: '/profile/$did/followers',
84
+
getParentRoute: () => rootRouteImport,
85
+
} as any)
70
86
const PathlessLayoutNestedLayoutRouteBRoute =
71
87
PathlessLayoutNestedLayoutRouteBRouteImport.update({
72
88
id: '/route-b',
···
84
100
path: '/profile/$did/post/$rkey',
85
101
getParentRoute: () => rootRouteImport,
86
102
} as any)
103
+
const ProfileDidFeedRkeyRoute = ProfileDidFeedRkeyRouteImport.update({
104
+
id: '/profile/$did/feed/$rkey',
105
+
path: '/profile/$did/feed/$rkey',
106
+
getParentRoute: () => rootRouteImport,
107
+
} as any)
108
+
const ProfileDidPostRkeyRepostedByRoute =
109
+
ProfileDidPostRkeyRepostedByRouteImport.update({
110
+
id: '/reposted-by',
111
+
path: '/reposted-by',
112
+
getParentRoute: () => ProfileDidPostRkeyRoute,
113
+
} as any)
114
+
const ProfileDidPostRkeyQuotesRoute =
115
+
ProfileDidPostRkeyQuotesRouteImport.update({
116
+
id: '/quotes',
117
+
path: '/quotes',
118
+
getParentRoute: () => ProfileDidPostRkeyRoute,
119
+
} as any)
120
+
const ProfileDidPostRkeyLikedByRoute =
121
+
ProfileDidPostRkeyLikedByRouteImport.update({
122
+
id: '/liked-by',
123
+
path: '/liked-by',
124
+
getParentRoute: () => ProfileDidPostRkeyRoute,
125
+
} as any)
87
126
const ProfileDidPostRkeyImageIRoute =
88
127
ProfileDidPostRkeyImageIRouteImport.update({
89
128
id: '/image/$i',
···
100
139
'/callback': typeof CallbackIndexRoute
101
140
'/route-a': typeof PathlessLayoutNestedLayoutRouteARoute
102
141
'/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute
142
+
'/profile/$did/followers': typeof ProfileDidFollowersRoute
143
+
'/profile/$did/follows': typeof ProfileDidFollowsRoute
103
144
'/profile/$did': typeof ProfileDidIndexRoute
145
+
'/profile/$did/feed/$rkey': typeof ProfileDidFeedRkeyRoute
104
146
'/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren
147
+
'/profile/$did/post/$rkey/liked-by': typeof ProfileDidPostRkeyLikedByRoute
148
+
'/profile/$did/post/$rkey/quotes': typeof ProfileDidPostRkeyQuotesRoute
149
+
'/profile/$did/post/$rkey/reposted-by': typeof ProfileDidPostRkeyRepostedByRoute
105
150
'/profile/$did/post/$rkey/image/$i': typeof ProfileDidPostRkeyImageIRoute
106
151
}
107
152
export interface FileRoutesByTo {
···
113
158
'/callback': typeof CallbackIndexRoute
114
159
'/route-a': typeof PathlessLayoutNestedLayoutRouteARoute
115
160
'/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute
161
+
'/profile/$did/followers': typeof ProfileDidFollowersRoute
162
+
'/profile/$did/follows': typeof ProfileDidFollowsRoute
116
163
'/profile/$did': typeof ProfileDidIndexRoute
164
+
'/profile/$did/feed/$rkey': typeof ProfileDidFeedRkeyRoute
117
165
'/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren
166
+
'/profile/$did/post/$rkey/liked-by': typeof ProfileDidPostRkeyLikedByRoute
167
+
'/profile/$did/post/$rkey/quotes': typeof ProfileDidPostRkeyQuotesRoute
168
+
'/profile/$did/post/$rkey/reposted-by': typeof ProfileDidPostRkeyRepostedByRoute
118
169
'/profile/$did/post/$rkey/image/$i': typeof ProfileDidPostRkeyImageIRoute
119
170
}
120
171
export interface FileRoutesById {
···
129
180
'/callback/': typeof CallbackIndexRoute
130
181
'/_pathlessLayout/_nested-layout/route-a': typeof PathlessLayoutNestedLayoutRouteARoute
131
182
'/_pathlessLayout/_nested-layout/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute
183
+
'/profile/$did/followers': typeof ProfileDidFollowersRoute
184
+
'/profile/$did/follows': typeof ProfileDidFollowsRoute
132
185
'/profile/$did/': typeof ProfileDidIndexRoute
186
+
'/profile/$did/feed/$rkey': typeof ProfileDidFeedRkeyRoute
133
187
'/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren
188
+
'/profile/$did/post/$rkey/liked-by': typeof ProfileDidPostRkeyLikedByRoute
189
+
'/profile/$did/post/$rkey/quotes': typeof ProfileDidPostRkeyQuotesRoute
190
+
'/profile/$did/post/$rkey/reposted-by': typeof ProfileDidPostRkeyRepostedByRoute
134
191
'/profile/$did/post/$rkey/image/$i': typeof ProfileDidPostRkeyImageIRoute
135
192
}
136
193
export interface FileRouteTypes {
···
144
201
| '/callback'
145
202
| '/route-a'
146
203
| '/route-b'
204
+
| '/profile/$did/followers'
205
+
| '/profile/$did/follows'
147
206
| '/profile/$did'
207
+
| '/profile/$did/feed/$rkey'
148
208
| '/profile/$did/post/$rkey'
209
+
| '/profile/$did/post/$rkey/liked-by'
210
+
| '/profile/$did/post/$rkey/quotes'
211
+
| '/profile/$did/post/$rkey/reposted-by'
149
212
| '/profile/$did/post/$rkey/image/$i'
150
213
fileRoutesByTo: FileRoutesByTo
151
214
to:
···
157
220
| '/callback'
158
221
| '/route-a'
159
222
| '/route-b'
223
+
| '/profile/$did/followers'
224
+
| '/profile/$did/follows'
160
225
| '/profile/$did'
226
+
| '/profile/$did/feed/$rkey'
161
227
| '/profile/$did/post/$rkey'
228
+
| '/profile/$did/post/$rkey/liked-by'
229
+
| '/profile/$did/post/$rkey/quotes'
230
+
| '/profile/$did/post/$rkey/reposted-by'
162
231
| '/profile/$did/post/$rkey/image/$i'
163
232
id:
164
233
| '__root__'
···
172
241
| '/callback/'
173
242
| '/_pathlessLayout/_nested-layout/route-a'
174
243
| '/_pathlessLayout/_nested-layout/route-b'
244
+
| '/profile/$did/followers'
245
+
| '/profile/$did/follows'
175
246
| '/profile/$did/'
247
+
| '/profile/$did/feed/$rkey'
176
248
| '/profile/$did/post/$rkey'
249
+
| '/profile/$did/post/$rkey/liked-by'
250
+
| '/profile/$did/post/$rkey/quotes'
251
+
| '/profile/$did/post/$rkey/reposted-by'
177
252
| '/profile/$did/post/$rkey/image/$i'
178
253
fileRoutesById: FileRoutesById
179
254
}
···
185
260
SearchRoute: typeof SearchRoute
186
261
SettingsRoute: typeof SettingsRoute
187
262
CallbackIndexRoute: typeof CallbackIndexRoute
263
+
ProfileDidFollowersRoute: typeof ProfileDidFollowersRoute
264
+
ProfileDidFollowsRoute: typeof ProfileDidFollowsRoute
188
265
ProfileDidIndexRoute: typeof ProfileDidIndexRoute
266
+
ProfileDidFeedRkeyRoute: typeof ProfileDidFeedRkeyRoute
189
267
ProfileDidPostRkeyRoute: typeof ProfileDidPostRkeyRouteWithChildren
190
268
}
191
269
···
254
332
preLoaderRoute: typeof ProfileDidIndexRouteImport
255
333
parentRoute: typeof rootRouteImport
256
334
}
335
+
'/profile/$did/follows': {
336
+
id: '/profile/$did/follows'
337
+
path: '/profile/$did/follows'
338
+
fullPath: '/profile/$did/follows'
339
+
preLoaderRoute: typeof ProfileDidFollowsRouteImport
340
+
parentRoute: typeof rootRouteImport
341
+
}
342
+
'/profile/$did/followers': {
343
+
id: '/profile/$did/followers'
344
+
path: '/profile/$did/followers'
345
+
fullPath: '/profile/$did/followers'
346
+
preLoaderRoute: typeof ProfileDidFollowersRouteImport
347
+
parentRoute: typeof rootRouteImport
348
+
}
257
349
'/_pathlessLayout/_nested-layout/route-b': {
258
350
id: '/_pathlessLayout/_nested-layout/route-b'
259
351
path: '/route-b'
···
275
367
preLoaderRoute: typeof ProfileDidPostRkeyRouteImport
276
368
parentRoute: typeof rootRouteImport
277
369
}
370
+
'/profile/$did/feed/$rkey': {
371
+
id: '/profile/$did/feed/$rkey'
372
+
path: '/profile/$did/feed/$rkey'
373
+
fullPath: '/profile/$did/feed/$rkey'
374
+
preLoaderRoute: typeof ProfileDidFeedRkeyRouteImport
375
+
parentRoute: typeof rootRouteImport
376
+
}
377
+
'/profile/$did/post/$rkey/reposted-by': {
378
+
id: '/profile/$did/post/$rkey/reposted-by'
379
+
path: '/reposted-by'
380
+
fullPath: '/profile/$did/post/$rkey/reposted-by'
381
+
preLoaderRoute: typeof ProfileDidPostRkeyRepostedByRouteImport
382
+
parentRoute: typeof ProfileDidPostRkeyRoute
383
+
}
384
+
'/profile/$did/post/$rkey/quotes': {
385
+
id: '/profile/$did/post/$rkey/quotes'
386
+
path: '/quotes'
387
+
fullPath: '/profile/$did/post/$rkey/quotes'
388
+
preLoaderRoute: typeof ProfileDidPostRkeyQuotesRouteImport
389
+
parentRoute: typeof ProfileDidPostRkeyRoute
390
+
}
391
+
'/profile/$did/post/$rkey/liked-by': {
392
+
id: '/profile/$did/post/$rkey/liked-by'
393
+
path: '/liked-by'
394
+
fullPath: '/profile/$did/post/$rkey/liked-by'
395
+
preLoaderRoute: typeof ProfileDidPostRkeyLikedByRouteImport
396
+
parentRoute: typeof ProfileDidPostRkeyRoute
397
+
}
278
398
'/profile/$did/post/$rkey/image/$i': {
279
399
id: '/profile/$did/post/$rkey/image/$i'
280
400
path: '/image/$i'
···
316
436
)
317
437
318
438
interface ProfileDidPostRkeyRouteChildren {
439
+
ProfileDidPostRkeyLikedByRoute: typeof ProfileDidPostRkeyLikedByRoute
440
+
ProfileDidPostRkeyQuotesRoute: typeof ProfileDidPostRkeyQuotesRoute
441
+
ProfileDidPostRkeyRepostedByRoute: typeof ProfileDidPostRkeyRepostedByRoute
319
442
ProfileDidPostRkeyImageIRoute: typeof ProfileDidPostRkeyImageIRoute
320
443
}
321
444
322
445
const ProfileDidPostRkeyRouteChildren: ProfileDidPostRkeyRouteChildren = {
446
+
ProfileDidPostRkeyLikedByRoute: ProfileDidPostRkeyLikedByRoute,
447
+
ProfileDidPostRkeyQuotesRoute: ProfileDidPostRkeyQuotesRoute,
448
+
ProfileDidPostRkeyRepostedByRoute: ProfileDidPostRkeyRepostedByRoute,
323
449
ProfileDidPostRkeyImageIRoute: ProfileDidPostRkeyImageIRoute,
324
450
}
325
451
···
334
460
SearchRoute: SearchRoute,
335
461
SettingsRoute: SettingsRoute,
336
462
CallbackIndexRoute: CallbackIndexRoute,
463
+
ProfileDidFollowersRoute: ProfileDidFollowersRoute,
464
+
ProfileDidFollowsRoute: ProfileDidFollowsRoute,
337
465
ProfileDidIndexRoute: ProfileDidIndexRoute,
466
+
ProfileDidFeedRkeyRoute: ProfileDidFeedRkeyRoute,
338
467
ProfileDidPostRkeyRoute: ProfileDidPostRkeyRouteWithChildren,
339
468
}
340
469
export const routeTree = rootRouteImport
+8
-5
src/routes/__root.tsx
+8
-5
src/routes/__root.tsx
···
22
22
import Login from "~/components/Login";
23
23
import { NotFound } from "~/components/NotFound";
24
24
import { FluentEmojiHighContrastGlowingStar } from "~/components/Star";
25
+
import { LikeMutationQueueProvider } from "~/providers/LikeMutationQueueProvider";
25
26
import { UnifiedAuthProvider, useAuth } from "~/providers/UnifiedAuthProvider";
26
27
import { composerAtom, hueAtom, useAtomCssVar } from "~/utils/atoms";
27
28
import { seo } from "~/utils/seo";
···
79
80
function RootComponent() {
80
81
return (
81
82
<UnifiedAuthProvider>
82
-
<RootDocument>
83
-
<KeepAliveProvider>
84
-
<KeepAliveOutlet />
85
-
</KeepAliveProvider>
86
-
</RootDocument>
83
+
<LikeMutationQueueProvider>
84
+
<RootDocument>
85
+
<KeepAliveProvider>
86
+
<KeepAliveOutlet />
87
+
</KeepAliveProvider>
88
+
</RootDocument>
89
+
</LikeMutationQueueProvider>
87
90
</UnifiedAuthProvider>
88
91
);
89
92
}
+44
-33
src/routes/index.tsx
+44
-33
src/routes/index.tsx
···
359
359
>
360
360
{!isAuthRestoring && savedFeeds.length > 0 ? (
361
361
<div className={`flex items-center px-4 py-2 h-[52px] sticky top-0 bg-[var(--header-bg-light)] dark:bg-[var(--header-bg-dark)] ${!isAtTop && "shadow-sm"} sm:shadow-none sm:bg-white sm:dark:bg-gray-950 z-10 border-0 sm:border-b border-gray-200 dark:border-gray-700 overflow-x-auto overflow-y-hidden scroll-thin`}>
362
-
{savedFeeds.map((item: any, idx: number) => {
363
-
const label = item.value.split("/").pop() || item.value;
364
-
const isActive = selectedFeed === item.value;
365
-
return (
366
-
<button
367
-
key={item.value || idx}
368
-
className={`px-3 py-1 rounded-full whitespace-nowrap font-medium transition-colors ${
369
-
isActive
370
-
? "text-gray-900 dark:text-gray-100 hover:bg-gray-300 dark:bg-gray-700 bg-gray-200 hover:dark:bg-gray-600"
371
-
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 hover:dark:bg-gray-800"
372
-
// ? "bg-gray-500 text-white"
373
-
// : item.pinned
374
-
// ? "bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200"
375
-
// : "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-200"
376
-
}`}
377
-
onClick={() => setSelectedFeed(item.value)}
378
-
title={item.value}
379
-
>
380
-
{label}
381
-
{item.pinned && (
382
-
<span
383
-
className={`ml-1 text-xs ${
384
-
isActive
385
-
? "text-gray-900 dark:text-gray-100"
386
-
: "text-gray-600 dark:text-gray-400"
387
-
}`}
388
-
>
389
-
โ
390
-
</span>
391
-
)}
392
-
</button>
393
-
);
394
-
})}
362
+
{savedFeeds.map((item: any, idx: number) => {return <FeedTabOnTop key={item} item={item} idx={idx} />})}
395
363
</div>
396
364
) : (
397
365
// <span className="text-xl font-bold ml-2">Home</span>
···
435
403
</div>
436
404
);
437
405
}
406
+
407
+
408
+
// todo please use types this is dangerous very dangerous.
409
+
// todo fix this whenever proper preferences is handled
410
+
function FeedTabOnTop({item, idx}:{item: any, idx: number}) {
411
+
const [persistentSelectedFeed, setPersistentSelectedFeed] = useAtom(selectedFeedUriAtom);
412
+
const selectedFeed = persistentSelectedFeed
413
+
const setSelectedFeed = setPersistentSelectedFeed
414
+
const rkey = item.value.split("/").pop() || item.value;
415
+
const isActive = selectedFeed === item.value;
416
+
const { data: feedrecord } = useQueryArbitrary(item.value)
417
+
const label = feedrecord?.value?.displayName || rkey
418
+
return (
419
+
<button
420
+
key={item.value || idx}
421
+
className={`px-3 py-1 rounded-full whitespace-nowrap font-medium transition-colors ${
422
+
isActive
423
+
? "text-gray-900 dark:text-gray-100 hover:bg-gray-300 dark:bg-gray-700 bg-gray-200 hover:dark:bg-gray-600"
424
+
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 hover:dark:bg-gray-800"
425
+
// ? "bg-gray-500 text-white"
426
+
// : item.pinned
427
+
// ? "bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200"
428
+
// : "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-200"
429
+
}`}
430
+
onClick={() => setSelectedFeed(item.value)}
431
+
title={item.value}
432
+
>
433
+
{label}
434
+
{item.pinned && (
435
+
<span
436
+
className={`ml-1 text-xs ${
437
+
isActive
438
+
? "text-gray-900 dark:text-gray-100"
439
+
: "text-gray-600 dark:text-gray-400"
440
+
}`}
441
+
>
442
+
โ
443
+
</span>
444
+
)}
445
+
</button>
446
+
);
447
+
}
448
+
438
449
// not even used lmaooo
439
450
440
451
// export async function cachedResolveDIDWEBDOC({
+645
-161
src/routes/notifications.tsx
+645
-161
src/routes/notifications.tsx
···
1
-
import { createFileRoute } from "@tanstack/react-router";
1
+
import { AtUri } from "@atproto/api";
2
+
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
3
+
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
2
4
import { useAtom } from "jotai";
3
-
import React, { useEffect, useRef,useState } from "react";
5
+
import * as React from "react";
4
6
7
+
import defaultpfp from "~/../public/favicon.png";
8
+
import { Header } from "~/components/Header";
9
+
import {
10
+
ReusableTabRoute,
11
+
useReusableTabScrollRestore,
12
+
} from "~/components/ReusableTabRoute";
13
+
import {
14
+
MdiCardsHeartOutline,
15
+
MdiCommentOutline,
16
+
MdiRepeat,
17
+
UniversalPostRendererATURILoader,
18
+
} from "~/components/UniversalPostRenderer";
5
19
import { useAuth } from "~/providers/UnifiedAuthProvider";
6
-
import { constellationURLAtom } from "~/utils/atoms";
20
+
import {
21
+
constellationURLAtom,
22
+
enableBitesAtom,
23
+
imgCDNAtom,
24
+
postInteractionsFiltersAtom,
25
+
} from "~/utils/atoms";
26
+
import {
27
+
useInfiniteQueryAuthorFeed,
28
+
useQueryConstellation,
29
+
useQueryIdentity,
30
+
useQueryProfile,
31
+
yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks,
32
+
} from "~/utils/useQuery";
33
+
34
+
import { FollowButton, Mutual } from "./profile.$did";
35
+
import { ConditionalTextPlaceholder } from "~/components/placeholders/TextPlaceholder";
7
36
8
-
const HANDLE_DID_CACHE_TIMEOUT = 60 * 60 * 1000; // 1 hour
37
+
export function NotificationsComponent() {
38
+
return (
39
+
<div className="">
40
+
<Header
41
+
title={`Notifications`}
42
+
backButtonCallback={() => {
43
+
if (window.history.length > 1) {
44
+
window.history.back();
45
+
} else {
46
+
window.location.assign("/");
47
+
}
48
+
}}
49
+
bottomBorderDisabled={true}
50
+
/>
51
+
<NotificationsTabs />
52
+
</div>
53
+
);
54
+
}
9
55
10
56
export const Route = createFileRoute("/notifications")({
11
-
component: NotificationsComponent,
57
+
component: NotificationsComponent,
12
58
});
13
59
14
-
function NotificationsComponent() {
15
-
// /*mass comment*/ console.log("NotificationsComponent render");
16
-
const { agent, status } = useAuth();
17
-
const authed = !!agent?.did;
18
-
const authLoading = status === "loading";
19
-
const [did, setDid] = useState<string | null>(null);
20
-
const [resolving, setResolving] = useState(false);
21
-
const [error, setError] = useState<string | null>(null);
22
-
const [responses, setResponses] = useState<any[]>([null, null, null]);
23
-
const [loading, setLoading] = useState(false);
24
-
const inputRef = useRef<HTMLInputElement>(null);
60
+
export default function NotificationsTabs() {
61
+
const [bitesEnabled] = useAtom(enableBitesAtom);
62
+
return (
63
+
<ReusableTabRoute
64
+
route={`Notifications`}
65
+
tabs={{
66
+
Mentions: <MentionsTab />,
67
+
Follows: <FollowsTab />,
68
+
"Post Interactions": <PostInteractionsTab />,
69
+
...(bitesEnabled
70
+
? {
71
+
Bites: <BitesTab />,
72
+
}
73
+
: {}),
74
+
}}
75
+
/>
76
+
);
77
+
}
78
+
79
+
function MentionsTab() {
80
+
const { agent } = useAuth();
81
+
const [constellationurl] = useAtom(constellationURLAtom);
82
+
const infinitequeryresults = useInfiniteQuery({
83
+
...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(
84
+
{
85
+
constellation: constellationurl,
86
+
method: "/links",
87
+
target: agent?.did,
88
+
collection: "app.bsky.feed.post",
89
+
path: ".facets[app.bsky.richtext.facet].features[app.bsky.richtext.facet#mention].did",
90
+
}
91
+
),
92
+
enabled: !!agent?.did,
93
+
});
94
+
95
+
const {
96
+
data: infiniteMentionsData,
97
+
fetchNextPage,
98
+
hasNextPage,
99
+
isFetchingNextPage,
100
+
isLoading,
101
+
isError,
102
+
error,
103
+
} = infinitequeryresults;
104
+
105
+
const mentionsAturis = React.useMemo(() => {
106
+
// Get all replies from the standard infinite query
107
+
return (
108
+
infiniteMentionsData?.pages.flatMap(
109
+
(page) =>
110
+
page?.linking_records.map(
111
+
(r) => `at://${r.did}/${r.collection}/${r.rkey}`
112
+
) ?? []
113
+
) ?? []
114
+
);
115
+
}, [infiniteMentionsData]);
116
+
117
+
useReusableTabScrollRestore("Notifications");
118
+
119
+
if (isLoading) return <LoadingState text="Loading mentions..." />;
120
+
if (isError) return <ErrorState error={error} />;
121
+
122
+
if (!mentionsAturis?.length) return <EmptyState text="No mentions yet." />;
123
+
124
+
return (
125
+
<>
126
+
{mentionsAturis.map((m) => (
127
+
<UniversalPostRendererATURILoader key={m} atUri={m} />
128
+
))}
129
+
130
+
{hasNextPage && (
131
+
<button
132
+
onClick={() => fetchNextPage()}
133
+
disabled={isFetchingNextPage}
134
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50"
135
+
>
136
+
{isFetchingNextPage ? "Loading..." : "Load More"}
137
+
</button>
138
+
)}
139
+
</>
140
+
);
141
+
}
142
+
143
+
export function FollowsTab({ did }: { did?: string }) {
144
+
const { agent } = useAuth();
145
+
const userdidunsafe = did ?? agent?.did;
146
+
const { data: identity } = useQueryIdentity(userdidunsafe);
147
+
const userdid = identity?.did;
148
+
149
+
const [constellationurl] = useAtom(constellationURLAtom);
150
+
const infinitequeryresults = useInfiniteQuery({
151
+
...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(
152
+
{
153
+
constellation: constellationurl,
154
+
method: "/links",
155
+
target: userdid,
156
+
collection: "app.bsky.graph.follow",
157
+
path: ".subject",
158
+
}
159
+
),
160
+
enabled: !!userdid,
161
+
});
162
+
163
+
const {
164
+
data: infiniteFollowsData,
165
+
fetchNextPage,
166
+
hasNextPage,
167
+
isFetchingNextPage,
168
+
isLoading,
169
+
isError,
170
+
error,
171
+
} = infinitequeryresults;
172
+
173
+
const followsAturis = React.useMemo(() => {
174
+
// Get all replies from the standard infinite query
175
+
return (
176
+
infiniteFollowsData?.pages.flatMap(
177
+
(page) =>
178
+
page?.linking_records.map(
179
+
(r) => `at://${r.did}/${r.collection}/${r.rkey}`
180
+
) ?? []
181
+
) ?? []
182
+
);
183
+
}, [infiniteFollowsData]);
184
+
185
+
useReusableTabScrollRestore("Notifications");
186
+
187
+
if (isLoading) return <LoadingState text="Loading follows..." />;
188
+
if (isError) return <ErrorState error={error} />;
189
+
190
+
if (!followsAturis?.length) return <EmptyState text="No follows yet." />;
191
+
192
+
return (
193
+
<>
194
+
{followsAturis.map((m) => (
195
+
<NotificationItem key={m} notification={m} />
196
+
))}
197
+
198
+
{hasNextPage && (
199
+
<button
200
+
onClick={() => fetchNextPage()}
201
+
disabled={isFetchingNextPage}
202
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50"
203
+
>
204
+
{isFetchingNextPage ? "Loading..." : "Load More"}
205
+
</button>
206
+
)}
207
+
</>
208
+
);
209
+
}
210
+
211
+
export function BitesTab({ did }: { did?: string }) {
212
+
const { agent } = useAuth();
213
+
const userdidunsafe = did ?? agent?.did;
214
+
const { data: identity } = useQueryIdentity(userdidunsafe);
215
+
const userdid = identity?.did;
216
+
217
+
const [constellationurl] = useAtom(constellationURLAtom);
218
+
const infinitequeryresults = useInfiniteQuery({
219
+
...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(
220
+
{
221
+
constellation: constellationurl,
222
+
method: "/links",
223
+
target: "at://" + userdid,
224
+
collection: "net.wafrn.feed.bite",
225
+
path: ".subject",
226
+
staleMult: 0, // safe fun
227
+
}
228
+
),
229
+
enabled: !!userdid,
230
+
});
231
+
232
+
const {
233
+
data: infiniteFollowsData,
234
+
fetchNextPage,
235
+
hasNextPage,
236
+
isFetchingNextPage,
237
+
isLoading,
238
+
isError,
239
+
error,
240
+
} = infinitequeryresults;
241
+
242
+
const followsAturis = React.useMemo(() => {
243
+
// Get all replies from the standard infinite query
244
+
return (
245
+
infiniteFollowsData?.pages.flatMap(
246
+
(page) =>
247
+
page?.linking_records.map(
248
+
(r) => `at://${r.did}/${r.collection}/${r.rkey}`
249
+
) ?? []
250
+
) ?? []
251
+
);
252
+
}, [infiniteFollowsData]);
253
+
254
+
useReusableTabScrollRestore("Notifications");
25
255
26
-
useEffect(() => {
27
-
if (authLoading) return;
28
-
if (authed && agent && agent.assertDid) {
29
-
setDid(agent.assertDid);
30
-
}
31
-
}, [authed, agent, authLoading]);
256
+
if (isLoading) return <LoadingState text="Loading bites..." />;
257
+
if (isError) return <ErrorState error={error} />;
32
258
33
-
async function handleSubmit() {
34
-
// /*mass comment*/ console.log("handleSubmit called");
35
-
setError(null);
36
-
setResponses([null, null, null]);
37
-
const value = inputRef.current?.value?.trim() || "";
38
-
if (!value) return;
39
-
if (value.startsWith("did:")) {
40
-
setDid(value);
41
-
setError(null);
42
-
return;
43
-
}
44
-
setResolving(true);
45
-
const cacheKey = `handleDid:${value}`;
46
-
const now = Date.now();
47
-
const cached = undefined // await get(cacheKey);
48
-
// if (
49
-
// cached &&
50
-
// cached.value &&
51
-
// cached.time &&
52
-
// now - cached.time < HANDLE_DID_CACHE_TIMEOUT
53
-
// ) {
54
-
// try {
55
-
// const data = JSON.parse(cached.value);
56
-
// setDid(data.did);
57
-
// setResolving(false);
58
-
// return;
59
-
// } catch {}
60
-
// }
61
-
try {
62
-
const url = `https://free-fly-24.deno.dev/?handle=${encodeURIComponent(value)}`;
63
-
const res = await fetch(url);
64
-
if (!res.ok) throw new Error("Failed to resolve handle");
65
-
const data = await res.json();
66
-
//set(cacheKey, JSON.stringify(data));
67
-
setDid(data.did);
68
-
} catch (e: any) {
69
-
setError("Failed to resolve handle: " + (e?.message || e));
70
-
} finally {
71
-
setResolving(false);
72
-
}
73
-
}
259
+
if (!followsAturis?.length) return <EmptyState text="No bites yet." />;
74
260
75
-
const [constellationURL] = useAtom(constellationURLAtom)
261
+
return (
262
+
<>
263
+
{followsAturis.map((m) => (
264
+
<NotificationItem key={m} notification={m} />
265
+
))}
76
266
77
-
useEffect(() => {
78
-
if (!did) return;
79
-
setLoading(true);
80
-
setError(null);
81
-
const urls = [
82
-
`https://${constellationURL}/links?target=${encodeURIComponent(did)}&collection=app.bsky.feed.post&path=.facets[app.bsky.richtext.facet].features[app.bsky.richtext.facet%23mention].did`,
83
-
`https://${constellationURL}/links?target=${encodeURIComponent(did)}&collection=app.bsky.feed.post&path=.facets[].features[app.bsky.richtext.facet%23mention].did`,
84
-
`https://${constellationURL}/links?target=${encodeURIComponent(did)}&collection=app.bsky.graph.follow&path=.subject`,
85
-
];
86
-
let ignore = false;
87
-
Promise.all(
88
-
urls.map(async (url) => {
89
-
try {
90
-
const r = await fetch(url);
91
-
if (!r.ok) throw new Error("Failed to fetch");
92
-
const text = await r.text();
93
-
if (!text) return null;
94
-
try {
95
-
return JSON.parse(text);
96
-
} catch {
97
-
return null;
98
-
}
99
-
} catch (e: any) {
100
-
return { error: e?.message || String(e) };
101
-
}
102
-
})
103
-
)
104
-
.then((results) => {
105
-
if (!ignore) setResponses(results);
106
-
})
107
-
.catch((e) => {
108
-
if (!ignore)
109
-
setError("Failed to fetch notifications: " + (e?.message || e));
110
-
})
111
-
.finally(() => {
112
-
if (!ignore) setLoading(false);
113
-
});
114
-
return () => {
115
-
ignore = true;
116
-
};
117
-
}, [did]);
267
+
{hasNextPage && (
268
+
<button
269
+
onClick={() => fetchNextPage()}
270
+
disabled={isFetchingNextPage}
271
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50"
272
+
>
273
+
{isFetchingNextPage ? "Loading..." : "Load More"}
274
+
</button>
275
+
)}
276
+
</>
277
+
);
278
+
}
118
279
119
-
return (
120
-
<div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800">
121
-
<div className="flex items-center gap-2 px-4 py-2 h-[52px] sticky top-0 bg-white dark:bg-gray-950 z-10 border-b border-gray-200 dark:border-gray-800">
122
-
<span className="text-xl font-bold ml-2">Notifications</span>
123
-
{!authed && (
124
-
<div className="flex items-center gap-2">
125
-
<input
126
-
type="text"
127
-
placeholder="Enter handle or DID"
128
-
ref={inputRef}
129
-
className="ml-4 px-2 py-1 rounded border border-gray-300 dark:border-gray-700 bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100"
130
-
style={{ minWidth: 220 }}
131
-
disabled={resolving}
132
-
/>
133
-
<button
134
-
type="button"
135
-
className="px-3 py-1 rounded bg-blue-600 text-white font-semibold disabled:opacity-50"
136
-
disabled={resolving}
137
-
onClick={handleSubmit}
138
-
>
139
-
{resolving ? "Resolving..." : "Submit"}
140
-
</button>
141
-
</div>
280
+
function PostInteractionsTab() {
281
+
const { agent } = useAuth();
282
+
const { data: identity } = useQueryIdentity(agent?.did);
283
+
const queryClient = useQueryClient();
284
+
const {
285
+
data: postsData,
286
+
fetchNextPage,
287
+
hasNextPage,
288
+
isFetchingNextPage,
289
+
isLoading: arePostsLoading,
290
+
} = useInfiniteQueryAuthorFeed(agent?.did, identity?.pds);
291
+
292
+
React.useEffect(() => {
293
+
if (postsData) {
294
+
postsData.pages.forEach((page) => {
295
+
page.records.forEach((record) => {
296
+
if (!queryClient.getQueryData(["post", record.uri])) {
297
+
queryClient.setQueryData(["post", record.uri], record);
298
+
}
299
+
});
300
+
});
301
+
}
302
+
}, [postsData, queryClient]);
303
+
304
+
const posts = React.useMemo(
305
+
() => postsData?.pages.flatMap((page) => page.records) ?? [],
306
+
[postsData]
307
+
);
308
+
309
+
useReusableTabScrollRestore("Notifications");
310
+
311
+
const [filters] = useAtom(postInteractionsFiltersAtom);
312
+
const empty =
313
+
!filters.likes && !filters.quotes && !filters.replies && !filters.reposts;
314
+
315
+
return (
316
+
<>
317
+
<PostInteractionsFilterChipBar />
318
+
{!empty &&
319
+
posts.map((m) => <PostInteractionsItem key={m.uri} uri={m.uri} />)}
320
+
321
+
{hasNextPage && (
322
+
<button
323
+
onClick={() => fetchNextPage()}
324
+
disabled={isFetchingNextPage}
325
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50"
326
+
>
327
+
{isFetchingNextPage ? "Loading..." : "Load More"}
328
+
</button>
329
+
)}
330
+
</>
331
+
);
332
+
}
333
+
334
+
function PostInteractionsFilterChipBar() {
335
+
const [filters, setFilters] = useAtom(postInteractionsFiltersAtom);
336
+
// const empty = (!filters.likes && !filters.quotes && !filters.replies && !filters.reposts);
337
+
338
+
// useEffect(() => {
339
+
// if (empty) {
340
+
// setFilters((prev) => ({
341
+
// ...prev,
342
+
// likes: true,
343
+
// }));
344
+
// }
345
+
// }, [
346
+
// empty,
347
+
// setFilters,
348
+
// ]);
349
+
350
+
const toggle = (key: keyof typeof filters) => {
351
+
setFilters((prev) => ({
352
+
...prev,
353
+
[key]: !prev[key],
354
+
}));
355
+
};
356
+
357
+
return (
358
+
<div className="flex flex-row flex-wrap gap-2 px-4 pt-4">
359
+
<Chip
360
+
state={filters.likes}
361
+
text="Likes"
362
+
onClick={() => toggle("likes")}
363
+
/>
364
+
<Chip
365
+
state={filters.reposts}
366
+
text="Reposts"
367
+
onClick={() => toggle("reposts")}
368
+
/>
369
+
<Chip
370
+
state={filters.replies}
371
+
text="Replies"
372
+
onClick={() => toggle("replies")}
373
+
/>
374
+
<Chip
375
+
state={filters.quotes}
376
+
text="Quotes"
377
+
onClick={() => toggle("quotes")}
378
+
/>
379
+
<Chip
380
+
state={filters.showAll}
381
+
text="Show All Metrics"
382
+
onClick={() => toggle("showAll")}
383
+
/>
384
+
</div>
385
+
);
386
+
}
387
+
388
+
export function Chip({
389
+
state,
390
+
text,
391
+
onClick,
392
+
}: {
393
+
state: boolean;
394
+
text: string;
395
+
onClick: React.MouseEventHandler<HTMLButtonElement>;
396
+
}) {
397
+
return (
398
+
<button
399
+
onClick={onClick}
400
+
className={`relative inline-flex items-center px-3 py-1.5 rounded-lg text-sm font-medium transition-all
401
+
${
402
+
state
403
+
? "bg-primary/20 text-primary bg-gray-200 dark:bg-gray-800 border border-transparent"
404
+
: "bg-surface-container-low text-on-surface-variant border border-outline"
405
+
}
406
+
hover:bg-primary/30 active:scale-[0.97]
407
+
dark:border-outline-variant
408
+
`}
409
+
>
410
+
{state && (
411
+
<IconMdiCheck
412
+
className="mr-1.5 inline-block w-4 h-4 rounded-full bg-primary"
413
+
aria-hidden
414
+
/>
415
+
)}
416
+
{text}
417
+
</button>
418
+
);
419
+
}
420
+
421
+
function PostInteractionsItem({ uri }: { uri: string }) {
422
+
const [filters] = useAtom(postInteractionsFiltersAtom);
423
+
const { data: links } = useQueryConstellation({
424
+
method: "/links/all",
425
+
target: uri,
426
+
});
427
+
428
+
const likes =
429
+
links?.links?.["app.bsky.feed.like"]?.[".subject.uri"]?.records || 0;
430
+
const replies =
431
+
links?.links?.["app.bsky.feed.post"]?.[".reply.parent.uri"]?.records || 0;
432
+
const reposts =
433
+
links?.links?.["app.bsky.feed.repost"]?.[".subject.uri"]?.records || 0;
434
+
const quotes1 =
435
+
links?.links?.["app.bsky.feed.post"]?.[".embed.record.uri"]?.records || 0;
436
+
const quotes2 =
437
+
links?.links?.["app.bsky.feed.post"]?.[".embed.record.record.uri"]
438
+
?.records || 0;
439
+
const quotes = quotes1 + quotes2;
440
+
441
+
const all = likes + replies + reposts + quotes;
442
+
443
+
//const failLikes = filters.likes && likes < 1;
444
+
//const failReposts = filters.reposts && reposts < 1;
445
+
//const failReplies = filters.replies && replies < 1;
446
+
//const failQuotes = filters.quotes && quotes < 1;
447
+
448
+
const showLikes = filters.showAll || filters.likes;
449
+
const showReposts = filters.showAll || filters.reposts;
450
+
const showReplies = filters.showAll || filters.replies;
451
+
const showQuotes = filters.showAll || filters.quotes;
452
+
453
+
//const showNone = !showLikes && !showReposts && !showReplies && !showQuotes;
454
+
455
+
//const fail = failLikes || failReposts || failReplies || failQuotes || showNone;
456
+
457
+
const matchesLikes = filters.likes && likes > 0;
458
+
const matchesReposts = filters.reposts && reposts > 0;
459
+
const matchesReplies = filters.replies && replies > 0;
460
+
const matchesQuotes = filters.quotes && quotes > 0;
461
+
462
+
const matchesAnything =
463
+
// filters.showAll ||
464
+
matchesLikes || matchesReposts || matchesReplies || matchesQuotes;
465
+
466
+
if (!matchesAnything) return null;
467
+
468
+
//if (fail) return;
469
+
470
+
return (
471
+
<div className="flex flex-col">
472
+
{/* <span>fail likes {failLikes ? "true" : "false"}</span>
473
+
<span>fail repost {failReposts ? "true" : "false"}</span>
474
+
<span>fail reply {failReplies ? "true" : "false"}</span>
475
+
<span>fail qupte {failQuotes ? "true" : "false"}</span> */}
476
+
<div className="border rounded-xl mx-4 mt-4 overflow-hidden">
477
+
<UniversalPostRendererATURILoader
478
+
isQuote
479
+
key={uri}
480
+
atUri={uri}
481
+
nopics={true}
482
+
concise={true}
483
+
/>
484
+
<div className="flex flex-col divide-x">
485
+
{showLikes && (
486
+
<InteractionsButton type={"like"} uri={uri} count={likes} />
487
+
)}
488
+
{showReposts && (
489
+
<InteractionsButton type={"repost"} uri={uri} count={reposts} />
490
+
)}
491
+
{showReplies && (
492
+
<InteractionsButton type={"reply"} uri={uri} count={replies} />
493
+
)}
494
+
{showQuotes && (
495
+
<InteractionsButton type={"quote"} uri={uri} count={quotes} />
496
+
)}
497
+
{!all && (
498
+
<div className="text-center text-gray-500 dark:text-gray-400 pb-3 pt-2 border-t">
499
+
No interactions yet.
500
+
</div>
501
+
)}
502
+
</div>
503
+
</div>
504
+
</div>
505
+
);
506
+
}
507
+
508
+
function InteractionsButton({
509
+
type,
510
+
uri,
511
+
count,
512
+
}: {
513
+
type: "reply" | "repost" | "like" | "quote";
514
+
uri: string;
515
+
count: number;
516
+
}) {
517
+
if (!count) return <></>;
518
+
const aturi = new AtUri(uri);
519
+
return (
520
+
<Link
521
+
to={
522
+
`/profile/$did/post/$rkey` +
523
+
(type === "like"
524
+
? "/liked-by"
525
+
: type === "repost"
526
+
? "/reposted-by"
527
+
: type === "quote"
528
+
? "/quotes"
529
+
: "")
530
+
}
531
+
params={{
532
+
did: aturi.host,
533
+
rkey: aturi.rkey,
534
+
}}
535
+
className="flex-1 border-t py-2 px-4 flex flex-row items-center gap-2 transition-colors hover:bg-gray-100 hover:dark:bg-gray-800"
536
+
>
537
+
{type === "like" ? (
538
+
<MdiCardsHeartOutline height={22} width={22} />
539
+
) : type === "repost" ? (
540
+
<MdiRepeat height={22} width={22} />
541
+
) : type === "reply" ? (
542
+
<MdiCommentOutline height={22} width={22} />
543
+
) : type === "quote" ? (
544
+
<IconMdiMessageReplyTextOutline
545
+
height={22}
546
+
width={22}
547
+
className=" text-gray-400"
548
+
/>
549
+
) : (
550
+
<></>
551
+
)}
552
+
{type === "like"
553
+
? "likes"
554
+
: type === "reply"
555
+
? "replies"
556
+
: type === "quote"
557
+
? "quotes"
558
+
: type === "repost"
559
+
? "reposts"
560
+
: ""}
561
+
<div className="flex-1" /> {count}
562
+
</Link>
563
+
);
564
+
}
565
+
566
+
export function NotificationItem({ notification }: { notification: string }) {
567
+
const aturi = new AtUri(notification);
568
+
const bite = aturi.collection === "net.wafrn.feed.bite";
569
+
const navigate = useNavigate();
570
+
const { data: identity } = useQueryIdentity(aturi.host);
571
+
const resolvedDid = identity?.did;
572
+
const profileUri = resolvedDid
573
+
? `at://${resolvedDid}/app.bsky.actor.profile/self`
574
+
: undefined;
575
+
const { data: profileRecord } = useQueryProfile(profileUri);
576
+
const profile = profileRecord?.value;
577
+
578
+
const [imgcdn] = useAtom(imgCDNAtom);
579
+
580
+
function getAvatarUrl(p: typeof profile) {
581
+
const link = p?.avatar?.ref?.["$link"];
582
+
if (!link || !resolvedDid) return null;
583
+
return `https://${imgcdn}/img/avatar/plain/${resolvedDid}/${link}@jpeg`;
584
+
}
585
+
586
+
const avatar = getAvatarUrl(profile);
587
+
588
+
return (
589
+
<div
590
+
className="flex items-center p-4 cursor-pointer gap-3 justify-around border-b flex-row"
591
+
onClick={() =>
592
+
aturi &&
593
+
navigate({
594
+
to: "/profile/$did",
595
+
params: { did: aturi.host },
596
+
})
597
+
}
598
+
>
599
+
{/* <div>
600
+
{aturi.collection === "app.bsky.graph.follow" ? (
601
+
<IconMdiAccountPlus />
602
+
) : aturi.collection === "app.bsky.feed.like" ? (
603
+
<MdiCardsHeart />
604
+
) : (
605
+
<></>
142
606
)}
143
-
</div>
144
-
{error && <div className="p-4 text-red-500">{error}</div>}
145
-
{loading && (
146
-
<div className="p-4 text-gray-500">Loading notifications...</div>
147
-
)}
148
-
{!loading &&
149
-
!error &&
150
-
responses.map((resp, i) => (
151
-
<div key={i} className="p-4">
152
-
<div className="font-bold mb-2">Query {i + 1}</div>
153
-
{!resp ||
154
-
(typeof resp === "object" && Object.keys(resp).length === 0) ||
155
-
(Array.isArray(resp) && resp.length === 0) ? (
156
-
<div className="text-gray-500">No notifications found.</div>
157
-
) : (
158
-
<pre
159
-
style={{
160
-
background: "#222",
161
-
color: "#eee",
162
-
borderRadius: 8,
163
-
padding: 12,
164
-
fontSize: 13,
165
-
overflowX: "auto",
166
-
}}
167
-
>
168
-
{JSON.stringify(resp, null, 2)}
169
-
</pre>
170
-
)}
171
-
</div>
172
-
))}
173
-
{/* <div className="p-4"> yo this project sucks, ill remake it some other time, like cmon inputting anything into the textbox makes it break. ive warned you</div> */}
174
-
</div>
175
-
);
607
+
</div> */}
608
+
{profile ? (
609
+
<img
610
+
src={avatar || defaultpfp}
611
+
alt={identity?.handle}
612
+
className="w-10 h-10 rounded-full"
613
+
/>
614
+
) : (
615
+
<div className="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-700" />
616
+
)}
617
+
<div className="flex flex-col min-w-0 flex-grow">
618
+
<div className="flex flex-row gap-2 items-center overflow-hidden text-ellipsis whitespace-nowrap min-w-0">
619
+
<ConditionalTextPlaceholder
620
+
show={profile != undefined}
621
+
placeholderClassName="flex-grow"
622
+
>
623
+
<span className="font-medium text-gray-900 dark:text-gray-100 truncate">
624
+
{profile?.displayName || identity?.handle}
625
+
</span>
626
+
</ConditionalTextPlaceholder>
627
+
<span className="text-gray-700 dark:text-gray-400 truncate">
628
+
@{identity?.handle}
629
+
</span>
630
+
</div>
631
+
<div className="flex flex-row gap-2">
632
+
{identity?.did && <Mutual targetdidorhandle={identity?.did} />}
633
+
{/* <span className="text-sm text-gray-600 dark:text-gray-400">
634
+
followed you
635
+
</span> */}
636
+
</div>
637
+
</div>
638
+
<div className="flex-1" />
639
+
{identity?.did && <FollowButton targetdidorhandle={identity?.did} />}
640
+
</div>
641
+
);
176
642
}
643
+
644
+
export const EmptyState = ({ text }: { text: string }) => (
645
+
<div className="py-10 text-center text-gray-500 dark:text-gray-400">
646
+
{text}
647
+
</div>
648
+
);
649
+
650
+
export const LoadingState = ({ text }: { text: string }) => (
651
+
<div className="py-10 text-center text-gray-500 dark:text-gray-400 italic">
652
+
{text}
653
+
</div>
654
+
);
655
+
656
+
export const ErrorState = ({ error }: { error: unknown }) => (
657
+
<div className="py-10 text-center text-red-600 dark:text-red-400">
658
+
Error: {(error as Error)?.message || "Something went wrong."}
659
+
</div>
660
+
);
+91
src/routes/profile.$did/feed.$rkey.tsx
+91
src/routes/profile.$did/feed.$rkey.tsx
···
1
+
import * as ATPAPI from "@atproto/api";
2
+
import { AtUri } from "@atproto/api";
3
+
import { createFileRoute } from "@tanstack/react-router";
4
+
import { useAtom } from "jotai";
5
+
6
+
import { Header } from "~/components/Header";
7
+
import { InfiniteCustomFeed } from "~/components/InfiniteCustomFeed";
8
+
import { useAuth } from "~/providers/UnifiedAuthProvider";
9
+
import { quickAuthAtom } from "~/utils/atoms";
10
+
import { useQueryArbitrary, useQueryIdentity } from "~/utils/useQuery";
11
+
12
+
export const Route = createFileRoute("/profile/$did/feed/$rkey")({
13
+
component: FeedRoute,
14
+
});
15
+
16
+
// todo: scroll restoration
17
+
function FeedRoute() {
18
+
const { did, rkey } = Route.useParams();
19
+
const { agent, status } = useAuth();
20
+
const { data: identitydata } = useQueryIdentity(did);
21
+
const { data: identity } = useQueryIdentity(agent?.did);
22
+
const uri = `at://${identitydata?.did || did}/app.bsky.feed.generator/${rkey}`;
23
+
const aturi = new AtUri(uri);
24
+
const { data: feeddata } = useQueryArbitrary(uri);
25
+
26
+
const [quickAuth, setQuickAuth] = useAtom(quickAuthAtom);
27
+
const isAuthRestoring = quickAuth ? status === "loading" : false;
28
+
29
+
const authed = status === "signedIn";
30
+
31
+
const feedServiceDid = !isAuthRestoring
32
+
? ((feeddata?.value as any)?.did as string | undefined)
33
+
: undefined;
34
+
35
+
// const {
36
+
// data: feedData,
37
+
// isLoading: isFeedLoading,
38
+
// error: feedError,
39
+
// } = useQueryFeedSkeleton({
40
+
// feedUri: selectedFeed!,
41
+
// agent: agent ?? undefined,
42
+
// isAuthed: authed ?? false,
43
+
// pdsUrl: identity?.pds,
44
+
// feedServiceDid: feedServiceDid,
45
+
// });
46
+
47
+
// const feed = feedData?.feed || [];
48
+
49
+
const isReadyForAuthedFeed =
50
+
!isAuthRestoring && authed && agent && identity?.pds && feedServiceDid;
51
+
const isReadyForUnauthedFeed = !isAuthRestoring && !authed;
52
+
53
+
const feed: ATPAPI.AppBskyFeedGenerator.Record | undefined = feeddata?.value;
54
+
55
+
const web = feedServiceDid?.replace(/^did:web:/, "") || "";
56
+
57
+
return (
58
+
<>
59
+
<Header
60
+
title={feed?.displayName || aturi.rkey}
61
+
backButtonCallback={() => {
62
+
if (window.history.length > 1) {
63
+
window.history.back();
64
+
} else {
65
+
window.location.assign("/");
66
+
}
67
+
}}
68
+
/>
69
+
70
+
{isAuthRestoring ||
71
+
(authed && (!identity?.pds || !feedServiceDid) && (
72
+
<div className="p-4 text-center text-gray-500">
73
+
Preparing your feed...
74
+
</div>
75
+
))}
76
+
77
+
{!isAuthRestoring && (isReadyForAuthedFeed || isReadyForUnauthedFeed) ? (
78
+
<InfiniteCustomFeed
79
+
key={uri}
80
+
feedUri={uri}
81
+
pdsUrl={identity?.pds}
82
+
feedServiceDid={feedServiceDid}
83
+
authedOverride={!authed && true || undefined}
84
+
unauthedfeedurl={!authed && web || undefined}
85
+
/>
86
+
) : (
87
+
<div className="p-4 text-center text-gray-500">Loading.......</div>
88
+
)}
89
+
</>
90
+
);
91
+
}
+30
src/routes/profile.$did/followers.tsx
+30
src/routes/profile.$did/followers.tsx
···
1
+
import { createFileRoute } from "@tanstack/react-router";
2
+
3
+
import { Header } from "~/components/Header";
4
+
5
+
import { FollowsTab } from "../notifications";
6
+
7
+
export const Route = createFileRoute("/profile/$did/followers")({
8
+
component: RouteComponent,
9
+
});
10
+
11
+
// todo: scroll restoration
12
+
function RouteComponent() {
13
+
const params = Route.useParams();
14
+
15
+
return (
16
+
<div>
17
+
<Header
18
+
title={"Followers"}
19
+
backButtonCallback={() => {
20
+
if (window.history.length > 1) {
21
+
window.history.back();
22
+
} else {
23
+
window.location.assign("/");
24
+
}
25
+
}}
26
+
/>
27
+
<FollowsTab did={params.did} />
28
+
</div>
29
+
);
30
+
}
+79
src/routes/profile.$did/follows.tsx
+79
src/routes/profile.$did/follows.tsx
···
1
+
import * as ATPAPI from "@atproto/api"
2
+
import { createFileRoute } from '@tanstack/react-router'
3
+
import React from 'react';
4
+
5
+
import { Header } from '~/components/Header';
6
+
import { useReusableTabScrollRestore } from '~/components/ReusableTabRoute';
7
+
import { useInfiniteQueryAuthorFeed, useQueryIdentity } from '~/utils/useQuery';
8
+
9
+
import { EmptyState, ErrorState, LoadingState, NotificationItem } from '../notifications';
10
+
11
+
export const Route = createFileRoute('/profile/$did/follows')({
12
+
component: RouteComponent,
13
+
})
14
+
15
+
// todo: scroll restoration
16
+
function RouteComponent() {
17
+
const params = Route.useParams();
18
+
return (
19
+
<div>
20
+
<Header
21
+
title={"Follows"}
22
+
backButtonCallback={() => {
23
+
if (window.history.length > 1) {
24
+
window.history.back();
25
+
} else {
26
+
window.location.assign("/");
27
+
}
28
+
}}
29
+
/>
30
+
<Follows did={params.did}/>
31
+
</div>
32
+
);
33
+
}
34
+
35
+
function Follows({did}:{did:string}) {
36
+
const {data: identity} = useQueryIdentity(did);
37
+
const infinitequeryresults = useInfiniteQueryAuthorFeed(identity?.did, identity?.pds, "app.bsky.graph.follow");
38
+
39
+
const {
40
+
data: infiniteFollowsData,
41
+
fetchNextPage,
42
+
hasNextPage,
43
+
isFetchingNextPage,
44
+
isLoading,
45
+
isError,
46
+
error,
47
+
} = infinitequeryresults;
48
+
49
+
const followsAturis = React.useMemo(
50
+
() => infiniteFollowsData?.pages.flatMap((page) => page.records) ?? [],
51
+
[infiniteFollowsData]
52
+
);
53
+
54
+
useReusableTabScrollRestore("Notifications");
55
+
56
+
if (isLoading) return <LoadingState text="Loading follows..." />;
57
+
if (isError) return <ErrorState error={error} />;
58
+
59
+
if (!followsAturis?.length) return <EmptyState text="No follows yet." />;
60
+
61
+
return (
62
+
<>
63
+
{followsAturis.map((m) => {
64
+
const record = m.value as unknown as ATPAPI.AppBskyGraphFollow.Record;
65
+
return <NotificationItem key={record.subject} notification={record.subject} />
66
+
})}
67
+
68
+
{hasNextPage && (
69
+
<button
70
+
onClick={() => fetchNextPage()}
71
+
disabled={isFetchingNextPage}
72
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50"
73
+
>
74
+
{isFetchingNextPage ? "Loading..." : "Load More"}
75
+
</button>
76
+
)}
77
+
</>
78
+
);
79
+
}
+663
-81
src/routes/profile.$did/index.tsx
+663
-81
src/routes/profile.$did/index.tsx
···
1
-
import { RichText } from "@atproto/api";
1
+
import { Agent, RichText } from "@atproto/api";
2
+
import * as ATPAPI from "@atproto/api";
3
+
import { TID } from "@atproto/common-web";
2
4
import { useQueryClient } from "@tanstack/react-query";
3
-
import { createFileRoute, useNavigate } from "@tanstack/react-router";
5
+
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
4
6
import { useAtom } from "jotai";
5
7
import React, { type ReactNode, useEffect, useState } from "react";
6
8
9
+
import defaultpfp from "~/../public/favicon.png";
7
10
import { Header } from "~/components/Header";
8
-
import { Button } from "~/components/radix-m3-rd/Button";
11
+
import {
12
+
ReusableTabRoute,
13
+
useReusableTabScrollRestore,
14
+
} from "~/components/ReusableTabRoute";
9
15
import {
10
16
renderTextWithFacets,
11
17
UniversalPostRendererATURILoader,
12
18
} from "~/components/UniversalPostRenderer";
13
19
import { useAuth } from "~/providers/UnifiedAuthProvider";
14
-
import { imgCDNAtom } from "~/utils/atoms";
20
+
import { enableBitesAtom, imgCDNAtom, profileChipsAtom } from "~/utils/atoms";
15
21
import {
16
22
toggleFollow,
17
23
useGetFollowState,
18
24
useGetOneToOneState,
19
25
} from "~/utils/followState";
26
+
import { useFastSetLikesFromFeed } from "~/utils/likeMutationQueue";
20
27
import {
21
28
useInfiniteQueryAuthorFeed,
29
+
useQueryArbitrary,
30
+
useQueryConstellation,
31
+
useQueryConstellationLinksCountDistinctDids,
22
32
useQueryIdentity,
23
33
useQueryProfile,
24
34
} from "~/utils/useQuery";
25
35
36
+
import { Chip } from "../notifications";
37
+
26
38
export const Route = createFileRoute("/profile/$did/")({
27
39
component: ProfileComponent,
28
40
});
···
30
42
function ProfileComponent() {
31
43
// booo bad this is not always the did it might be a handle, use identity.did instead
32
44
const { did } = Route.useParams();
45
+
const { agent } = useAuth();
33
46
const navigate = useNavigate();
34
47
const queryClient = useQueryClient();
35
48
const {
···
48
61
const { data: profileRecord } = useQueryProfile(profileUri);
49
62
const profile = profileRecord?.value;
50
63
51
-
const {
52
-
data: postsData,
53
-
fetchNextPage,
54
-
hasNextPage,
55
-
isFetchingNextPage,
56
-
isLoading: arePostsLoading,
57
-
} = useInfiniteQueryAuthorFeed(resolvedDid, pdsUrl);
58
-
59
-
React.useEffect(() => {
60
-
if (postsData) {
61
-
postsData.pages.forEach((page) => {
62
-
page.records.forEach((record) => {
63
-
if (!queryClient.getQueryData(["post", record.uri])) {
64
-
queryClient.setQueryData(["post", record.uri], record);
65
-
}
66
-
});
67
-
});
68
-
}
69
-
}, [postsData, queryClient]);
70
-
71
-
const posts = React.useMemo(
72
-
() => postsData?.pages.flatMap((page) => page.records) ?? [],
73
-
[postsData]
74
-
);
75
-
76
64
const [imgcdn] = useAtom(imgCDNAtom);
77
65
78
66
function getAvatarUrl(p: typeof profile) {
···
91
79
const handle = resolvedHandle ? `@${resolvedHandle}` : resolvedDid || did;
92
80
const description = profile?.description || "";
93
81
94
-
if (isIdentityLoading) {
95
-
return (
96
-
<div className="p-4 text-center text-gray-500">Resolving profile...</div>
97
-
);
98
-
}
82
+
const isReady = !!resolvedDid && !isIdentityLoading && !!profileRecord;
99
83
100
-
if (identityError) {
101
-
return (
102
-
<div className="p-4 text-center text-red-500">
103
-
Error: {identityError.message}
104
-
</div>
105
-
);
106
-
}
84
+
const resultwhateversure = useQueryConstellationLinksCountDistinctDids(resolvedDid ? {
85
+
method: "/links/count/distinct-dids",
86
+
collection: "app.bsky.graph.follow",
87
+
target: resolvedDid,
88
+
path: ".subject"
89
+
} : undefined)
107
90
108
-
if (!resolvedDid) {
109
-
return (
110
-
<div className="p-4 text-center text-gray-500">Profile not found.</div>
111
-
);
112
-
}
91
+
const followercount = resultwhateversure?.data?.total;
113
92
114
93
return (
115
-
<>
94
+
<div className="">
116
95
<Header
117
96
title={`Profile`}
118
97
backButtonCallback={() => {
···
122
101
window.location.assign("/");
123
102
}
124
103
}}
104
+
bottomBorderDisabled={true}
125
105
/>
126
106
{/* <div className="flex gap-2 px-4 py-2 h-[52px] sticky top-0 bg-white dark:bg-gray-950 z-10 border-b border-gray-200 dark:border-gray-700">
127
107
<Link
···
164
144
</div>
165
145
166
146
<div className="absolute right-[16px] top-[170px] flex flex-row gap-2.5">
147
+
<BiteButton targetdidorhandle={did} />
167
148
{/*
168
149
todo: full follow and unfollow backfill (along with partial likes backfill,
169
150
just enough for it to be useful)
···
171
152
also save it persistently
172
153
*/}
173
154
<FollowButton targetdidorhandle={did} />
174
-
<Button className="rounded-full" variant={"secondary"}>
155
+
<button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]">
175
156
... {/* todo: icon */}
176
-
</Button>
157
+
</button>
177
158
</div>
178
159
179
160
{/* Info Card */}
···
183
164
<Mutual targetdidorhandle={did} />
184
165
{handle}
185
166
</div>
167
+
<div className="flex flex-row gap-2 text-md text-gray-500 dark:text-gray-400 mb-2">
168
+
<Link to="/profile/$did/followers" params={{did: did}}>{followercount && (<span className="mr-1 text-gray-900 dark:text-gray-200 font-medium">{followercount}</span>)}Followers</Link>
169
+
-
170
+
<Link to="/profile/$did/follows" params={{did: did}}>Follows</Link>
171
+
</div>
186
172
{description && (
187
173
<div className="text-base leading-relaxed text-gray-800 dark:text-gray-300 mb-5 whitespace-pre-wrap break-words text-[15px]">
188
174
{/* {description} */}
···
192
178
</div>
193
179
</div>
194
180
195
-
{/* Posts Section */}
196
-
<div className="max-w-2xl mx-auto">
197
-
<div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
198
-
Posts
181
+
{/* this should not be rendered until its ready (the top profile layout is stable) */}
182
+
{isReady ? (
183
+
<ReusableTabRoute
184
+
route={`Profile` + did}
185
+
tabs={{
186
+
Posts: <PostsTab did={did} />,
187
+
Reposts: <RepostsTab did={did} />,
188
+
Feeds: <FeedsTab did={did} />,
189
+
Lists: <ListsTab did={did} />,
190
+
...(identity?.did === agent?.did
191
+
? { Likes: <SelfLikesTab did={did} /> }
192
+
: {}),
193
+
}}
194
+
/>
195
+
) : isIdentityLoading ? (
196
+
<div className="p-4 text-center text-gray-500">
197
+
Resolving profile...
199
198
</div>
200
-
<div>
201
-
{posts.map((post) => (
199
+
) : identityError ? (
200
+
<div className="p-4 text-center text-red-500">
201
+
Error: {identityError.message}
202
+
</div>
203
+
) : !resolvedDid ? (
204
+
<div className="p-4 text-center text-gray-500">Profile not found.</div>
205
+
) : (
206
+
<div className="p-4 text-center text-gray-500">
207
+
Loading profile content...
208
+
</div>
209
+
)}
210
+
</div>
211
+
);
212
+
}
213
+
214
+
export type ProfilePostsFilter = {
215
+
posts: boolean,
216
+
replies: boolean,
217
+
mediaOnly: boolean,
218
+
}
219
+
export const defaultProfilePostsFilter: ProfilePostsFilter = {
220
+
posts: true,
221
+
replies: true,
222
+
mediaOnly: false,
223
+
}
224
+
225
+
function ProfilePostsFilterChipBar({filters, toggle}:{filters: ProfilePostsFilter | null, toggle: (key: keyof ProfilePostsFilter) => void}) {
226
+
const empty = (!filters?.replies && !filters?.posts);
227
+
const almostEmpty = (!filters?.replies && filters?.posts);
228
+
229
+
useEffect(() => {
230
+
if (empty) {
231
+
toggle("posts")
232
+
}
233
+
}, [empty, toggle]);
234
+
235
+
return (
236
+
<div className="flex flex-row flex-wrap gap-2 px-4 pt-4">
237
+
<Chip
238
+
state={filters?.posts ?? true}
239
+
text="Posts"
240
+
onClick={() => almostEmpty ? null : toggle("posts")}
241
+
/>
242
+
<Chip
243
+
state={filters?.replies ?? true}
244
+
text="Replies"
245
+
onClick={() => toggle("replies")}
246
+
/>
247
+
<Chip
248
+
state={filters?.mediaOnly ?? false}
249
+
text="Media Only"
250
+
onClick={() => toggle("mediaOnly")}
251
+
/>
252
+
</div>
253
+
);
254
+
}
255
+
256
+
function PostsTab({ did }: { did: string }) {
257
+
// todo: this needs to be a (non-persisted is fine) atom to survive navigation
258
+
const [filterses, setFilterses] = useAtom(profileChipsAtom);
259
+
const filters = filterses?.[did];
260
+
const setFilters = (obj: ProfilePostsFilter) => {
261
+
setFilterses((prev)=>{
262
+
return{
263
+
...prev,
264
+
[did]: obj
265
+
}
266
+
})
267
+
}
268
+
useEffect(()=>{
269
+
if (!filters) {
270
+
setFilters(defaultProfilePostsFilter);
271
+
}
272
+
})
273
+
useReusableTabScrollRestore(`Profile` + did);
274
+
const queryClient = useQueryClient();
275
+
const {
276
+
data: identity,
277
+
isLoading: isIdentityLoading,
278
+
error: identityError,
279
+
} = useQueryIdentity(did);
280
+
281
+
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
282
+
283
+
const {
284
+
data: postsData,
285
+
fetchNextPage,
286
+
hasNextPage,
287
+
isFetchingNextPage,
288
+
isLoading: arePostsLoading,
289
+
} = useInfiniteQueryAuthorFeed(resolvedDid, identity?.pds);
290
+
291
+
React.useEffect(() => {
292
+
if (postsData) {
293
+
postsData.pages.forEach((page) => {
294
+
page.records.forEach((record) => {
295
+
if (!queryClient.getQueryData(["post", record.uri])) {
296
+
queryClient.setQueryData(["post", record.uri], record);
297
+
}
298
+
});
299
+
});
300
+
}
301
+
}, [postsData, queryClient]);
302
+
303
+
const posts = React.useMemo(
304
+
() => postsData?.pages.flatMap((page) => page.records) ?? [],
305
+
[postsData]
306
+
);
307
+
308
+
const toggle = (key: keyof ProfilePostsFilter) => {
309
+
setFilterses(prev => {
310
+
const existing = prev[did] ?? { posts: false, replies: false, mediaOnly: false }; // default
311
+
312
+
return {
313
+
...prev,
314
+
[did]: {
315
+
...existing,
316
+
[key]: !existing[key], // safely negate
317
+
},
318
+
};
319
+
});
320
+
};
321
+
322
+
return (
323
+
<>
324
+
{/* <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
325
+
Posts
326
+
</div> */}
327
+
<ProfilePostsFilterChipBar filters={filters} toggle={toggle} />
328
+
<div>
329
+
{posts.map((post) => (
330
+
<UniversalPostRendererATURILoader
331
+
key={post.uri}
332
+
atUri={post.uri}
333
+
feedviewpost={true}
334
+
filterNoReplies={!filters?.replies}
335
+
filterMustHaveMedia={filters?.mediaOnly}
336
+
filterMustBeReply={!filters?.posts}
337
+
/>
338
+
))}
339
+
</div>
340
+
341
+
{/* Loading and "Load More" states */}
342
+
{arePostsLoading && posts.length === 0 && (
343
+
<div className="p-4 text-center text-gray-500">Loading posts...</div>
344
+
)}
345
+
{isFetchingNextPage && (
346
+
<div className="p-4 text-center text-gray-500">Loading more...</div>
347
+
)}
348
+
{hasNextPage && !isFetchingNextPage && (
349
+
<button
350
+
onClick={() => fetchNextPage()}
351
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold"
352
+
>
353
+
Load More Posts
354
+
</button>
355
+
)}
356
+
{posts.length === 0 && !arePostsLoading && (
357
+
<div className="p-4 text-center text-gray-500">No posts found.</div>
358
+
)}
359
+
</>
360
+
);
361
+
}
362
+
363
+
function RepostsTab({ did }: { did: string }) {
364
+
useReusableTabScrollRestore(`Profile` + did);
365
+
const {
366
+
data: identity,
367
+
isLoading: isIdentityLoading,
368
+
error: identityError,
369
+
} = useQueryIdentity(did);
370
+
371
+
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
372
+
373
+
const {
374
+
data: repostsData,
375
+
fetchNextPage,
376
+
hasNextPage,
377
+
isFetchingNextPage,
378
+
isLoading: arePostsLoading,
379
+
} = useInfiniteQueryAuthorFeed(
380
+
resolvedDid,
381
+
identity?.pds,
382
+
"app.bsky.feed.repost"
383
+
);
384
+
385
+
const reposts = React.useMemo(
386
+
() => repostsData?.pages.flatMap((page) => page.records) ?? [],
387
+
[repostsData]
388
+
);
389
+
390
+
return (
391
+
<>
392
+
<div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
393
+
Reposts
394
+
</div>
395
+
<div>
396
+
{reposts.map((repost) => {
397
+
if (
398
+
!repost ||
399
+
!repost?.value ||
400
+
!repost?.value?.subject ||
401
+
// @ts-expect-error blehhhhh
402
+
!repost?.value?.subject?.uri
403
+
)
404
+
return;
405
+
const repostRecord =
406
+
repost.value as unknown as ATPAPI.AppBskyFeedRepost.Record;
407
+
return (
202
408
<UniversalPostRendererATURILoader
203
-
key={post.uri}
204
-
atUri={post.uri}
409
+
key={repostRecord.subject.uri}
410
+
atUri={repostRecord.subject.uri}
205
411
feedviewpost={true}
412
+
repostedby={repost.uri}
206
413
/>
207
-
))}
414
+
);
415
+
})}
416
+
</div>
417
+
418
+
{/* Loading and "Load More" states */}
419
+
{arePostsLoading && reposts.length === 0 && (
420
+
<div className="p-4 text-center text-gray-500">Loading posts...</div>
421
+
)}
422
+
{isFetchingNextPage && (
423
+
<div className="p-4 text-center text-gray-500">Loading more...</div>
424
+
)}
425
+
{hasNextPage && !isFetchingNextPage && (
426
+
<button
427
+
onClick={() => fetchNextPage()}
428
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold"
429
+
>
430
+
Load More Posts
431
+
</button>
432
+
)}
433
+
{reposts.length === 0 && !arePostsLoading && (
434
+
<div className="p-4 text-center text-gray-500">No posts found.</div>
435
+
)}
436
+
</>
437
+
);
438
+
}
439
+
440
+
function FeedsTab({ did }: { did: string }) {
441
+
useReusableTabScrollRestore(`Profile` + did);
442
+
const {
443
+
data: identity,
444
+
isLoading: isIdentityLoading,
445
+
error: identityError,
446
+
} = useQueryIdentity(did);
447
+
448
+
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
449
+
450
+
const {
451
+
data: feedsData,
452
+
fetchNextPage,
453
+
hasNextPage,
454
+
isFetchingNextPage,
455
+
isLoading: arePostsLoading,
456
+
} = useInfiniteQueryAuthorFeed(
457
+
resolvedDid,
458
+
identity?.pds,
459
+
"app.bsky.feed.generator"
460
+
);
461
+
462
+
const feeds = React.useMemo(
463
+
() => feedsData?.pages.flatMap((page) => page.records) ?? [],
464
+
[feedsData]
465
+
);
466
+
467
+
return (
468
+
<>
469
+
<div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
470
+
Feeds
471
+
</div>
472
+
<div>
473
+
{feeds.map((feed) => {
474
+
if (!feed || !feed?.value) return;
475
+
const feedGenRecord =
476
+
feed.value as unknown as ATPAPI.AppBskyFeedGenerator.Record;
477
+
return <FeedItemRender feed={feed as any} key={feed.uri} />;
478
+
})}
479
+
</div>
480
+
481
+
{/* Loading and "Load More" states */}
482
+
{arePostsLoading && feeds.length === 0 && (
483
+
<div className="p-4 text-center text-gray-500">Loading feeds...</div>
484
+
)}
485
+
{isFetchingNextPage && (
486
+
<div className="p-4 text-center text-gray-500">Loading more...</div>
487
+
)}
488
+
{hasNextPage && !isFetchingNextPage && (
489
+
<button
490
+
onClick={() => fetchNextPage()}
491
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold"
492
+
>
493
+
Load More Feeds
494
+
</button>
495
+
)}
496
+
{feeds.length === 0 && !arePostsLoading && (
497
+
<div className="p-4 text-center text-gray-500">No feeds found.</div>
498
+
)}
499
+
</>
500
+
);
501
+
}
502
+
503
+
export function FeedItemRenderAturiLoader({
504
+
aturi,
505
+
listmode,
506
+
disableBottomBorder,
507
+
disablePropagation,
508
+
}: {
509
+
aturi: string;
510
+
listmode?: boolean;
511
+
disableBottomBorder?: boolean;
512
+
disablePropagation?: boolean;
513
+
}) {
514
+
const { data: record } = useQueryArbitrary(aturi);
515
+
516
+
if (!record) return;
517
+
return (
518
+
<FeedItemRender
519
+
listmode={listmode}
520
+
feed={record}
521
+
disableBottomBorder={disableBottomBorder}
522
+
disablePropagation={disablePropagation}
523
+
/>
524
+
);
525
+
}
526
+
527
+
export function FeedItemRender({
528
+
feed,
529
+
listmode,
530
+
disableBottomBorder,
531
+
disablePropagation,
532
+
}: {
533
+
feed: { uri: string; cid: string; value: any };
534
+
listmode?: boolean;
535
+
disableBottomBorder?: boolean;
536
+
disablePropagation?: boolean;
537
+
}) {
538
+
const name = listmode
539
+
? (feed.value?.name as string)
540
+
: (feed.value?.displayName as string);
541
+
const aturi = new ATPAPI.AtUri(feed.uri);
542
+
const { data: identity } = useQueryIdentity(aturi.host);
543
+
const resolvedDid = identity?.did;
544
+
const [imgcdn] = useAtom(imgCDNAtom);
545
+
546
+
function getAvatarThumbnailUrl(f: typeof feed) {
547
+
const link = f?.value.avatar?.ref?.["$link"];
548
+
if (!link || !resolvedDid) return null;
549
+
return `https://${imgcdn}/img/avatar/plain/${resolvedDid}/${link}@jpeg`;
550
+
}
551
+
552
+
const { data: likes } = useQueryConstellation(
553
+
// @ts-expect-error overloads sucks
554
+
!listmode
555
+
? {
556
+
target: feed.uri,
557
+
method: "/links/count",
558
+
collection: "app.bsky.feed.like",
559
+
path: ".subject.uri",
560
+
}
561
+
: undefined
562
+
);
563
+
564
+
return (
565
+
<Link
566
+
className={`px-4 py-4 ${!disableBottomBorder && "border-b"} flex flex-col gap-1`}
567
+
to="/profile/$did/feed/$rkey"
568
+
params={{ did: aturi.host, rkey: aturi.rkey }}
569
+
onClick={(e) => {
570
+
e.stopPropagation();
571
+
}}
572
+
>
573
+
<div className="flex flex-row gap-3">
574
+
<div className="min-w-10 min-h-10">
575
+
<img
576
+
src={getAvatarThumbnailUrl(feed) || defaultpfp}
577
+
className="h-10 w-10 rounded border"
578
+
/>
208
579
</div>
580
+
<div className="flex flex-col">
581
+
<span className="">{name}</span>
582
+
<span className=" text-sm px-1.5 py-0.5 text-gray-500 bg-gray-200 dark:text-gray-400 dark:bg-gray-800 rounded-lg flex flex-row items-center justify-center">
583
+
{feed.value.did || aturi.rkey}
584
+
</span>
585
+
</div>
586
+
<div className="flex-1" />
587
+
{/* <div className="button bg-red-500 rounded-full min-w-[60px]" /> */}
588
+
</div>
589
+
<span className=" text-sm">{feed.value?.description}</span>
590
+
{!listmode && (
591
+
<span className=" text-sm dark:text-gray-400 text-gray-500">
592
+
Liked by {((likes as unknown as any)?.total as number) || 0} users
593
+
</span>
594
+
)}
595
+
</Link>
596
+
);
597
+
}
209
598
210
-
{/* Loading and "Load More" states */}
211
-
{arePostsLoading && posts.length === 0 && (
212
-
<div className="p-4 text-center text-gray-500">Loading posts...</div>
213
-
)}
214
-
{isFetchingNextPage && (
215
-
<div className="p-4 text-center text-gray-500">Loading more...</div>
216
-
)}
217
-
{hasNextPage && !isFetchingNextPage && (
218
-
<button
219
-
onClick={() => fetchNextPage()}
220
-
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold"
221
-
>
222
-
Load More Posts
223
-
</button>
224
-
)}
225
-
{posts.length === 0 && !arePostsLoading && (
226
-
<div className="p-4 text-center text-gray-500">No posts found.</div>
227
-
)}
599
+
function ListsTab({ did }: { did: string }) {
600
+
useReusableTabScrollRestore(`Profile` + did);
601
+
const {
602
+
data: identity,
603
+
isLoading: isIdentityLoading,
604
+
error: identityError,
605
+
} = useQueryIdentity(did);
606
+
607
+
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
608
+
609
+
const {
610
+
data: feedsData,
611
+
fetchNextPage,
612
+
hasNextPage,
613
+
isFetchingNextPage,
614
+
isLoading: arePostsLoading,
615
+
} = useInfiniteQueryAuthorFeed(
616
+
resolvedDid,
617
+
identity?.pds,
618
+
"app.bsky.graph.list"
619
+
);
620
+
621
+
const feeds = React.useMemo(
622
+
() => feedsData?.pages.flatMap((page) => page.records) ?? [],
623
+
[feedsData]
624
+
);
625
+
626
+
return (
627
+
<>
628
+
<div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
629
+
Feeds
228
630
</div>
631
+
<div>
632
+
{feeds.map((feed) => {
633
+
if (!feed || !feed?.value) return;
634
+
const feedGenRecord =
635
+
feed.value as unknown as ATPAPI.AppBskyFeedGenerator.Record;
636
+
return (
637
+
<FeedItemRender listmode={true} feed={feed as any} key={feed.uri} />
638
+
);
639
+
})}
640
+
</div>
641
+
642
+
{/* Loading and "Load More" states */}
643
+
{arePostsLoading && feeds.length === 0 && (
644
+
<div className="p-4 text-center text-gray-500">Loading lists...</div>
645
+
)}
646
+
{isFetchingNextPage && (
647
+
<div className="p-4 text-center text-gray-500">Loading more...</div>
648
+
)}
649
+
{hasNextPage && !isFetchingNextPage && (
650
+
<button
651
+
onClick={() => fetchNextPage()}
652
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold"
653
+
>
654
+
Load More Lists
655
+
</button>
656
+
)}
657
+
{feeds.length === 0 && !arePostsLoading && (
658
+
<div className="p-4 text-center text-gray-500">No lists found.</div>
659
+
)}
660
+
</>
661
+
);
662
+
}
663
+
664
+
function SelfLikesTab({ did }: { did: string }) {
665
+
useReusableTabScrollRestore(`Profile` + did);
666
+
const {
667
+
data: identity,
668
+
isLoading: isIdentityLoading,
669
+
error: identityError,
670
+
} = useQueryIdentity(did);
671
+
672
+
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
673
+
674
+
const {
675
+
data: likesData,
676
+
fetchNextPage,
677
+
hasNextPage,
678
+
isFetchingNextPage,
679
+
isLoading: arePostsLoading,
680
+
} = useInfiniteQueryAuthorFeed(
681
+
resolvedDid,
682
+
identity?.pds,
683
+
"app.bsky.feed.like"
684
+
);
685
+
686
+
const likes = React.useMemo(
687
+
() => likesData?.pages.flatMap((page) => page.records) ?? [],
688
+
[likesData]
689
+
);
690
+
691
+
const { setFastState } = useFastSetLikesFromFeed();
692
+
const seededRef = React.useRef(new Set<string>());
693
+
694
+
useEffect(() => {
695
+
for (const like of likes) {
696
+
if (!seededRef.current.has(like.uri)) {
697
+
seededRef.current.add(like.uri);
698
+
const record = like.value as unknown as ATPAPI.AppBskyFeedLike.Record;
699
+
setFastState(record.subject.uri, {
700
+
target: record.subject.uri,
701
+
uri: like.uri,
702
+
cid: like.cid,
703
+
});
704
+
}
705
+
}
706
+
}, [likes, setFastState]);
707
+
708
+
return (
709
+
<>
710
+
<div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
711
+
Likes
712
+
</div>
713
+
<div>
714
+
{likes.map((like) => {
715
+
if (
716
+
!like ||
717
+
!like?.value ||
718
+
!like?.value?.subject ||
719
+
// @ts-expect-error blehhhhh
720
+
!like?.value?.subject?.uri
721
+
)
722
+
return;
723
+
const likeRecord =
724
+
like.value as unknown as ATPAPI.AppBskyFeedLike.Record;
725
+
return (
726
+
<UniversalPostRendererATURILoader
727
+
key={likeRecord.subject.uri}
728
+
atUri={likeRecord.subject.uri}
729
+
feedviewpost={true}
730
+
/>
731
+
);
732
+
})}
733
+
</div>
734
+
735
+
{/* Loading and "Load More" states */}
736
+
{arePostsLoading && likes.length === 0 && (
737
+
<div className="p-4 text-center text-gray-500">Loading likes...</div>
738
+
)}
739
+
{isFetchingNextPage && (
740
+
<div className="p-4 text-center text-gray-500">Loading more...</div>
741
+
)}
742
+
{hasNextPage && !isFetchingNextPage && (
743
+
<button
744
+
onClick={() => fetchNextPage()}
745
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold"
746
+
>
747
+
Load More Likes
748
+
</button>
749
+
)}
750
+
{likes.length === 0 && !arePostsLoading && (
751
+
<div className="p-4 text-center text-gray-500">No likes found.</div>
752
+
)}
229
753
</>
230
754
);
231
755
}
···
249
773
{identity?.did !== agent?.did ? (
250
774
<>
251
775
{!(followRecords?.length && followRecords?.length > 0) ? (
252
-
<Button
776
+
<button
253
777
onClick={(e) => {
254
778
e.stopPropagation();
255
779
toggleFollow({
···
259
783
queryClient: queryClient,
260
784
});
261
785
}}
786
+
className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]"
262
787
>
263
788
Follow
264
-
</Button>
789
+
</button>
265
790
) : (
266
-
<Button
791
+
<button
267
792
onClick={(e) => {
268
793
e.stopPropagation();
269
794
toggleFollow({
···
273
798
queryClient: queryClient,
274
799
});
275
800
}}
801
+
className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]"
276
802
>
277
803
Unfollow
278
-
</Button>
804
+
</button>
279
805
)}
280
806
</>
281
807
) : (
282
-
<Button variant={"secondary"}>Edit Profile</Button>
808
+
<button className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]">
809
+
Edit Profile
810
+
</button>
283
811
)}
284
812
</>
285
813
);
286
814
}
815
+
816
+
export function BiteButton({
817
+
targetdidorhandle,
818
+
}: {
819
+
targetdidorhandle: string;
820
+
}) {
821
+
const { agent } = useAuth();
822
+
const { data: identity } = useQueryIdentity(targetdidorhandle);
823
+
const [show] = useAtom(enableBitesAtom);
824
+
825
+
if (!show) return
826
+
827
+
return (
828
+
<>
829
+
<button
830
+
onClick={(e) => {
831
+
e.stopPropagation();
832
+
sendBite({
833
+
agent: agent || undefined,
834
+
targetDid: identity?.did,
835
+
});
836
+
}}
837
+
className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]"
838
+
>
839
+
Bite
840
+
</button>
841
+
</>
842
+
);
843
+
}
844
+
845
+
function sendBite({
846
+
agent,
847
+
targetDid,
848
+
}: {
849
+
agent?: Agent;
850
+
targetDid?: string;
851
+
}) {
852
+
if (!agent?.did || !targetDid) return;
853
+
const newRecord = {
854
+
repo: agent.did,
855
+
collection: "net.wafrn.feed.bite",
856
+
rkey: TID.next().toString(),
857
+
record: {
858
+
$type: "net.wafrn.feed.bite",
859
+
subject: "at://"+targetDid,
860
+
createdAt: new Date().toISOString(),
861
+
},
862
+
};
863
+
864
+
agent.com.atproto.repo.createRecord(newRecord).catch((err) => {
865
+
console.error("Bite failed:", err);
866
+
});
867
+
}
868
+
287
869
288
870
export function Mutual({ targetdidorhandle }: { targetdidorhandle: string }) {
289
871
const { agent } = useAuth();
+100
src/routes/profile.$did/post.$rkey.liked-by.tsx
+100
src/routes/profile.$did/post.$rkey.liked-by.tsx
···
1
+
import { useInfiniteQuery } from "@tanstack/react-query";
2
+
import { createFileRoute } from "@tanstack/react-router";
3
+
import { useAtom } from "jotai";
4
+
import React from "react";
5
+
6
+
import { Header } from "~/components/Header";
7
+
import { constellationURLAtom } from "~/utils/atoms";
8
+
import { useQueryIdentity, yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks } from "~/utils/useQuery";
9
+
10
+
import {
11
+
EmptyState,
12
+
ErrorState,
13
+
LoadingState,
14
+
NotificationItem,
15
+
} from "../notifications";
16
+
17
+
export const Route = createFileRoute("/profile/$did/post/$rkey/liked-by")({
18
+
component: RouteComponent,
19
+
});
20
+
21
+
function RouteComponent() {
22
+
const { did, rkey } = Route.useParams();
23
+
const { data: identity } = useQueryIdentity(did);
24
+
const atUri = identity?.did && rkey ? `at://${decodeURIComponent(identity.did)}/app.bsky.feed.post/${rkey}` : '';
25
+
26
+
const [constellationurl] = useAtom(constellationURLAtom);
27
+
const infinitequeryresults = useInfiniteQuery({
28
+
...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(
29
+
{
30
+
constellation: constellationurl,
31
+
method: "/links",
32
+
target: atUri,
33
+
collection: "app.bsky.feed.like",
34
+
path: ".subject.uri",
35
+
}
36
+
),
37
+
enabled: !!atUri,
38
+
});
39
+
40
+
const {
41
+
data: infiniteLikesData,
42
+
fetchNextPage,
43
+
hasNextPage,
44
+
isFetchingNextPage,
45
+
isLoading,
46
+
isError,
47
+
error,
48
+
} = infinitequeryresults;
49
+
50
+
const likesAturis = React.useMemo(() => {
51
+
// Get all replies from the standard infinite query
52
+
return (
53
+
infiniteLikesData?.pages.flatMap(
54
+
(page) =>
55
+
page?.linking_records.map(
56
+
(r) => `at://${r.did}/${r.collection}/${r.rkey}`
57
+
) ?? []
58
+
) ?? []
59
+
);
60
+
}, [infiniteLikesData]);
61
+
62
+
return (
63
+
<>
64
+
<Header
65
+
title={`Liked By`}
66
+
backButtonCallback={() => {
67
+
if (window.history.length > 1) {
68
+
window.history.back();
69
+
} else {
70
+
window.location.assign("/");
71
+
}
72
+
}}
73
+
/>
74
+
75
+
<>
76
+
{(() => {
77
+
if (isLoading) return <LoadingState text="Loading likes..." />;
78
+
if (isError) return <ErrorState error={error} />;
79
+
80
+
if (!likesAturis?.length)
81
+
return <EmptyState text="No likes yet." />;
82
+
})()}
83
+
</>
84
+
85
+
{likesAturis.map((m) => (
86
+
<NotificationItem key={m} notification={m} />
87
+
))}
88
+
89
+
{hasNextPage && (
90
+
<button
91
+
onClick={() => fetchNextPage()}
92
+
disabled={isFetchingNextPage}
93
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50"
94
+
>
95
+
{isFetchingNextPage ? "Loading..." : "Load More"}
96
+
</button>
97
+
)}
98
+
</>
99
+
);
100
+
}
+141
src/routes/profile.$did/post.$rkey.quotes.tsx
+141
src/routes/profile.$did/post.$rkey.quotes.tsx
···
1
+
import { useInfiniteQuery } from "@tanstack/react-query";
2
+
import { createFileRoute } from "@tanstack/react-router";
3
+
import { useAtom } from "jotai";
4
+
import React from "react";
5
+
6
+
import { Header } from "~/components/Header";
7
+
import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer";
8
+
import { constellationURLAtom } from "~/utils/atoms";
9
+
import { type linksRecord,useQueryIdentity, yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks } from "~/utils/useQuery";
10
+
11
+
import {
12
+
EmptyState,
13
+
ErrorState,
14
+
LoadingState,
15
+
} from "../notifications";
16
+
17
+
export const Route = createFileRoute("/profile/$did/post/$rkey/quotes")({
18
+
component: RouteComponent,
19
+
});
20
+
21
+
function RouteComponent() {
22
+
const { did, rkey } = Route.useParams();
23
+
const { data: identity } = useQueryIdentity(did);
24
+
const atUri = identity?.did && rkey ? `at://${decodeURIComponent(identity.did)}/app.bsky.feed.post/${rkey}` : '';
25
+
26
+
const [constellationurl] = useAtom(constellationURLAtom);
27
+
const infinitequeryresultsWithoutMedia = useInfiniteQuery({
28
+
...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(
29
+
{
30
+
constellation: constellationurl,
31
+
method: "/links",
32
+
target: atUri,
33
+
collection: "app.bsky.feed.post",
34
+
path: ".embed.record.uri", // embed.record.record.uri and embed.record.uri
35
+
}
36
+
),
37
+
enabled: !!atUri,
38
+
});
39
+
const infinitequeryresultsWithMedia = useInfiniteQuery({
40
+
...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(
41
+
{
42
+
constellation: constellationurl,
43
+
method: "/links",
44
+
target: atUri,
45
+
collection: "app.bsky.feed.post",
46
+
path: ".embed.record.record.uri", // embed.record.record.uri and embed.record.uri
47
+
}
48
+
),
49
+
enabled: !!atUri,
50
+
});
51
+
52
+
const {
53
+
data: infiniteQuotesDataWithoutMedia,
54
+
fetchNextPage: fetchNextPageWithoutMedia,
55
+
hasNextPage: hasNextPageWithoutMedia,
56
+
isFetchingNextPage: isFetchingNextPageWithoutMedia,
57
+
isLoading: isLoadingWithoutMedia,
58
+
isError: isErrorWithoutMedia,
59
+
error: errorWithoutMedia,
60
+
} = infinitequeryresultsWithoutMedia;
61
+
const {
62
+
data: infiniteQuotesDataWithMedia,
63
+
fetchNextPage: fetchNextPageWithMedia,
64
+
hasNextPage: hasNextPageWithMedia,
65
+
isFetchingNextPage: isFetchingNextPageWithMedia,
66
+
isLoading: isLoadingWithMedia,
67
+
isError: isErrorWithMedia,
68
+
error: errorWithMedia,
69
+
} = infinitequeryresultsWithMedia;
70
+
71
+
const fetchNextPage = async () => {
72
+
await Promise.all([
73
+
hasNextPageWithMedia && fetchNextPageWithMedia(),
74
+
hasNextPageWithoutMedia && fetchNextPageWithoutMedia(),
75
+
]);
76
+
};
77
+
78
+
const hasNextPage = hasNextPageWithMedia || hasNextPageWithoutMedia;
79
+
const isFetchingNextPage = isFetchingNextPageWithMedia || isFetchingNextPageWithoutMedia;
80
+
const isLoading = isLoadingWithMedia || isLoadingWithoutMedia;
81
+
82
+
const allQuotes = React.useMemo(() => {
83
+
const withPages = infiniteQuotesDataWithMedia?.pages ?? [];
84
+
const withoutPages = infiniteQuotesDataWithoutMedia?.pages ?? [];
85
+
const maxLen = Math.max(withPages.length, withoutPages.length);
86
+
const merged: linksRecord[] = [];
87
+
88
+
for (let i = 0; i < maxLen; i++) {
89
+
const a = withPages[i]?.linking_records ?? [];
90
+
const b = withoutPages[i]?.linking_records ?? [];
91
+
const mergedPage = [...a, ...b].sort((b, a) => a.rkey.localeCompare(b.rkey));
92
+
merged.push(...mergedPage);
93
+
}
94
+
95
+
return merged;
96
+
}, [infiniteQuotesDataWithMedia?.pages, infiniteQuotesDataWithoutMedia?.pages]);
97
+
98
+
const quotesAturis = React.useMemo(() => {
99
+
return allQuotes.flatMap((r) => `at://${r.did}/${r.collection}/${r.rkey}`);
100
+
}, [allQuotes]);
101
+
102
+
return (
103
+
<>
104
+
<Header
105
+
title={`Quotes`}
106
+
backButtonCallback={() => {
107
+
if (window.history.length > 1) {
108
+
window.history.back();
109
+
} else {
110
+
window.location.assign("/");
111
+
}
112
+
}}
113
+
/>
114
+
115
+
<>
116
+
{(() => {
117
+
if (isLoading) return <LoadingState text="Loading quotes..." />;
118
+
if (isErrorWithMedia) return <ErrorState error={errorWithMedia} />;
119
+
if (isErrorWithoutMedia) return <ErrorState error={errorWithoutMedia} />;
120
+
121
+
if (!quotesAturis?.length)
122
+
return <EmptyState text="No quotes yet." />;
123
+
})()}
124
+
</>
125
+
126
+
{quotesAturis.map((m) => (
127
+
<UniversalPostRendererATURILoader key={m} atUri={m} />
128
+
))}
129
+
130
+
{hasNextPage && (
131
+
<button
132
+
onClick={() => fetchNextPage()}
133
+
disabled={isFetchingNextPage}
134
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50"
135
+
>
136
+
{isFetchingNextPage ? "Loading..." : "Load More"}
137
+
</button>
138
+
)}
139
+
</>
140
+
);
141
+
}
+100
src/routes/profile.$did/post.$rkey.reposted-by.tsx
+100
src/routes/profile.$did/post.$rkey.reposted-by.tsx
···
1
+
import { useInfiniteQuery } from "@tanstack/react-query";
2
+
import { createFileRoute } from "@tanstack/react-router";
3
+
import { useAtom } from "jotai";
4
+
import React from "react";
5
+
6
+
import { Header } from "~/components/Header";
7
+
import { constellationURLAtom } from "~/utils/atoms";
8
+
import { useQueryIdentity, yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks } from "~/utils/useQuery";
9
+
10
+
import {
11
+
EmptyState,
12
+
ErrorState,
13
+
LoadingState,
14
+
NotificationItem,
15
+
} from "../notifications";
16
+
17
+
export const Route = createFileRoute("/profile/$did/post/$rkey/reposted-by")({
18
+
component: RouteComponent,
19
+
});
20
+
21
+
function RouteComponent() {
22
+
const { did, rkey } = Route.useParams();
23
+
const { data: identity } = useQueryIdentity(did);
24
+
const atUri = identity?.did && rkey ? `at://${decodeURIComponent(identity.did)}/app.bsky.feed.post/${rkey}` : '';
25
+
26
+
const [constellationurl] = useAtom(constellationURLAtom);
27
+
const infinitequeryresults = useInfiniteQuery({
28
+
...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(
29
+
{
30
+
constellation: constellationurl,
31
+
method: "/links",
32
+
target: atUri,
33
+
collection: "app.bsky.feed.repost",
34
+
path: ".subject.uri",
35
+
}
36
+
),
37
+
enabled: !!atUri,
38
+
});
39
+
40
+
const {
41
+
data: infiniteRepostsData,
42
+
fetchNextPage,
43
+
hasNextPage,
44
+
isFetchingNextPage,
45
+
isLoading,
46
+
isError,
47
+
error,
48
+
} = infinitequeryresults;
49
+
50
+
const repostsAturis = React.useMemo(() => {
51
+
// Get all replies from the standard infinite query
52
+
return (
53
+
infiniteRepostsData?.pages.flatMap(
54
+
(page) =>
55
+
page?.linking_records.map(
56
+
(r) => `at://${r.did}/${r.collection}/${r.rkey}`
57
+
) ?? []
58
+
) ?? []
59
+
);
60
+
}, [infiniteRepostsData]);
61
+
62
+
return (
63
+
<>
64
+
<Header
65
+
title={`Reposted By`}
66
+
backButtonCallback={() => {
67
+
if (window.history.length > 1) {
68
+
window.history.back();
69
+
} else {
70
+
window.location.assign("/");
71
+
}
72
+
}}
73
+
/>
74
+
75
+
<>
76
+
{(() => {
77
+
if (isLoading) return <LoadingState text="Loading reposts..." />;
78
+
if (isError) return <ErrorState error={error} />;
79
+
80
+
if (!repostsAturis?.length)
81
+
return <EmptyState text="No reposts yet." />;
82
+
})()}
83
+
</>
84
+
85
+
{repostsAturis.map((m) => (
86
+
<NotificationItem key={m} notification={m} />
87
+
))}
88
+
89
+
{hasNextPage && (
90
+
<button
91
+
onClick={() => fetchNextPage()}
92
+
disabled={isFetchingNextPage}
93
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50"
94
+
>
95
+
{isFetchingNextPage ? "Loading..." : "Load More"}
96
+
</button>
97
+
)}
98
+
</>
99
+
);
100
+
}
+98
-92
src/routes/profile.$did/post.$rkey.tsx
+98
-92
src/routes/profile.$did/post.$rkey.tsx
···
1
1
import { AtUri } from "@atproto/api";
2
2
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
3
-
import { createFileRoute, Outlet } from "@tanstack/react-router";
3
+
import { createFileRoute, Outlet, useMatchRoute } from "@tanstack/react-router";
4
4
import { useAtom } from "jotai";
5
5
import React, { useLayoutEffect } from "react";
6
6
···
52
52
nopics?: boolean;
53
53
lightboxCallback?: (d: LightboxProps) => void;
54
54
}) {
55
+
const matchRoute = useMatchRoute()
56
+
const showMainPostRoute = !!matchRoute({ to: '/profile/$did/post/$rkey' }) || !!matchRoute({ to: '/profile/$did/post/$rkey/image/$i' })
57
+
55
58
//const { get, set } = usePersistentStore();
56
59
const queryClient = useQueryClient();
57
60
// const [resolvedDid, setResolvedDid] = React.useState<string | null>(null);
···
190
193
data: identity,
191
194
isLoading: isIdentityLoading,
192
195
error: identityError,
193
-
} = useQueryIdentity(did);
196
+
} = useQueryIdentity(showMainPostRoute ? did : undefined);
194
197
195
198
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
196
199
197
200
const atUri = React.useMemo(
198
201
() =>
199
-
resolvedDid
202
+
resolvedDid && showMainPostRoute
200
203
? `at://${decodeURIComponent(resolvedDid)}/app.bsky.feed.post/${rkey}`
201
204
: undefined,
202
-
[resolvedDid, rkey]
205
+
[resolvedDid, rkey, showMainPostRoute]
203
206
);
204
207
205
-
const { data: mainPost } = useQueryPost(atUri);
208
+
const { data: mainPost } = useQueryPost(showMainPostRoute ? atUri : undefined);
206
209
207
210
console.log("atUri",atUri)
208
211
···
215
218
);
216
219
217
220
// @ts-expect-error i hate overloads
218
-
const { data: links } = useQueryConstellation(atUri?{
221
+
const { data: links } = useQueryConstellation(atUri&&showMainPostRoute?{
219
222
method: "/links/all",
220
223
target: atUri,
221
224
} : {
···
248
251
}, [links]);
249
252
250
253
const { data: opreplies } = useQueryConstellation(
251
-
!!opdid && replyCount && replyCount >= 25
254
+
showMainPostRoute && !!opdid && replyCount && replyCount >= 25
252
255
? {
253
256
method: "/links",
254
257
target: atUri,
···
289
292
path: ".reply.parent.uri",
290
293
}
291
294
),
292
-
enabled: !!atUri,
295
+
enabled: !!atUri && showMainPostRoute,
293
296
});
294
297
295
298
const {
···
371
374
const [layoutReady, setLayoutReady] = React.useState(false);
372
375
373
376
useLayoutEffect(() => {
377
+
if (!showMainPostRoute) return
374
378
if (parents.length > 0 && !layoutReady && mainPostRef.current) {
375
379
const mainPostElement = mainPostRef.current;
376
380
···
389
393
// eslint-disable-next-line react-hooks/set-state-in-effect
390
394
setLayoutReady(true);
391
395
}
392
-
}, [parents, layoutReady]);
396
+
}, [parents, layoutReady, showMainPostRoute]);
393
397
394
398
395
399
const [slingshoturl] = useAtom(slingshotURLAtom)
396
400
397
401
React.useEffect(() => {
398
-
if (parentsLoading) {
402
+
if (parentsLoading || !showMainPostRoute) {
399
403
setLayoutReady(false);
400
404
}
401
405
···
403
407
setLayoutReady(true);
404
408
hasPerformedInitialLayout.current = true;
405
409
}
406
-
}, [parentsLoading, mainPost]);
410
+
}, [parentsLoading, mainPost, showMainPostRoute]);
407
411
408
412
React.useEffect(() => {
409
413
if (!mainPost?.value?.reply?.parent?.uri) {
···
444
448
return () => {
445
449
ignore = true;
446
450
};
447
-
}, [mainPost, queryClient]);
451
+
}, [mainPost, queryClient, slingshoturl]);
448
452
449
-
if (!did || !rkey) return <div>Invalid post URI</div>;
450
-
if (isIdentityLoading) return <div>Resolving handle...</div>;
451
-
if (identityError)
453
+
if ((!did || !rkey) && showMainPostRoute) return <div>Invalid post URI</div>;
454
+
if (isIdentityLoading && showMainPostRoute) return <div>Resolving handle...</div>;
455
+
if (identityError && showMainPostRoute)
452
456
return <div style={{ color: "red" }}>{identityError.message}</div>;
453
-
if (!atUri) return <div>Could not construct post URI.</div>;
457
+
if (!atUri && showMainPostRoute) return <div>Could not construct post URI.</div>;
454
458
455
459
return (
456
460
<>
457
461
<Outlet />
458
-
<Header
459
-
title={`Post`}
460
-
backButtonCallback={() => {
461
-
if (window.history.length > 1) {
462
-
window.history.back();
463
-
} else {
464
-
window.location.assign("/");
465
-
}
466
-
}}
467
-
/>
462
+
{showMainPostRoute && (<>
463
+
<Header
464
+
title={`Post`}
465
+
backButtonCallback={() => {
466
+
if (window.history.length > 1) {
467
+
window.history.back();
468
+
} else {
469
+
window.location.assign("/");
470
+
}
471
+
}}
472
+
/>
468
473
469
-
{parentsLoading && (
470
-
<div className="text-center text-gray-500 dark:text-gray-400 flex flex-row">
471
-
<div className="ml-4 w-[42px] flex justify-center">
472
-
<div
473
-
style={{ width: 2, height: "100%", opacity: 0.5 }}
474
-
className="bg-gray-500 dark:bg-gray-400"
475
-
></div>
474
+
{parentsLoading && (
475
+
<div className="text-center text-gray-500 dark:text-gray-400 flex flex-row">
476
+
<div className="ml-4 w-[42px] flex justify-center">
477
+
<div
478
+
style={{ width: 2, height: "100%", opacity: 0.5 }}
479
+
className="bg-gray-500 dark:bg-gray-400"
480
+
></div>
481
+
</div>
482
+
Loading conversation...
476
483
</div>
477
-
Loading conversation...
484
+
)}
485
+
486
+
{/* we should use the reply lines here thats provided by UPR*/}
487
+
<div style={{ maxWidth: 600, padding: 0 }}>
488
+
{parents.map((parent, index) => (
489
+
<UniversalPostRendererATURILoader
490
+
key={parent.uri}
491
+
atUri={parent.uri}
492
+
topReplyLine={index > 0}
493
+
bottomReplyLine={true}
494
+
bottomBorder={false}
495
+
/>
496
+
))}
478
497
</div>
479
-
)}
480
-
481
-
{/* we should use the reply lines here thats provided by UPR*/}
482
-
<div style={{ maxWidth: 600, padding: 0 }}>
483
-
{parents.map((parent, index) => (
498
+
<div ref={mainPostRef}>
484
499
<UniversalPostRendererATURILoader
485
-
key={parent.uri}
486
-
atUri={parent.uri}
487
-
topReplyLine={index > 0}
488
-
bottomReplyLine={true}
489
-
bottomBorder={false}
500
+
atUri={atUri!}
501
+
detailed={true}
502
+
topReplyLine={parentsLoading || parents.length > 0}
503
+
nopics={!!nopics}
504
+
lightboxCallback={lightboxCallback}
490
505
/>
491
-
))}
492
-
</div>
493
-
<div ref={mainPostRef}>
494
-
<UniversalPostRendererATURILoader
495
-
atUri={atUri}
496
-
detailed={true}
497
-
topReplyLine={parentsLoading || parents.length > 0}
498
-
nopics={!!nopics}
499
-
lightboxCallback={lightboxCallback}
500
-
/>
501
-
</div>
502
-
<div
503
-
style={{
504
-
maxWidth: 600,
505
-
//margin: "0px auto 0",
506
-
padding: 0,
507
-
minHeight: "80dvh",
508
-
paddingBottom: "20dvh",
509
-
}}
510
-
>
506
+
</div>
511
507
<div
512
-
className="text-gray-500 dark:text-gray-400 text-sm font-bold"
513
508
style={{
514
-
fontSize: 18,
515
-
margin: "12px 16px 12px 16px",
516
-
fontWeight: 600,
509
+
maxWidth: 600,
510
+
//margin: "0px auto 0",
511
+
padding: 0,
512
+
minHeight: "80dvh",
513
+
paddingBottom: "20dvh",
517
514
}}
518
515
>
519
-
Replies
516
+
<div
517
+
className="text-gray-500 dark:text-gray-400 text-sm font-bold"
518
+
style={{
519
+
fontSize: 18,
520
+
margin: "12px 16px 12px 16px",
521
+
fontWeight: 600,
522
+
}}
523
+
>
524
+
Replies
525
+
</div>
526
+
<div style={{ display: "flex", flexDirection: "column", gap: 0 }}>
527
+
{replyAturis.length > 0 &&
528
+
replyAturis.map((reply) => {
529
+
//const replyAtUri = `at://${reply.did}/app.bsky.feed.post/${reply.rkey}`;
530
+
return (
531
+
<UniversalPostRendererATURILoader
532
+
key={reply}
533
+
atUri={reply}
534
+
maxReplies={4}
535
+
/>
536
+
);
537
+
})}
538
+
{hasNextPage && (
539
+
<button
540
+
onClick={() => fetchNextPage()}
541
+
disabled={isFetchingNextPage}
542
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50"
543
+
>
544
+
{isFetchingNextPage ? "Loading..." : "Load More"}
545
+
</button>
546
+
)}
547
+
</div>
520
548
</div>
521
-
<div style={{ display: "flex", flexDirection: "column", gap: 0 }}>
522
-
{replyAturis.length > 0 &&
523
-
replyAturis.map((reply) => {
524
-
//const replyAtUri = `at://${reply.did}/app.bsky.feed.post/${reply.rkey}`;
525
-
return (
526
-
<UniversalPostRendererATURILoader
527
-
key={reply}
528
-
atUri={reply}
529
-
maxReplies={4}
530
-
/>
531
-
);
532
-
})}
533
-
{hasNextPage && (
534
-
<button
535
-
onClick={() => fetchNextPage()}
536
-
disabled={isFetchingNextPage}
537
-
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50"
538
-
>
539
-
{isFetchingNextPage ? "Loading..." : "Load More"}
540
-
</button>
541
-
)}
542
-
</div>
543
-
</div>
549
+
</>)}
544
550
</>
545
551
);
546
552
}
+118
-16
src/routes/settings.tsx
+118
-16
src/routes/settings.tsx
···
1
1
import { createFileRoute } from "@tanstack/react-router";
2
-
import { useAtom } from "jotai";
3
-
import { Slider } from "radix-ui";
2
+
import { useAtom, useAtomValue, useSetAtom } from "jotai";
3
+
import { Slider, Switch } from "radix-ui";
4
+
import { useEffect, useState } from "react";
4
5
5
6
import { Header } from "~/components/Header";
6
7
import Login from "~/components/Login";
···
11
12
defaultImgCDN,
12
13
defaultslingshotURL,
13
14
defaultVideoCDN,
15
+
enableBitesAtom,
16
+
enableBridgyTextAtom,
17
+
enableWafrnTextAtom,
14
18
hueAtom,
15
19
imgCDNAtom,
16
20
slingshotURLAtom,
···
38
42
<Login />
39
43
</div>
40
44
<div className="h-4" />
45
+
46
+
<SettingHeading title="Personalization" top />
47
+
<Hue />
48
+
49
+
<SettingHeading title="Network Configuration" />
50
+
<div className="flex flex-col px-4 pb-2">
51
+
<span className="text-md">Service Endpoints</span>
52
+
<span className="text-sm text-gray-500 dark:text-gray-400">
53
+
Customize the servers to be used by the app
54
+
</span>
55
+
</div>
41
56
<TextInputSetting
42
57
atom={constellationURLAtom}
43
58
title={"Constellation"}
···
67
82
init={defaultVideoCDN}
68
83
/>
69
84
70
-
<Hue />
71
-
<p className="text-gray-500 dark:text-gray-400 py-4 px-6 text-sm">
72
-
please restart/refresh the app if changes arent applying correctly
85
+
<SettingHeading title="Experimental" />
86
+
<SwitchSetting
87
+
atom={enableBitesAtom}
88
+
title={"Bites"}
89
+
description={"Enable Wafrn Bites to bite and be bitten by other people"}
90
+
//init={false}
91
+
/>
92
+
<div className="h-4" />
93
+
<SwitchSetting
94
+
atom={enableBridgyTextAtom}
95
+
title={"Bridgy Text"}
96
+
description={
97
+
"Show the original text of posts bridged from the Fediverse"
98
+
}
99
+
//init={false}
100
+
/>
101
+
<div className="h-4" />
102
+
<SwitchSetting
103
+
atom={enableWafrnTextAtom}
104
+
title={"Wafrn Text"}
105
+
description={
106
+
"Show the original text of posts from Wafrn instances"
107
+
}
108
+
//init={false}
109
+
/>
110
+
<p className="text-gray-500 dark:text-gray-400 py-4 px-4 text-sm border rounded-xl mx-4 mt-8 mb-4">
111
+
Notice: Please restart/refresh the app if changes arent applying
112
+
correctly
73
113
</p>
74
114
</>
75
115
);
76
116
}
117
+
118
+
export function SettingHeading({
119
+
title,
120
+
top,
121
+
}: {
122
+
title: string;
123
+
top?: boolean;
124
+
}) {
125
+
return (
126
+
<div
127
+
className="px-4"
128
+
style={{ marginTop: top ? 0 : 18, paddingBottom: 12 }}
129
+
>
130
+
<span className=" text-sm font-medium text-gray-500 dark:text-gray-400">
131
+
{title}
132
+
</span>
133
+
</div>
134
+
);
135
+
}
136
+
137
+
export function SwitchSetting({
138
+
atom,
139
+
title,
140
+
description,
141
+
}: {
142
+
atom: typeof enableBitesAtom;
143
+
title?: string;
144
+
description?: string;
145
+
}) {
146
+
const value = useAtomValue(atom);
147
+
const setValue = useSetAtom(atom);
148
+
149
+
const [hydrated, setHydrated] = useState(false);
150
+
// eslint-disable-next-line react-hooks/set-state-in-effect
151
+
useEffect(() => setHydrated(true), []);
152
+
153
+
if (!hydrated) {
154
+
// Avoid rendering Switch until we know storage is loaded
155
+
return null;
156
+
}
157
+
158
+
return (
159
+
<div className="flex items-center gap-4 px-4 ">
160
+
<label htmlFor={`switch-${title}`} className="flex flex-row flex-1">
161
+
<div className="flex flex-col">
162
+
<span className="text-md">{title}</span>
163
+
<span className="text-sm text-gray-500 dark:text-gray-400">
164
+
{description}
165
+
</span>
166
+
</div>
167
+
</label>
168
+
169
+
<Switch.Root
170
+
id={`switch-${title}`}
171
+
checked={value}
172
+
onCheckedChange={(v) => setValue(v)}
173
+
className="m3switch root"
174
+
>
175
+
<Switch.Thumb className="m3switch thumb " />
176
+
</Switch.Root>
177
+
</div>
178
+
);
179
+
}
180
+
77
181
function Hue() {
78
182
const [hue, setHue] = useAtom(hueAtom);
79
183
return (
80
-
<div className="flex flex-col px-4 mt-4 ">
81
-
<span className="z-10">Hue</span>
82
-
<div className="flex flex-row items-center gap-4">
83
-
<SliderComponent
84
-
atom={hueAtom}
85
-
max={360}
86
-
/>
184
+
<div className="flex flex-col px-4">
185
+
<span className="z-[2] text-md">Hue</span>
186
+
<span className="z-[2] text-sm text-gray-500 dark:text-gray-400">
187
+
Change the colors of the app
188
+
</span>
189
+
<div className="z-[1] flex flex-row items-center gap-4">
190
+
<SliderComponent atom={hueAtom} max={360} />
87
191
<button
88
192
onClick={() => setHue(defaulthue ?? 28)}
89
193
className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800
···
154
258
);
155
259
}
156
260
157
-
158
261
interface SliderProps {
159
262
atom: typeof hueAtom;
160
263
min?: number;
···
168
271
max = 100,
169
272
step = 1,
170
273
}) => {
171
-
172
-
const [value, setValue] = useAtom(atom)
274
+
const [value, setValue] = useAtom(atom);
173
275
174
276
return (
175
277
<Slider.Root
···
186
288
<Slider.Thumb className="shadow-[0_0_0_8px_var(--color-white)] dark:shadow-[0_0_0_8px_var(--color-gray-950)] block w-[3px] h-12 bg-gray-500 dark:bg-gray-400 rounded-md focus:outline-none" />
187
289
</Slider.Root>
188
290
);
189
-
};
291
+
};
+137
src/styles/app.css
+137
src/styles/app.css
···
197
197
198
198
/* focus ring */
199
199
.m3input-field.m3input-label.m3input-border input:focus {
200
+
/*border-color: var(--m3input-focus-color);*/
200
201
border-color: var(--m3input-focus-color);
202
+
box-shadow: 0 0 0 1px var(--m3input-focus-color);
201
203
/*box-shadow: 0 0 0 2px color-mix(in srgb, var(--focus-color) 20%, transparent);*/
202
204
}
203
205
···
231
233
/* radix i love you but like cmon man */
232
234
body[data-scroll-locked]{
233
235
margin-left: var(--removed-body-scroll-bar-size) !important;
236
+
}
237
+
238
+
/* radix tabs */
239
+
240
+
.m3tab[data-radix-collection-item] {
241
+
flex: 1;
242
+
display: flex;
243
+
padding: 12px 8px;
244
+
align-items: center;
245
+
justify-content: center;
246
+
color: var(--color-gray-500);
247
+
font-weight: 500;
248
+
&:hover {
249
+
background-color: var(--color-gray-100);
250
+
cursor: pointer;
251
+
}
252
+
&[aria-selected="true"] {
253
+
color: var(--color-gray-950);
254
+
&::before{
255
+
content: "";
256
+
position: absolute;
257
+
width: min(80px, 80%);
258
+
border-radius: 99px 99px 0px 0px ;
259
+
height: 3px;
260
+
bottom: 0;
261
+
background-color: var(--color-gray-400);
262
+
}
263
+
}
264
+
}
265
+
266
+
@media (prefers-color-scheme: dark) {
267
+
.m3tab[data-radix-collection-item] {
268
+
color: var(--color-gray-400);
269
+
&:hover {
270
+
background-color: var(--color-gray-900);
271
+
cursor: pointer;
272
+
}
273
+
&[aria-selected="true"] {
274
+
color: var(--color-gray-50);
275
+
&::before{
276
+
background-color: var(--color-gray-500);
277
+
}
278
+
}
279
+
}
280
+
}
281
+
282
+
:root{
283
+
--thumb-size: 2rem;
284
+
--root-size: 3.25rem;
285
+
286
+
--switch-off-border: var(--color-gray-400);
287
+
--switch-off-bg: var(--color-gray-200);
288
+
--switch-off-thumb: var(--color-gray-400);
289
+
290
+
291
+
--switch-on-bg: var(--color-gray-500);
292
+
--switch-on-thumb: var(--color-gray-50);
293
+
294
+
}
295
+
@media (prefers-color-scheme: dark) {
296
+
:root {
297
+
--switch-off-border: var(--color-gray-500);
298
+
--switch-off-bg: var(--color-gray-800);
299
+
--switch-off-thumb: var(--color-gray-500);
300
+
301
+
302
+
--switch-on-bg: var(--color-gray-400);
303
+
--switch-on-thumb: var(--color-gray-700);
304
+
}
305
+
}
306
+
307
+
.m3switch.root{
308
+
/*w-10 h-6 bg-gray-300 rounded-full relative data-[state=checked]:bg-gray-500 transition-colors*/
309
+
/*width: 40px;
310
+
height: 24px;*/
311
+
312
+
inline-size: var(--root-size);
313
+
block-size: 2rem;
314
+
border-radius: 99999px;
315
+
316
+
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to;
317
+
transition-timing-function: var(--default-transition-timing-function); /* cubic-bezier(0.4, 0, 0.2, 1) */
318
+
transition-duration: var(--default-transition-duration); /* 150ms */
319
+
320
+
.m3switch.thumb{
321
+
/*block w-5 h-5 bg-white rounded-full shadow-sm transition-transform translate-x-[2px] data-[state=checked]:translate-x-[20px]*/
322
+
323
+
height: var(--thumb-size);
324
+
width: var(--thumb-size);
325
+
display: inline-block;
326
+
border-radius: 9999px;
327
+
328
+
transform-origin: center;
329
+
330
+
transition-property: transform, translate, scale, rotate;
331
+
transition-timing-function: var(--default-transition-timing-function); /* cubic-bezier(0.4, 0, 0.2, 1) */
332
+
transition-duration: var(--default-transition-duration); /* 150ms */
333
+
334
+
}
335
+
336
+
&[aria-checked="true"] {
337
+
box-shadow: inset 0px 0px 0px 1.8px transparent;
338
+
background-color: var(--switch-on-bg);
339
+
340
+
.m3switch.thumb{
341
+
/*block w-5 h-5 bg-white rounded-full shadow-sm transition-transform translate-x-[2px] data-[state=checked]:translate-x-[20px]*/
342
+
343
+
background-color: var(--switch-on-thumb);
344
+
transform: translate(calc((var(--root-size) / 2) - 50%),0) scale(0.72);
345
+
&:active {
346
+
transform: translate(calc((var(--root-size) / 2) - 50%),0) scale(0.88);
347
+
}
348
+
349
+
}
350
+
&:active .m3switch.thumb{
351
+
transform: translate(calc((var(--root-size) / 2) - 50%),0) scale(0.88);
352
+
}
353
+
}
354
+
355
+
&[aria-checked="false"] {
356
+
box-shadow: inset 0px 0px 0px 1.8px var(--switch-off-border);
357
+
background-color: var(--switch-off-bg);
358
+
.m3switch.thumb{
359
+
/*block w-5 h-5 bg-white rounded-full shadow-sm transition-transform translate-x-[2px] data-[state=checked]:translate-x-[20px]*/
360
+
361
+
background-color: var(--switch-off-thumb);
362
+
transform: translate(calc(-1 * (var(--root-size) / 2) + 50%),0) scale(0.5);
363
+
&:active {
364
+
transform: translate(calc(-1 * (var(--root-size) / 2) + 50%),0) scale(0.88);
365
+
}
366
+
}
367
+
&:active .m3switch.thumb{
368
+
transform: translate(calc(-1 * (var(--root-size) / 2) + 50%),0) scale(0.88);
369
+
}
370
+
}
234
371
}
+67
src/utils/atoms.ts
+67
src/utils/atoms.ts
···
2
2
import { atomWithStorage } from "jotai/utils";
3
3
import { useEffect } from "react";
4
4
5
+
import { type ProfilePostsFilter } from "~/routes/profile.$did";
6
+
5
7
export const store = createStore();
6
8
7
9
export const quickAuthAtom = atomWithStorage<string | null>(
···
21
23
{}
22
24
);
23
25
26
+
type TabRouteScrollState = {
27
+
activeTab: string;
28
+
scrollPositions: Record<string, number>;
29
+
};
30
+
/**
31
+
* @deprecated should be safe to remove i think
32
+
*/
33
+
export const notificationsScrollAtom = atom<TabRouteScrollState>({
34
+
activeTab: "mentions",
35
+
scrollPositions: {},
36
+
});
37
+
38
+
export type InteractionFilter = {
39
+
likes: boolean;
40
+
reposts: boolean;
41
+
quotes: boolean;
42
+
replies: boolean;
43
+
showAll: boolean;
44
+
};
45
+
const defaultFilters: InteractionFilter = {
46
+
likes: true,
47
+
reposts: true,
48
+
quotes: true,
49
+
replies: true,
50
+
showAll: false,
51
+
};
52
+
export const postInteractionsFiltersAtom = atomWithStorage<InteractionFilter>(
53
+
"postInteractionsFilters",
54
+
defaultFilters
55
+
);
56
+
57
+
export const reusableTabRouteScrollAtom = atom<Record<string, TabRouteScrollState | undefined> | undefined>({});
58
+
24
59
export const likedPostsAtom = atomWithStorage<Record<string, string>>(
25
60
"likedPosts",
26
61
{}
27
62
);
63
+
64
+
export type LikeRecord = {
65
+
uri: string; // at://did/collection/rkey
66
+
target: string;
67
+
cid: string;
68
+
};
69
+
70
+
export const internalLikedPostsAtom = atomWithStorage<Record<string, LikeRecord | null>>(
71
+
"internal-liked-posts",
72
+
{}
73
+
);
74
+
75
+
export const profileChipsAtom = atom<Record<string, ProfilePostsFilter | null>>({})
28
76
29
77
export const defaultconstellationURL = "constellation.microcosm.blue";
30
78
export const constellationURLAtom = atomWithStorage<string>(
···
80
128
// console.log("atom get ", initial);
81
129
// document.documentElement.style.setProperty(cssVar, initial.toString());
82
130
// }
131
+
132
+
133
+
134
+
// fun stuff
135
+
136
+
export const enableBitesAtom = atomWithStorage<boolean>(
137
+
"enableBitesAtom",
138
+
false
139
+
);
140
+
141
+
export const enableBridgyTextAtom = atomWithStorage<boolean>(
142
+
"enableBridgyTextAtom",
143
+
false
144
+
);
145
+
146
+
export const enableWafrnTextAtom = atomWithStorage<boolean>(
147
+
"enableWafrnTextAtom",
148
+
false
149
+
);
+34
src/utils/likeMutationQueue.ts
+34
src/utils/likeMutationQueue.ts
···
1
+
import { useAtom } from "jotai";
2
+
import { useCallback } from "react";
3
+
4
+
import { type LikeRecord,useLikeMutationQueue as useLikeMutationQueueFromProvider } from "~/providers/LikeMutationQueueProvider";
5
+
import { useAuth } from "~/providers/UnifiedAuthProvider";
6
+
7
+
import { internalLikedPostsAtom } from "./atoms";
8
+
9
+
export function useFastLike(target: string, cid: string) {
10
+
const { agent } = useAuth();
11
+
const { fastState, fastToggle, backfillState } = useLikeMutationQueueFromProvider();
12
+
13
+
const liked = fastState(target);
14
+
const toggle = () => fastToggle(target, cid);
15
+
/**
16
+
*
17
+
* @deprecated dont use it yet, will cause infinite rerenders
18
+
*/
19
+
const backfill = () => agent?.did && backfillState(target, agent.did);
20
+
21
+
return { liked, toggle, backfill };
22
+
}
23
+
24
+
export function useFastSetLikesFromFeed() {
25
+
const [_, setLikedPosts] = useAtom(internalLikedPostsAtom);
26
+
27
+
const setFastState = useCallback(
28
+
(target: string, record: LikeRecord | null) =>
29
+
setLikedPosts((prev) => ({ ...prev, [target]: record })),
30
+
[setLikedPosts]
31
+
);
32
+
33
+
return { setFastState };
34
+
}
+2
-2
src/utils/oauthClient.ts
+2
-2
src/utils/oauthClient.ts
···
1
1
import { BrowserOAuthClient, type ClientMetadata } from '@atproto/oauth-client-browser';
2
2
3
-
// i tried making this https://pds-nd.whey.party but cors is annoying as fuck
4
-
const handleResolverPDS = 'https://bsky.social';
3
+
import resolvers from '../../public/resolvers.json' with { type: 'json' };
4
+
const handleResolverPDS = resolvers.resolver || 'https://bsky.social';
5
5
6
6
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
7
7
// @ts-ignore this should be fine ? the vite plugin should generate this before errors
+41
-17
src/utils/useQuery.ts
+41
-17
src/utils/useQuery.ts
···
284
284
gcTime: /*0//*/5 * 60 * 1000,
285
285
});
286
286
}
287
+
// todo do more of these instead of overloads since overloads sucks so much apparently
288
+
export function useQueryConstellationLinksCountDistinctDids(query?: {
289
+
method: "/links/count/distinct-dids";
290
+
target: string;
291
+
collection: string;
292
+
path: string;
293
+
cursor?: string;
294
+
}): UseQueryResult<linksCountResponse, Error> | undefined {
295
+
//if (!query) return;
296
+
const [constellationurl] = useAtom(constellationURLAtom)
297
+
const queryres = useQuery(
298
+
constructConstellationQuery(query && {constellation: constellationurl, ...query})
299
+
) as unknown as UseQueryResult<linksCountResponse, Error>;
300
+
if (!query) {
301
+
return undefined as undefined;
302
+
}
303
+
return queryres as UseQueryResult<linksCountResponse, Error>;
304
+
}
305
+
287
306
export function useQueryConstellation(query: {
288
307
method: "/links";
289
308
target: string;
···
352
371
);
353
372
}
354
373
355
-
type linksRecord = {
374
+
export type linksRecord = {
356
375
did: string;
357
376
collection: string;
358
377
rkey: string;
···
534
553
}[];
535
554
};
536
555
537
-
export function constructAuthorFeedQuery(did: string, pdsUrl: string) {
556
+
export function constructAuthorFeedQuery(did: string, pdsUrl: string, collection: string = "app.bsky.feed.post") {
538
557
return queryOptions({
539
-
queryKey: ['authorFeed', did],
558
+
queryKey: ['authorFeed', did, collection],
540
559
queryFn: async ({ pageParam }: QueryFunctionContext) => {
541
560
const limit = 25;
542
561
543
562
const cursor = pageParam as string | undefined;
544
563
const cursorParam = cursor ? `&cursor=${cursor}` : '';
545
564
546
-
const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post&limit=${limit}${cursorParam}`;
565
+
const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${collection}&limit=${limit}${cursorParam}`;
547
566
548
567
const res = await fetch(url);
549
568
if (!res.ok) throw new Error("Failed to fetch author's posts");
···
553
572
});
554
573
}
555
574
556
-
export function useInfiniteQueryAuthorFeed(did: string | undefined, pdsUrl: string | undefined) {
557
-
const { queryKey, queryFn } = constructAuthorFeedQuery(did!, pdsUrl!);
575
+
export function useInfiniteQueryAuthorFeed(did: string | undefined, pdsUrl: string | undefined, collection?: string) {
576
+
const { queryKey, queryFn } = constructAuthorFeedQuery(did!, pdsUrl!, collection);
558
577
559
578
return useInfiniteQuery({
560
579
queryKey,
···
573
592
isAuthed: boolean;
574
593
pdsUrl?: string;
575
594
feedServiceDid?: string;
595
+
// todo the hell is a unauthedfeedurl
596
+
unauthedfeedurl?: string;
576
597
}) {
577
-
const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid } = options;
598
+
const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid, unauthedfeedurl } = options;
578
599
579
600
return queryOptions({
580
601
queryKey: ["feedSkeleton", feedUri, { isAuthed, did: agent?.did }],
···
582
603
queryFn: async ({ pageParam }: QueryFunctionContext): Promise<FeedSkeletonPage> => {
583
604
const cursorParam = pageParam ? `&cursor=${pageParam}` : "";
584
605
585
-
if (isAuthed) {
606
+
if (isAuthed && !unauthedfeedurl) {
586
607
if (!agent || !pdsUrl || !feedServiceDid) {
587
608
throw new Error("Missing required info for authenticated feed fetch.");
588
609
}
···
597
618
if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`);
598
619
return (await res.json()) as FeedSkeletonPage;
599
620
} else {
600
-
const url = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`;
621
+
const url = `https://${unauthedfeedurl ? unauthedfeedurl : "discover.bsky.app"}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`;
601
622
const res = await fetch(url);
602
623
if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`);
603
624
return (await res.json()) as FeedSkeletonPage;
···
612
633
isAuthed: boolean;
613
634
pdsUrl?: string;
614
635
feedServiceDid?: string;
636
+
unauthedfeedurl?: string;
615
637
}) {
616
638
const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery(options);
617
639
···
622
644
getNextPageParam: (lastPage) => lastPage.cursor as null | undefined,
623
645
staleTime: Infinity,
624
646
refetchOnWindowFocus: false,
625
-
enabled: !!options.feedUri && (options.isAuthed ? !!options.agent && !!options.pdsUrl && !!options.feedServiceDid : true),
647
+
enabled: !!options.feedUri && (options.isAuthed ? (!!options.agent && !!options.pdsUrl || !!options.unauthedfeedurl) && !!options.feedServiceDid : true),
626
648
}), queryKey: queryKey};
627
649
}
628
650
···
632
654
method: '/links'
633
655
target?: string
634
656
collection: string
635
-
path: string
657
+
path: string,
658
+
staleMult?: number
636
659
}) {
637
-
console.log(
638
-
'yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks',
639
-
query,
640
-
)
660
+
const safemult = query?.staleMult ?? 1;
661
+
// console.log(
662
+
// 'yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks',
663
+
// query,
664
+
// )
641
665
642
666
return infiniteQueryOptions({
643
667
enabled: !!query?.target,
···
675
699
return (lastPage as any)?.cursor ?? undefined
676
700
},
677
701
initialPageParam: undefined,
678
-
staleTime: 5 * 60 * 1000,
679
-
gcTime: 5 * 60 * 1000,
702
+
staleTime: 5 * 60 * 1000 * safemult,
703
+
gcTime: 5 * 60 * 1000 * safemult,
680
704
})
681
705
}
+5
vite.config.ts
+5
vite.config.ts
···
13
13
const PROD_URL = "https://reddwarf.app"
14
14
const DEV_URL = "https://local3768forumtest.whey.party"
15
15
16
+
const PROD_HANDLE_RESOLVER_PDS = "https://pds-nd.whey.party"
17
+
const DEV_HANDLE_RESOLVER_PDS = "https://bsky.social"
18
+
16
19
function shp(url: string): string {
17
20
return url.replace(/^https?:\/\//, '');
18
21
}
···
23
26
generateMetadataPlugin({
24
27
prod: PROD_URL,
25
28
dev: DEV_URL,
29
+
prodResolver: PROD_HANDLE_RESOLVER_PDS,
30
+
devResolver: DEV_HANDLE_RESOLVER_PDS,
26
31
}),
27
32
TanStackRouterVite({ autoCodeSplitting: true }),
28
33
viteReact({