+2
-1
.gitignore
+2
-1
.gitignore
+10
-1
README.md
+10
-1
README.md
···
8
8
## running dev and build
9
9
in the `vite.config.ts` file you should change these values
10
10
```ts
11
-
const PROD_URL = "https://reddwarf.whey.party"
11
+
const PROD_URL = "https://reddwarf.app"
12
12
const DEV_URL = "https://local3768forumtest.whey.party"
13
13
```
14
14
the PROD_URL is what will compile your oauth client metadata so it is very important to change that. same for DEV_URL if you are using a tunnel for dev work
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
+
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
17
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!
+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
+
}
+1695
package-lock.json
+1695
package-lock.json
···
8
8
"dependencies": {
9
9
"@atproto/api": "^0.16.6",
10
10
"@atproto/oauth-client-browser": "^0.3.33",
11
+
"@radix-ui/react-dialog": "^1.1.15",
12
+
"@radix-ui/react-dropdown-menu": "^2.1.16",
13
+
"@radix-ui/react-hover-card": "^1.1.15",
14
+
"@radix-ui/react-slider": "^1.3.6",
11
15
"@tailwindcss/vite": "^4.0.6",
12
16
"@tanstack/query-sync-storage-persister": "^5.85.6",
13
17
"@tanstack/react-devtools": "^0.2.2",
···
21
25
"idb-keyval": "^6.2.2",
22
26
"jotai": "^2.13.1",
23
27
"npm": "^11.6.2",
28
+
"radix-ui": "^1.4.3",
24
29
"react": "^19.0.0",
25
30
"react-dom": "^19.0.0",
26
31
"react-player": "^3.3.2",
32
+
"sonner": "^2.0.7",
27
33
"tailwindcss": "^4.0.6",
28
34
"tanstack-router-keepalive": "^1.0.0"
29
35
},
···
1592
1598
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1593
1599
}
1594
1600
},
1601
+
"node_modules/@floating-ui/core": {
1602
+
"version": "1.7.3",
1603
+
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
1604
+
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
1605
+
"dependencies": {
1606
+
"@floating-ui/utils": "^0.2.10"
1607
+
}
1608
+
},
1609
+
"node_modules/@floating-ui/dom": {
1610
+
"version": "1.7.4",
1611
+
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
1612
+
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
1613
+
"dependencies": {
1614
+
"@floating-ui/core": "^1.7.3",
1615
+
"@floating-ui/utils": "^0.2.10"
1616
+
}
1617
+
},
1618
+
"node_modules/@floating-ui/react-dom": {
1619
+
"version": "2.1.6",
1620
+
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz",
1621
+
"integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==",
1622
+
"dependencies": {
1623
+
"@floating-ui/dom": "^1.7.4"
1624
+
},
1625
+
"peerDependencies": {
1626
+
"react": ">=16.8.0",
1627
+
"react-dom": ">=16.8.0"
1628
+
}
1629
+
},
1630
+
"node_modules/@floating-ui/utils": {
1631
+
"version": "0.2.10",
1632
+
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
1633
+
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="
1634
+
},
1595
1635
"node_modules/@humanfs/core": {
1596
1636
"version": "0.19.1",
1597
1637
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
···
1895
1935
"node": ">= 8"
1896
1936
}
1897
1937
},
1938
+
"node_modules/@radix-ui/number": {
1939
+
"version": "1.1.1",
1940
+
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
1941
+
"integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="
1942
+
},
1943
+
"node_modules/@radix-ui/primitive": {
1944
+
"version": "1.1.3",
1945
+
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
1946
+
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="
1947
+
},
1948
+
"node_modules/@radix-ui/react-accessible-icon": {
1949
+
"version": "1.1.7",
1950
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.7.tgz",
1951
+
"integrity": "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==",
1952
+
"dependencies": {
1953
+
"@radix-ui/react-visually-hidden": "1.2.3"
1954
+
},
1955
+
"peerDependencies": {
1956
+
"@types/react": "*",
1957
+
"@types/react-dom": "*",
1958
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
1959
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
1960
+
},
1961
+
"peerDependenciesMeta": {
1962
+
"@types/react": {
1963
+
"optional": true
1964
+
},
1965
+
"@types/react-dom": {
1966
+
"optional": true
1967
+
}
1968
+
}
1969
+
},
1970
+
"node_modules/@radix-ui/react-accordion": {
1971
+
"version": "1.2.12",
1972
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz",
1973
+
"integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==",
1974
+
"dependencies": {
1975
+
"@radix-ui/primitive": "1.1.3",
1976
+
"@radix-ui/react-collapsible": "1.1.12",
1977
+
"@radix-ui/react-collection": "1.1.7",
1978
+
"@radix-ui/react-compose-refs": "1.1.2",
1979
+
"@radix-ui/react-context": "1.1.2",
1980
+
"@radix-ui/react-direction": "1.1.1",
1981
+
"@radix-ui/react-id": "1.1.1",
1982
+
"@radix-ui/react-primitive": "2.1.3",
1983
+
"@radix-ui/react-use-controllable-state": "1.2.2"
1984
+
},
1985
+
"peerDependencies": {
1986
+
"@types/react": "*",
1987
+
"@types/react-dom": "*",
1988
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
1989
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
1990
+
},
1991
+
"peerDependenciesMeta": {
1992
+
"@types/react": {
1993
+
"optional": true
1994
+
},
1995
+
"@types/react-dom": {
1996
+
"optional": true
1997
+
}
1998
+
}
1999
+
},
2000
+
"node_modules/@radix-ui/react-alert-dialog": {
2001
+
"version": "1.1.15",
2002
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz",
2003
+
"integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==",
2004
+
"dependencies": {
2005
+
"@radix-ui/primitive": "1.1.3",
2006
+
"@radix-ui/react-compose-refs": "1.1.2",
2007
+
"@radix-ui/react-context": "1.1.2",
2008
+
"@radix-ui/react-dialog": "1.1.15",
2009
+
"@radix-ui/react-primitive": "2.1.3",
2010
+
"@radix-ui/react-slot": "1.2.3"
2011
+
},
2012
+
"peerDependencies": {
2013
+
"@types/react": "*",
2014
+
"@types/react-dom": "*",
2015
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2016
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2017
+
},
2018
+
"peerDependenciesMeta": {
2019
+
"@types/react": {
2020
+
"optional": true
2021
+
},
2022
+
"@types/react-dom": {
2023
+
"optional": true
2024
+
}
2025
+
}
2026
+
},
2027
+
"node_modules/@radix-ui/react-arrow": {
2028
+
"version": "1.1.7",
2029
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
2030
+
"integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
2031
+
"dependencies": {
2032
+
"@radix-ui/react-primitive": "2.1.3"
2033
+
},
2034
+
"peerDependencies": {
2035
+
"@types/react": "*",
2036
+
"@types/react-dom": "*",
2037
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2038
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2039
+
},
2040
+
"peerDependenciesMeta": {
2041
+
"@types/react": {
2042
+
"optional": true
2043
+
},
2044
+
"@types/react-dom": {
2045
+
"optional": true
2046
+
}
2047
+
}
2048
+
},
2049
+
"node_modules/@radix-ui/react-aspect-ratio": {
2050
+
"version": "1.1.7",
2051
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.7.tgz",
2052
+
"integrity": "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==",
2053
+
"dependencies": {
2054
+
"@radix-ui/react-primitive": "2.1.3"
2055
+
},
2056
+
"peerDependencies": {
2057
+
"@types/react": "*",
2058
+
"@types/react-dom": "*",
2059
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2060
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2061
+
},
2062
+
"peerDependenciesMeta": {
2063
+
"@types/react": {
2064
+
"optional": true
2065
+
},
2066
+
"@types/react-dom": {
2067
+
"optional": true
2068
+
}
2069
+
}
2070
+
},
2071
+
"node_modules/@radix-ui/react-avatar": {
2072
+
"version": "1.1.10",
2073
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz",
2074
+
"integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==",
2075
+
"dependencies": {
2076
+
"@radix-ui/react-context": "1.1.2",
2077
+
"@radix-ui/react-primitive": "2.1.3",
2078
+
"@radix-ui/react-use-callback-ref": "1.1.1",
2079
+
"@radix-ui/react-use-is-hydrated": "0.1.0",
2080
+
"@radix-ui/react-use-layout-effect": "1.1.1"
2081
+
},
2082
+
"peerDependencies": {
2083
+
"@types/react": "*",
2084
+
"@types/react-dom": "*",
2085
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2086
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2087
+
},
2088
+
"peerDependenciesMeta": {
2089
+
"@types/react": {
2090
+
"optional": true
2091
+
},
2092
+
"@types/react-dom": {
2093
+
"optional": true
2094
+
}
2095
+
}
2096
+
},
2097
+
"node_modules/@radix-ui/react-checkbox": {
2098
+
"version": "1.3.3",
2099
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz",
2100
+
"integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==",
2101
+
"dependencies": {
2102
+
"@radix-ui/primitive": "1.1.3",
2103
+
"@radix-ui/react-compose-refs": "1.1.2",
2104
+
"@radix-ui/react-context": "1.1.2",
2105
+
"@radix-ui/react-presence": "1.1.5",
2106
+
"@radix-ui/react-primitive": "2.1.3",
2107
+
"@radix-ui/react-use-controllable-state": "1.2.2",
2108
+
"@radix-ui/react-use-previous": "1.1.1",
2109
+
"@radix-ui/react-use-size": "1.1.1"
2110
+
},
2111
+
"peerDependencies": {
2112
+
"@types/react": "*",
2113
+
"@types/react-dom": "*",
2114
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2115
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2116
+
},
2117
+
"peerDependenciesMeta": {
2118
+
"@types/react": {
2119
+
"optional": true
2120
+
},
2121
+
"@types/react-dom": {
2122
+
"optional": true
2123
+
}
2124
+
}
2125
+
},
2126
+
"node_modules/@radix-ui/react-collapsible": {
2127
+
"version": "1.1.12",
2128
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz",
2129
+
"integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==",
2130
+
"dependencies": {
2131
+
"@radix-ui/primitive": "1.1.3",
2132
+
"@radix-ui/react-compose-refs": "1.1.2",
2133
+
"@radix-ui/react-context": "1.1.2",
2134
+
"@radix-ui/react-id": "1.1.1",
2135
+
"@radix-ui/react-presence": "1.1.5",
2136
+
"@radix-ui/react-primitive": "2.1.3",
2137
+
"@radix-ui/react-use-controllable-state": "1.2.2",
2138
+
"@radix-ui/react-use-layout-effect": "1.1.1"
2139
+
},
2140
+
"peerDependencies": {
2141
+
"@types/react": "*",
2142
+
"@types/react-dom": "*",
2143
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2144
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2145
+
},
2146
+
"peerDependenciesMeta": {
2147
+
"@types/react": {
2148
+
"optional": true
2149
+
},
2150
+
"@types/react-dom": {
2151
+
"optional": true
2152
+
}
2153
+
}
2154
+
},
2155
+
"node_modules/@radix-ui/react-collection": {
2156
+
"version": "1.1.7",
2157
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
2158
+
"integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
2159
+
"dependencies": {
2160
+
"@radix-ui/react-compose-refs": "1.1.2",
2161
+
"@radix-ui/react-context": "1.1.2",
2162
+
"@radix-ui/react-primitive": "2.1.3",
2163
+
"@radix-ui/react-slot": "1.2.3"
2164
+
},
2165
+
"peerDependencies": {
2166
+
"@types/react": "*",
2167
+
"@types/react-dom": "*",
2168
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2169
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2170
+
},
2171
+
"peerDependenciesMeta": {
2172
+
"@types/react": {
2173
+
"optional": true
2174
+
},
2175
+
"@types/react-dom": {
2176
+
"optional": true
2177
+
}
2178
+
}
2179
+
},
2180
+
"node_modules/@radix-ui/react-compose-refs": {
2181
+
"version": "1.1.2",
2182
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
2183
+
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
2184
+
"peerDependencies": {
2185
+
"@types/react": "*",
2186
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2187
+
},
2188
+
"peerDependenciesMeta": {
2189
+
"@types/react": {
2190
+
"optional": true
2191
+
}
2192
+
}
2193
+
},
2194
+
"node_modules/@radix-ui/react-context": {
2195
+
"version": "1.1.2",
2196
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
2197
+
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
2198
+
"peerDependencies": {
2199
+
"@types/react": "*",
2200
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2201
+
},
2202
+
"peerDependenciesMeta": {
2203
+
"@types/react": {
2204
+
"optional": true
2205
+
}
2206
+
}
2207
+
},
2208
+
"node_modules/@radix-ui/react-context-menu": {
2209
+
"version": "2.2.16",
2210
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz",
2211
+
"integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==",
2212
+
"dependencies": {
2213
+
"@radix-ui/primitive": "1.1.3",
2214
+
"@radix-ui/react-context": "1.1.2",
2215
+
"@radix-ui/react-menu": "2.1.16",
2216
+
"@radix-ui/react-primitive": "2.1.3",
2217
+
"@radix-ui/react-use-callback-ref": "1.1.1",
2218
+
"@radix-ui/react-use-controllable-state": "1.2.2"
2219
+
},
2220
+
"peerDependencies": {
2221
+
"@types/react": "*",
2222
+
"@types/react-dom": "*",
2223
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2224
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2225
+
},
2226
+
"peerDependenciesMeta": {
2227
+
"@types/react": {
2228
+
"optional": true
2229
+
},
2230
+
"@types/react-dom": {
2231
+
"optional": true
2232
+
}
2233
+
}
2234
+
},
2235
+
"node_modules/@radix-ui/react-dialog": {
2236
+
"version": "1.1.15",
2237
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
2238
+
"integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
2239
+
"dependencies": {
2240
+
"@radix-ui/primitive": "1.1.3",
2241
+
"@radix-ui/react-compose-refs": "1.1.2",
2242
+
"@radix-ui/react-context": "1.1.2",
2243
+
"@radix-ui/react-dismissable-layer": "1.1.11",
2244
+
"@radix-ui/react-focus-guards": "1.1.3",
2245
+
"@radix-ui/react-focus-scope": "1.1.7",
2246
+
"@radix-ui/react-id": "1.1.1",
2247
+
"@radix-ui/react-portal": "1.1.9",
2248
+
"@radix-ui/react-presence": "1.1.5",
2249
+
"@radix-ui/react-primitive": "2.1.3",
2250
+
"@radix-ui/react-slot": "1.2.3",
2251
+
"@radix-ui/react-use-controllable-state": "1.2.2",
2252
+
"aria-hidden": "^1.2.4",
2253
+
"react-remove-scroll": "^2.6.3"
2254
+
},
2255
+
"peerDependencies": {
2256
+
"@types/react": "*",
2257
+
"@types/react-dom": "*",
2258
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2259
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2260
+
},
2261
+
"peerDependenciesMeta": {
2262
+
"@types/react": {
2263
+
"optional": true
2264
+
},
2265
+
"@types/react-dom": {
2266
+
"optional": true
2267
+
}
2268
+
}
2269
+
},
2270
+
"node_modules/@radix-ui/react-direction": {
2271
+
"version": "1.1.1",
2272
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
2273
+
"integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
2274
+
"peerDependencies": {
2275
+
"@types/react": "*",
2276
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2277
+
},
2278
+
"peerDependenciesMeta": {
2279
+
"@types/react": {
2280
+
"optional": true
2281
+
}
2282
+
}
2283
+
},
2284
+
"node_modules/@radix-ui/react-dismissable-layer": {
2285
+
"version": "1.1.11",
2286
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
2287
+
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
2288
+
"dependencies": {
2289
+
"@radix-ui/primitive": "1.1.3",
2290
+
"@radix-ui/react-compose-refs": "1.1.2",
2291
+
"@radix-ui/react-primitive": "2.1.3",
2292
+
"@radix-ui/react-use-callback-ref": "1.1.1",
2293
+
"@radix-ui/react-use-escape-keydown": "1.1.1"
2294
+
},
2295
+
"peerDependencies": {
2296
+
"@types/react": "*",
2297
+
"@types/react-dom": "*",
2298
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2299
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2300
+
},
2301
+
"peerDependenciesMeta": {
2302
+
"@types/react": {
2303
+
"optional": true
2304
+
},
2305
+
"@types/react-dom": {
2306
+
"optional": true
2307
+
}
2308
+
}
2309
+
},
2310
+
"node_modules/@radix-ui/react-dropdown-menu": {
2311
+
"version": "2.1.16",
2312
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz",
2313
+
"integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==",
2314
+
"dependencies": {
2315
+
"@radix-ui/primitive": "1.1.3",
2316
+
"@radix-ui/react-compose-refs": "1.1.2",
2317
+
"@radix-ui/react-context": "1.1.2",
2318
+
"@radix-ui/react-id": "1.1.1",
2319
+
"@radix-ui/react-menu": "2.1.16",
2320
+
"@radix-ui/react-primitive": "2.1.3",
2321
+
"@radix-ui/react-use-controllable-state": "1.2.2"
2322
+
},
2323
+
"peerDependencies": {
2324
+
"@types/react": "*",
2325
+
"@types/react-dom": "*",
2326
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2327
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2328
+
},
2329
+
"peerDependenciesMeta": {
2330
+
"@types/react": {
2331
+
"optional": true
2332
+
},
2333
+
"@types/react-dom": {
2334
+
"optional": true
2335
+
}
2336
+
}
2337
+
},
2338
+
"node_modules/@radix-ui/react-focus-guards": {
2339
+
"version": "1.1.3",
2340
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
2341
+
"integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
2342
+
"peerDependencies": {
2343
+
"@types/react": "*",
2344
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2345
+
},
2346
+
"peerDependenciesMeta": {
2347
+
"@types/react": {
2348
+
"optional": true
2349
+
}
2350
+
}
2351
+
},
2352
+
"node_modules/@radix-ui/react-focus-scope": {
2353
+
"version": "1.1.7",
2354
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
2355
+
"integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
2356
+
"dependencies": {
2357
+
"@radix-ui/react-compose-refs": "1.1.2",
2358
+
"@radix-ui/react-primitive": "2.1.3",
2359
+
"@radix-ui/react-use-callback-ref": "1.1.1"
2360
+
},
2361
+
"peerDependencies": {
2362
+
"@types/react": "*",
2363
+
"@types/react-dom": "*",
2364
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2365
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2366
+
},
2367
+
"peerDependenciesMeta": {
2368
+
"@types/react": {
2369
+
"optional": true
2370
+
},
2371
+
"@types/react-dom": {
2372
+
"optional": true
2373
+
}
2374
+
}
2375
+
},
2376
+
"node_modules/@radix-ui/react-form": {
2377
+
"version": "0.1.8",
2378
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.8.tgz",
2379
+
"integrity": "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==",
2380
+
"dependencies": {
2381
+
"@radix-ui/primitive": "1.1.3",
2382
+
"@radix-ui/react-compose-refs": "1.1.2",
2383
+
"@radix-ui/react-context": "1.1.2",
2384
+
"@radix-ui/react-id": "1.1.1",
2385
+
"@radix-ui/react-label": "2.1.7",
2386
+
"@radix-ui/react-primitive": "2.1.3"
2387
+
},
2388
+
"peerDependencies": {
2389
+
"@types/react": "*",
2390
+
"@types/react-dom": "*",
2391
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2392
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2393
+
},
2394
+
"peerDependenciesMeta": {
2395
+
"@types/react": {
2396
+
"optional": true
2397
+
},
2398
+
"@types/react-dom": {
2399
+
"optional": true
2400
+
}
2401
+
}
2402
+
},
2403
+
"node_modules/@radix-ui/react-hover-card": {
2404
+
"version": "1.1.15",
2405
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz",
2406
+
"integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==",
2407
+
"license": "MIT",
2408
+
"dependencies": {
2409
+
"@radix-ui/primitive": "1.1.3",
2410
+
"@radix-ui/react-compose-refs": "1.1.2",
2411
+
"@radix-ui/react-context": "1.1.2",
2412
+
"@radix-ui/react-dismissable-layer": "1.1.11",
2413
+
"@radix-ui/react-popper": "1.2.8",
2414
+
"@radix-ui/react-portal": "1.1.9",
2415
+
"@radix-ui/react-presence": "1.1.5",
2416
+
"@radix-ui/react-primitive": "2.1.3",
2417
+
"@radix-ui/react-use-controllable-state": "1.2.2"
2418
+
},
2419
+
"peerDependencies": {
2420
+
"@types/react": "*",
2421
+
"@types/react-dom": "*",
2422
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2423
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2424
+
},
2425
+
"peerDependenciesMeta": {
2426
+
"@types/react": {
2427
+
"optional": true
2428
+
},
2429
+
"@types/react-dom": {
2430
+
"optional": true
2431
+
}
2432
+
}
2433
+
},
2434
+
"node_modules/@radix-ui/react-id": {
2435
+
"version": "1.1.1",
2436
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
2437
+
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
2438
+
"dependencies": {
2439
+
"@radix-ui/react-use-layout-effect": "1.1.1"
2440
+
},
2441
+
"peerDependencies": {
2442
+
"@types/react": "*",
2443
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2444
+
},
2445
+
"peerDependenciesMeta": {
2446
+
"@types/react": {
2447
+
"optional": true
2448
+
}
2449
+
}
2450
+
},
2451
+
"node_modules/@radix-ui/react-label": {
2452
+
"version": "2.1.7",
2453
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz",
2454
+
"integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==",
2455
+
"dependencies": {
2456
+
"@radix-ui/react-primitive": "2.1.3"
2457
+
},
2458
+
"peerDependencies": {
2459
+
"@types/react": "*",
2460
+
"@types/react-dom": "*",
2461
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2462
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2463
+
},
2464
+
"peerDependenciesMeta": {
2465
+
"@types/react": {
2466
+
"optional": true
2467
+
},
2468
+
"@types/react-dom": {
2469
+
"optional": true
2470
+
}
2471
+
}
2472
+
},
2473
+
"node_modules/@radix-ui/react-menu": {
2474
+
"version": "2.1.16",
2475
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz",
2476
+
"integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==",
2477
+
"dependencies": {
2478
+
"@radix-ui/primitive": "1.1.3",
2479
+
"@radix-ui/react-collection": "1.1.7",
2480
+
"@radix-ui/react-compose-refs": "1.1.2",
2481
+
"@radix-ui/react-context": "1.1.2",
2482
+
"@radix-ui/react-direction": "1.1.1",
2483
+
"@radix-ui/react-dismissable-layer": "1.1.11",
2484
+
"@radix-ui/react-focus-guards": "1.1.3",
2485
+
"@radix-ui/react-focus-scope": "1.1.7",
2486
+
"@radix-ui/react-id": "1.1.1",
2487
+
"@radix-ui/react-popper": "1.2.8",
2488
+
"@radix-ui/react-portal": "1.1.9",
2489
+
"@radix-ui/react-presence": "1.1.5",
2490
+
"@radix-ui/react-primitive": "2.1.3",
2491
+
"@radix-ui/react-roving-focus": "1.1.11",
2492
+
"@radix-ui/react-slot": "1.2.3",
2493
+
"@radix-ui/react-use-callback-ref": "1.1.1",
2494
+
"aria-hidden": "^1.2.4",
2495
+
"react-remove-scroll": "^2.6.3"
2496
+
},
2497
+
"peerDependencies": {
2498
+
"@types/react": "*",
2499
+
"@types/react-dom": "*",
2500
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2501
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2502
+
},
2503
+
"peerDependenciesMeta": {
2504
+
"@types/react": {
2505
+
"optional": true
2506
+
},
2507
+
"@types/react-dom": {
2508
+
"optional": true
2509
+
}
2510
+
}
2511
+
},
2512
+
"node_modules/@radix-ui/react-menubar": {
2513
+
"version": "1.1.16",
2514
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.16.tgz",
2515
+
"integrity": "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==",
2516
+
"dependencies": {
2517
+
"@radix-ui/primitive": "1.1.3",
2518
+
"@radix-ui/react-collection": "1.1.7",
2519
+
"@radix-ui/react-compose-refs": "1.1.2",
2520
+
"@radix-ui/react-context": "1.1.2",
2521
+
"@radix-ui/react-direction": "1.1.1",
2522
+
"@radix-ui/react-id": "1.1.1",
2523
+
"@radix-ui/react-menu": "2.1.16",
2524
+
"@radix-ui/react-primitive": "2.1.3",
2525
+
"@radix-ui/react-roving-focus": "1.1.11",
2526
+
"@radix-ui/react-use-controllable-state": "1.2.2"
2527
+
},
2528
+
"peerDependencies": {
2529
+
"@types/react": "*",
2530
+
"@types/react-dom": "*",
2531
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2532
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2533
+
},
2534
+
"peerDependenciesMeta": {
2535
+
"@types/react": {
2536
+
"optional": true
2537
+
},
2538
+
"@types/react-dom": {
2539
+
"optional": true
2540
+
}
2541
+
}
2542
+
},
2543
+
"node_modules/@radix-ui/react-navigation-menu": {
2544
+
"version": "1.2.14",
2545
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz",
2546
+
"integrity": "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==",
2547
+
"dependencies": {
2548
+
"@radix-ui/primitive": "1.1.3",
2549
+
"@radix-ui/react-collection": "1.1.7",
2550
+
"@radix-ui/react-compose-refs": "1.1.2",
2551
+
"@radix-ui/react-context": "1.1.2",
2552
+
"@radix-ui/react-direction": "1.1.1",
2553
+
"@radix-ui/react-dismissable-layer": "1.1.11",
2554
+
"@radix-ui/react-id": "1.1.1",
2555
+
"@radix-ui/react-presence": "1.1.5",
2556
+
"@radix-ui/react-primitive": "2.1.3",
2557
+
"@radix-ui/react-use-callback-ref": "1.1.1",
2558
+
"@radix-ui/react-use-controllable-state": "1.2.2",
2559
+
"@radix-ui/react-use-layout-effect": "1.1.1",
2560
+
"@radix-ui/react-use-previous": "1.1.1",
2561
+
"@radix-ui/react-visually-hidden": "1.2.3"
2562
+
},
2563
+
"peerDependencies": {
2564
+
"@types/react": "*",
2565
+
"@types/react-dom": "*",
2566
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2567
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2568
+
},
2569
+
"peerDependenciesMeta": {
2570
+
"@types/react": {
2571
+
"optional": true
2572
+
},
2573
+
"@types/react-dom": {
2574
+
"optional": true
2575
+
}
2576
+
}
2577
+
},
2578
+
"node_modules/@radix-ui/react-one-time-password-field": {
2579
+
"version": "0.1.8",
2580
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.8.tgz",
2581
+
"integrity": "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==",
2582
+
"dependencies": {
2583
+
"@radix-ui/number": "1.1.1",
2584
+
"@radix-ui/primitive": "1.1.3",
2585
+
"@radix-ui/react-collection": "1.1.7",
2586
+
"@radix-ui/react-compose-refs": "1.1.2",
2587
+
"@radix-ui/react-context": "1.1.2",
2588
+
"@radix-ui/react-direction": "1.1.1",
2589
+
"@radix-ui/react-primitive": "2.1.3",
2590
+
"@radix-ui/react-roving-focus": "1.1.11",
2591
+
"@radix-ui/react-use-controllable-state": "1.2.2",
2592
+
"@radix-ui/react-use-effect-event": "0.0.2",
2593
+
"@radix-ui/react-use-is-hydrated": "0.1.0",
2594
+
"@radix-ui/react-use-layout-effect": "1.1.1"
2595
+
},
2596
+
"peerDependencies": {
2597
+
"@types/react": "*",
2598
+
"@types/react-dom": "*",
2599
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2600
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2601
+
},
2602
+
"peerDependenciesMeta": {
2603
+
"@types/react": {
2604
+
"optional": true
2605
+
},
2606
+
"@types/react-dom": {
2607
+
"optional": true
2608
+
}
2609
+
}
2610
+
},
2611
+
"node_modules/@radix-ui/react-password-toggle-field": {
2612
+
"version": "0.1.3",
2613
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.3.tgz",
2614
+
"integrity": "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==",
2615
+
"dependencies": {
2616
+
"@radix-ui/primitive": "1.1.3",
2617
+
"@radix-ui/react-compose-refs": "1.1.2",
2618
+
"@radix-ui/react-context": "1.1.2",
2619
+
"@radix-ui/react-id": "1.1.1",
2620
+
"@radix-ui/react-primitive": "2.1.3",
2621
+
"@radix-ui/react-use-controllable-state": "1.2.2",
2622
+
"@radix-ui/react-use-effect-event": "0.0.2",
2623
+
"@radix-ui/react-use-is-hydrated": "0.1.0"
2624
+
},
2625
+
"peerDependencies": {
2626
+
"@types/react": "*",
2627
+
"@types/react-dom": "*",
2628
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2629
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2630
+
},
2631
+
"peerDependenciesMeta": {
2632
+
"@types/react": {
2633
+
"optional": true
2634
+
},
2635
+
"@types/react-dom": {
2636
+
"optional": true
2637
+
}
2638
+
}
2639
+
},
2640
+
"node_modules/@radix-ui/react-popover": {
2641
+
"version": "1.1.15",
2642
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
2643
+
"integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==",
2644
+
"dependencies": {
2645
+
"@radix-ui/primitive": "1.1.3",
2646
+
"@radix-ui/react-compose-refs": "1.1.2",
2647
+
"@radix-ui/react-context": "1.1.2",
2648
+
"@radix-ui/react-dismissable-layer": "1.1.11",
2649
+
"@radix-ui/react-focus-guards": "1.1.3",
2650
+
"@radix-ui/react-focus-scope": "1.1.7",
2651
+
"@radix-ui/react-id": "1.1.1",
2652
+
"@radix-ui/react-popper": "1.2.8",
2653
+
"@radix-ui/react-portal": "1.1.9",
2654
+
"@radix-ui/react-presence": "1.1.5",
2655
+
"@radix-ui/react-primitive": "2.1.3",
2656
+
"@radix-ui/react-slot": "1.2.3",
2657
+
"@radix-ui/react-use-controllable-state": "1.2.2",
2658
+
"aria-hidden": "^1.2.4",
2659
+
"react-remove-scroll": "^2.6.3"
2660
+
},
2661
+
"peerDependencies": {
2662
+
"@types/react": "*",
2663
+
"@types/react-dom": "*",
2664
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2665
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2666
+
},
2667
+
"peerDependenciesMeta": {
2668
+
"@types/react": {
2669
+
"optional": true
2670
+
},
2671
+
"@types/react-dom": {
2672
+
"optional": true
2673
+
}
2674
+
}
2675
+
},
2676
+
"node_modules/@radix-ui/react-popper": {
2677
+
"version": "1.2.8",
2678
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
2679
+
"integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
2680
+
"dependencies": {
2681
+
"@floating-ui/react-dom": "^2.0.0",
2682
+
"@radix-ui/react-arrow": "1.1.7",
2683
+
"@radix-ui/react-compose-refs": "1.1.2",
2684
+
"@radix-ui/react-context": "1.1.2",
2685
+
"@radix-ui/react-primitive": "2.1.3",
2686
+
"@radix-ui/react-use-callback-ref": "1.1.1",
2687
+
"@radix-ui/react-use-layout-effect": "1.1.1",
2688
+
"@radix-ui/react-use-rect": "1.1.1",
2689
+
"@radix-ui/react-use-size": "1.1.1",
2690
+
"@radix-ui/rect": "1.1.1"
2691
+
},
2692
+
"peerDependencies": {
2693
+
"@types/react": "*",
2694
+
"@types/react-dom": "*",
2695
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2696
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2697
+
},
2698
+
"peerDependenciesMeta": {
2699
+
"@types/react": {
2700
+
"optional": true
2701
+
},
2702
+
"@types/react-dom": {
2703
+
"optional": true
2704
+
}
2705
+
}
2706
+
},
2707
+
"node_modules/@radix-ui/react-portal": {
2708
+
"version": "1.1.9",
2709
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
2710
+
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
2711
+
"dependencies": {
2712
+
"@radix-ui/react-primitive": "2.1.3",
2713
+
"@radix-ui/react-use-layout-effect": "1.1.1"
2714
+
},
2715
+
"peerDependencies": {
2716
+
"@types/react": "*",
2717
+
"@types/react-dom": "*",
2718
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2719
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2720
+
},
2721
+
"peerDependenciesMeta": {
2722
+
"@types/react": {
2723
+
"optional": true
2724
+
},
2725
+
"@types/react-dom": {
2726
+
"optional": true
2727
+
}
2728
+
}
2729
+
},
2730
+
"node_modules/@radix-ui/react-presence": {
2731
+
"version": "1.1.5",
2732
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
2733
+
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
2734
+
"dependencies": {
2735
+
"@radix-ui/react-compose-refs": "1.1.2",
2736
+
"@radix-ui/react-use-layout-effect": "1.1.1"
2737
+
},
2738
+
"peerDependencies": {
2739
+
"@types/react": "*",
2740
+
"@types/react-dom": "*",
2741
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2742
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2743
+
},
2744
+
"peerDependenciesMeta": {
2745
+
"@types/react": {
2746
+
"optional": true
2747
+
},
2748
+
"@types/react-dom": {
2749
+
"optional": true
2750
+
}
2751
+
}
2752
+
},
2753
+
"node_modules/@radix-ui/react-primitive": {
2754
+
"version": "2.1.3",
2755
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
2756
+
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
2757
+
"dependencies": {
2758
+
"@radix-ui/react-slot": "1.2.3"
2759
+
},
2760
+
"peerDependencies": {
2761
+
"@types/react": "*",
2762
+
"@types/react-dom": "*",
2763
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2764
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2765
+
},
2766
+
"peerDependenciesMeta": {
2767
+
"@types/react": {
2768
+
"optional": true
2769
+
},
2770
+
"@types/react-dom": {
2771
+
"optional": true
2772
+
}
2773
+
}
2774
+
},
2775
+
"node_modules/@radix-ui/react-progress": {
2776
+
"version": "1.1.7",
2777
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz",
2778
+
"integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==",
2779
+
"dependencies": {
2780
+
"@radix-ui/react-context": "1.1.2",
2781
+
"@radix-ui/react-primitive": "2.1.3"
2782
+
},
2783
+
"peerDependencies": {
2784
+
"@types/react": "*",
2785
+
"@types/react-dom": "*",
2786
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2787
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2788
+
},
2789
+
"peerDependenciesMeta": {
2790
+
"@types/react": {
2791
+
"optional": true
2792
+
},
2793
+
"@types/react-dom": {
2794
+
"optional": true
2795
+
}
2796
+
}
2797
+
},
2798
+
"node_modules/@radix-ui/react-radio-group": {
2799
+
"version": "1.3.8",
2800
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz",
2801
+
"integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==",
2802
+
"dependencies": {
2803
+
"@radix-ui/primitive": "1.1.3",
2804
+
"@radix-ui/react-compose-refs": "1.1.2",
2805
+
"@radix-ui/react-context": "1.1.2",
2806
+
"@radix-ui/react-direction": "1.1.1",
2807
+
"@radix-ui/react-presence": "1.1.5",
2808
+
"@radix-ui/react-primitive": "2.1.3",
2809
+
"@radix-ui/react-roving-focus": "1.1.11",
2810
+
"@radix-ui/react-use-controllable-state": "1.2.2",
2811
+
"@radix-ui/react-use-previous": "1.1.1",
2812
+
"@radix-ui/react-use-size": "1.1.1"
2813
+
},
2814
+
"peerDependencies": {
2815
+
"@types/react": "*",
2816
+
"@types/react-dom": "*",
2817
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2818
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2819
+
},
2820
+
"peerDependenciesMeta": {
2821
+
"@types/react": {
2822
+
"optional": true
2823
+
},
2824
+
"@types/react-dom": {
2825
+
"optional": true
2826
+
}
2827
+
}
2828
+
},
2829
+
"node_modules/@radix-ui/react-roving-focus": {
2830
+
"version": "1.1.11",
2831
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
2832
+
"integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
2833
+
"dependencies": {
2834
+
"@radix-ui/primitive": "1.1.3",
2835
+
"@radix-ui/react-collection": "1.1.7",
2836
+
"@radix-ui/react-compose-refs": "1.1.2",
2837
+
"@radix-ui/react-context": "1.1.2",
2838
+
"@radix-ui/react-direction": "1.1.1",
2839
+
"@radix-ui/react-id": "1.1.1",
2840
+
"@radix-ui/react-primitive": "2.1.3",
2841
+
"@radix-ui/react-use-callback-ref": "1.1.1",
2842
+
"@radix-ui/react-use-controllable-state": "1.2.2"
2843
+
},
2844
+
"peerDependencies": {
2845
+
"@types/react": "*",
2846
+
"@types/react-dom": "*",
2847
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2848
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2849
+
},
2850
+
"peerDependenciesMeta": {
2851
+
"@types/react": {
2852
+
"optional": true
2853
+
},
2854
+
"@types/react-dom": {
2855
+
"optional": true
2856
+
}
2857
+
}
2858
+
},
2859
+
"node_modules/@radix-ui/react-scroll-area": {
2860
+
"version": "1.2.10",
2861
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz",
2862
+
"integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==",
2863
+
"dependencies": {
2864
+
"@radix-ui/number": "1.1.1",
2865
+
"@radix-ui/primitive": "1.1.3",
2866
+
"@radix-ui/react-compose-refs": "1.1.2",
2867
+
"@radix-ui/react-context": "1.1.2",
2868
+
"@radix-ui/react-direction": "1.1.1",
2869
+
"@radix-ui/react-presence": "1.1.5",
2870
+
"@radix-ui/react-primitive": "2.1.3",
2871
+
"@radix-ui/react-use-callback-ref": "1.1.1",
2872
+
"@radix-ui/react-use-layout-effect": "1.1.1"
2873
+
},
2874
+
"peerDependencies": {
2875
+
"@types/react": "*",
2876
+
"@types/react-dom": "*",
2877
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2878
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2879
+
},
2880
+
"peerDependenciesMeta": {
2881
+
"@types/react": {
2882
+
"optional": true
2883
+
},
2884
+
"@types/react-dom": {
2885
+
"optional": true
2886
+
}
2887
+
}
2888
+
},
2889
+
"node_modules/@radix-ui/react-select": {
2890
+
"version": "2.2.6",
2891
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
2892
+
"integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==",
2893
+
"dependencies": {
2894
+
"@radix-ui/number": "1.1.1",
2895
+
"@radix-ui/primitive": "1.1.3",
2896
+
"@radix-ui/react-collection": "1.1.7",
2897
+
"@radix-ui/react-compose-refs": "1.1.2",
2898
+
"@radix-ui/react-context": "1.1.2",
2899
+
"@radix-ui/react-direction": "1.1.1",
2900
+
"@radix-ui/react-dismissable-layer": "1.1.11",
2901
+
"@radix-ui/react-focus-guards": "1.1.3",
2902
+
"@radix-ui/react-focus-scope": "1.1.7",
2903
+
"@radix-ui/react-id": "1.1.1",
2904
+
"@radix-ui/react-popper": "1.2.8",
2905
+
"@radix-ui/react-portal": "1.1.9",
2906
+
"@radix-ui/react-primitive": "2.1.3",
2907
+
"@radix-ui/react-slot": "1.2.3",
2908
+
"@radix-ui/react-use-callback-ref": "1.1.1",
2909
+
"@radix-ui/react-use-controllable-state": "1.2.2",
2910
+
"@radix-ui/react-use-layout-effect": "1.1.1",
2911
+
"@radix-ui/react-use-previous": "1.1.1",
2912
+
"@radix-ui/react-visually-hidden": "1.2.3",
2913
+
"aria-hidden": "^1.2.4",
2914
+
"react-remove-scroll": "^2.6.3"
2915
+
},
2916
+
"peerDependencies": {
2917
+
"@types/react": "*",
2918
+
"@types/react-dom": "*",
2919
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2920
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2921
+
},
2922
+
"peerDependenciesMeta": {
2923
+
"@types/react": {
2924
+
"optional": true
2925
+
},
2926
+
"@types/react-dom": {
2927
+
"optional": true
2928
+
}
2929
+
}
2930
+
},
2931
+
"node_modules/@radix-ui/react-separator": {
2932
+
"version": "1.1.7",
2933
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz",
2934
+
"integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==",
2935
+
"dependencies": {
2936
+
"@radix-ui/react-primitive": "2.1.3"
2937
+
},
2938
+
"peerDependencies": {
2939
+
"@types/react": "*",
2940
+
"@types/react-dom": "*",
2941
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2942
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2943
+
},
2944
+
"peerDependenciesMeta": {
2945
+
"@types/react": {
2946
+
"optional": true
2947
+
},
2948
+
"@types/react-dom": {
2949
+
"optional": true
2950
+
}
2951
+
}
2952
+
},
2953
+
"node_modules/@radix-ui/react-slider": {
2954
+
"version": "1.3.6",
2955
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz",
2956
+
"integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==",
2957
+
"dependencies": {
2958
+
"@radix-ui/number": "1.1.1",
2959
+
"@radix-ui/primitive": "1.1.3",
2960
+
"@radix-ui/react-collection": "1.1.7",
2961
+
"@radix-ui/react-compose-refs": "1.1.2",
2962
+
"@radix-ui/react-context": "1.1.2",
2963
+
"@radix-ui/react-direction": "1.1.1",
2964
+
"@radix-ui/react-primitive": "2.1.3",
2965
+
"@radix-ui/react-use-controllable-state": "1.2.2",
2966
+
"@radix-ui/react-use-layout-effect": "1.1.1",
2967
+
"@radix-ui/react-use-previous": "1.1.1",
2968
+
"@radix-ui/react-use-size": "1.1.1"
2969
+
},
2970
+
"peerDependencies": {
2971
+
"@types/react": "*",
2972
+
"@types/react-dom": "*",
2973
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2974
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2975
+
},
2976
+
"peerDependenciesMeta": {
2977
+
"@types/react": {
2978
+
"optional": true
2979
+
},
2980
+
"@types/react-dom": {
2981
+
"optional": true
2982
+
}
2983
+
}
2984
+
},
2985
+
"node_modules/@radix-ui/react-slot": {
2986
+
"version": "1.2.3",
2987
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
2988
+
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
2989
+
"dependencies": {
2990
+
"@radix-ui/react-compose-refs": "1.1.2"
2991
+
},
2992
+
"peerDependencies": {
2993
+
"@types/react": "*",
2994
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2995
+
},
2996
+
"peerDependenciesMeta": {
2997
+
"@types/react": {
2998
+
"optional": true
2999
+
}
3000
+
}
3001
+
},
3002
+
"node_modules/@radix-ui/react-switch": {
3003
+
"version": "1.2.6",
3004
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
3005
+
"integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
3006
+
"dependencies": {
3007
+
"@radix-ui/primitive": "1.1.3",
3008
+
"@radix-ui/react-compose-refs": "1.1.2",
3009
+
"@radix-ui/react-context": "1.1.2",
3010
+
"@radix-ui/react-primitive": "2.1.3",
3011
+
"@radix-ui/react-use-controllable-state": "1.2.2",
3012
+
"@radix-ui/react-use-previous": "1.1.1",
3013
+
"@radix-ui/react-use-size": "1.1.1"
3014
+
},
3015
+
"peerDependencies": {
3016
+
"@types/react": "*",
3017
+
"@types/react-dom": "*",
3018
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
3019
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
3020
+
},
3021
+
"peerDependenciesMeta": {
3022
+
"@types/react": {
3023
+
"optional": true
3024
+
},
3025
+
"@types/react-dom": {
3026
+
"optional": true
3027
+
}
3028
+
}
3029
+
},
3030
+
"node_modules/@radix-ui/react-tabs": {
3031
+
"version": "1.1.13",
3032
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
3033
+
"integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==",
3034
+
"dependencies": {
3035
+
"@radix-ui/primitive": "1.1.3",
3036
+
"@radix-ui/react-context": "1.1.2",
3037
+
"@radix-ui/react-direction": "1.1.1",
3038
+
"@radix-ui/react-id": "1.1.1",
3039
+
"@radix-ui/react-presence": "1.1.5",
3040
+
"@radix-ui/react-primitive": "2.1.3",
3041
+
"@radix-ui/react-roving-focus": "1.1.11",
3042
+
"@radix-ui/react-use-controllable-state": "1.2.2"
3043
+
},
3044
+
"peerDependencies": {
3045
+
"@types/react": "*",
3046
+
"@types/react-dom": "*",
3047
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
3048
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
3049
+
},
3050
+
"peerDependenciesMeta": {
3051
+
"@types/react": {
3052
+
"optional": true
3053
+
},
3054
+
"@types/react-dom": {
3055
+
"optional": true
3056
+
}
3057
+
}
3058
+
},
3059
+
"node_modules/@radix-ui/react-toast": {
3060
+
"version": "1.2.15",
3061
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz",
3062
+
"integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==",
3063
+
"dependencies": {
3064
+
"@radix-ui/primitive": "1.1.3",
3065
+
"@radix-ui/react-collection": "1.1.7",
3066
+
"@radix-ui/react-compose-refs": "1.1.2",
3067
+
"@radix-ui/react-context": "1.1.2",
3068
+
"@radix-ui/react-dismissable-layer": "1.1.11",
3069
+
"@radix-ui/react-portal": "1.1.9",
3070
+
"@radix-ui/react-presence": "1.1.5",
3071
+
"@radix-ui/react-primitive": "2.1.3",
3072
+
"@radix-ui/react-use-callback-ref": "1.1.1",
3073
+
"@radix-ui/react-use-controllable-state": "1.2.2",
3074
+
"@radix-ui/react-use-layout-effect": "1.1.1",
3075
+
"@radix-ui/react-visually-hidden": "1.2.3"
3076
+
},
3077
+
"peerDependencies": {
3078
+
"@types/react": "*",
3079
+
"@types/react-dom": "*",
3080
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
3081
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
3082
+
},
3083
+
"peerDependenciesMeta": {
3084
+
"@types/react": {
3085
+
"optional": true
3086
+
},
3087
+
"@types/react-dom": {
3088
+
"optional": true
3089
+
}
3090
+
}
3091
+
},
3092
+
"node_modules/@radix-ui/react-toggle": {
3093
+
"version": "1.1.10",
3094
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz",
3095
+
"integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==",
3096
+
"dependencies": {
3097
+
"@radix-ui/primitive": "1.1.3",
3098
+
"@radix-ui/react-primitive": "2.1.3",
3099
+
"@radix-ui/react-use-controllable-state": "1.2.2"
3100
+
},
3101
+
"peerDependencies": {
3102
+
"@types/react": "*",
3103
+
"@types/react-dom": "*",
3104
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
3105
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
3106
+
},
3107
+
"peerDependenciesMeta": {
3108
+
"@types/react": {
3109
+
"optional": true
3110
+
},
3111
+
"@types/react-dom": {
3112
+
"optional": true
3113
+
}
3114
+
}
3115
+
},
3116
+
"node_modules/@radix-ui/react-toggle-group": {
3117
+
"version": "1.1.11",
3118
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz",
3119
+
"integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==",
3120
+
"dependencies": {
3121
+
"@radix-ui/primitive": "1.1.3",
3122
+
"@radix-ui/react-context": "1.1.2",
3123
+
"@radix-ui/react-direction": "1.1.1",
3124
+
"@radix-ui/react-primitive": "2.1.3",
3125
+
"@radix-ui/react-roving-focus": "1.1.11",
3126
+
"@radix-ui/react-toggle": "1.1.10",
3127
+
"@radix-ui/react-use-controllable-state": "1.2.2"
3128
+
},
3129
+
"peerDependencies": {
3130
+
"@types/react": "*",
3131
+
"@types/react-dom": "*",
3132
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
3133
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
3134
+
},
3135
+
"peerDependenciesMeta": {
3136
+
"@types/react": {
3137
+
"optional": true
3138
+
},
3139
+
"@types/react-dom": {
3140
+
"optional": true
3141
+
}
3142
+
}
3143
+
},
3144
+
"node_modules/@radix-ui/react-toolbar": {
3145
+
"version": "1.1.11",
3146
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.11.tgz",
3147
+
"integrity": "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==",
3148
+
"dependencies": {
3149
+
"@radix-ui/primitive": "1.1.3",
3150
+
"@radix-ui/react-context": "1.1.2",
3151
+
"@radix-ui/react-direction": "1.1.1",
3152
+
"@radix-ui/react-primitive": "2.1.3",
3153
+
"@radix-ui/react-roving-focus": "1.1.11",
3154
+
"@radix-ui/react-separator": "1.1.7",
3155
+
"@radix-ui/react-toggle-group": "1.1.11"
3156
+
},
3157
+
"peerDependencies": {
3158
+
"@types/react": "*",
3159
+
"@types/react-dom": "*",
3160
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
3161
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
3162
+
},
3163
+
"peerDependenciesMeta": {
3164
+
"@types/react": {
3165
+
"optional": true
3166
+
},
3167
+
"@types/react-dom": {
3168
+
"optional": true
3169
+
}
3170
+
}
3171
+
},
3172
+
"node_modules/@radix-ui/react-tooltip": {
3173
+
"version": "1.2.8",
3174
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
3175
+
"integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
3176
+
"dependencies": {
3177
+
"@radix-ui/primitive": "1.1.3",
3178
+
"@radix-ui/react-compose-refs": "1.1.2",
3179
+
"@radix-ui/react-context": "1.1.2",
3180
+
"@radix-ui/react-dismissable-layer": "1.1.11",
3181
+
"@radix-ui/react-id": "1.1.1",
3182
+
"@radix-ui/react-popper": "1.2.8",
3183
+
"@radix-ui/react-portal": "1.1.9",
3184
+
"@radix-ui/react-presence": "1.1.5",
3185
+
"@radix-ui/react-primitive": "2.1.3",
3186
+
"@radix-ui/react-slot": "1.2.3",
3187
+
"@radix-ui/react-use-controllable-state": "1.2.2",
3188
+
"@radix-ui/react-visually-hidden": "1.2.3"
3189
+
},
3190
+
"peerDependencies": {
3191
+
"@types/react": "*",
3192
+
"@types/react-dom": "*",
3193
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
3194
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
3195
+
},
3196
+
"peerDependenciesMeta": {
3197
+
"@types/react": {
3198
+
"optional": true
3199
+
},
3200
+
"@types/react-dom": {
3201
+
"optional": true
3202
+
}
3203
+
}
3204
+
},
3205
+
"node_modules/@radix-ui/react-use-callback-ref": {
3206
+
"version": "1.1.1",
3207
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
3208
+
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
3209
+
"peerDependencies": {
3210
+
"@types/react": "*",
3211
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
3212
+
},
3213
+
"peerDependenciesMeta": {
3214
+
"@types/react": {
3215
+
"optional": true
3216
+
}
3217
+
}
3218
+
},
3219
+
"node_modules/@radix-ui/react-use-controllable-state": {
3220
+
"version": "1.2.2",
3221
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
3222
+
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
3223
+
"dependencies": {
3224
+
"@radix-ui/react-use-effect-event": "0.0.2",
3225
+
"@radix-ui/react-use-layout-effect": "1.1.1"
3226
+
},
3227
+
"peerDependencies": {
3228
+
"@types/react": "*",
3229
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
3230
+
},
3231
+
"peerDependenciesMeta": {
3232
+
"@types/react": {
3233
+
"optional": true
3234
+
}
3235
+
}
3236
+
},
3237
+
"node_modules/@radix-ui/react-use-effect-event": {
3238
+
"version": "0.0.2",
3239
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
3240
+
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
3241
+
"dependencies": {
3242
+
"@radix-ui/react-use-layout-effect": "1.1.1"
3243
+
},
3244
+
"peerDependencies": {
3245
+
"@types/react": "*",
3246
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
3247
+
},
3248
+
"peerDependenciesMeta": {
3249
+
"@types/react": {
3250
+
"optional": true
3251
+
}
3252
+
}
3253
+
},
3254
+
"node_modules/@radix-ui/react-use-escape-keydown": {
3255
+
"version": "1.1.1",
3256
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
3257
+
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
3258
+
"dependencies": {
3259
+
"@radix-ui/react-use-callback-ref": "1.1.1"
3260
+
},
3261
+
"peerDependencies": {
3262
+
"@types/react": "*",
3263
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
3264
+
},
3265
+
"peerDependenciesMeta": {
3266
+
"@types/react": {
3267
+
"optional": true
3268
+
}
3269
+
}
3270
+
},
3271
+
"node_modules/@radix-ui/react-use-is-hydrated": {
3272
+
"version": "0.1.0",
3273
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz",
3274
+
"integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==",
3275
+
"dependencies": {
3276
+
"use-sync-external-store": "^1.5.0"
3277
+
},
3278
+
"peerDependencies": {
3279
+
"@types/react": "*",
3280
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
3281
+
},
3282
+
"peerDependenciesMeta": {
3283
+
"@types/react": {
3284
+
"optional": true
3285
+
}
3286
+
}
3287
+
},
3288
+
"node_modules/@radix-ui/react-use-layout-effect": {
3289
+
"version": "1.1.1",
3290
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
3291
+
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
3292
+
"peerDependencies": {
3293
+
"@types/react": "*",
3294
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
3295
+
},
3296
+
"peerDependenciesMeta": {
3297
+
"@types/react": {
3298
+
"optional": true
3299
+
}
3300
+
}
3301
+
},
3302
+
"node_modules/@radix-ui/react-use-previous": {
3303
+
"version": "1.1.1",
3304
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
3305
+
"integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
3306
+
"peerDependencies": {
3307
+
"@types/react": "*",
3308
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
3309
+
},
3310
+
"peerDependenciesMeta": {
3311
+
"@types/react": {
3312
+
"optional": true
3313
+
}
3314
+
}
3315
+
},
3316
+
"node_modules/@radix-ui/react-use-rect": {
3317
+
"version": "1.1.1",
3318
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
3319
+
"integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
3320
+
"dependencies": {
3321
+
"@radix-ui/rect": "1.1.1"
3322
+
},
3323
+
"peerDependencies": {
3324
+
"@types/react": "*",
3325
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
3326
+
},
3327
+
"peerDependenciesMeta": {
3328
+
"@types/react": {
3329
+
"optional": true
3330
+
}
3331
+
}
3332
+
},
3333
+
"node_modules/@radix-ui/react-use-size": {
3334
+
"version": "1.1.1",
3335
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
3336
+
"integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
3337
+
"dependencies": {
3338
+
"@radix-ui/react-use-layout-effect": "1.1.1"
3339
+
},
3340
+
"peerDependencies": {
3341
+
"@types/react": "*",
3342
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
3343
+
},
3344
+
"peerDependenciesMeta": {
3345
+
"@types/react": {
3346
+
"optional": true
3347
+
}
3348
+
}
3349
+
},
3350
+
"node_modules/@radix-ui/react-visually-hidden": {
3351
+
"version": "1.2.3",
3352
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
3353
+
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
3354
+
"dependencies": {
3355
+
"@radix-ui/react-primitive": "2.1.3"
3356
+
},
3357
+
"peerDependencies": {
3358
+
"@types/react": "*",
3359
+
"@types/react-dom": "*",
3360
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
3361
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
3362
+
},
3363
+
"peerDependenciesMeta": {
3364
+
"@types/react": {
3365
+
"optional": true
3366
+
},
3367
+
"@types/react-dom": {
3368
+
"optional": true
3369
+
}
3370
+
}
3371
+
},
3372
+
"node_modules/@radix-ui/rect": {
3373
+
"version": "1.1.1",
3374
+
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
3375
+
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="
3376
+
},
1898
3377
"node_modules/@rolldown/pluginutils": {
1899
3378
"version": "1.0.0-beta.27",
1900
3379
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
···
3807
5286
"dev": true,
3808
5287
"license": "Python-2.0"
3809
5288
},
5289
+
"node_modules/aria-hidden": {
5290
+
"version": "1.2.6",
5291
+
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
5292
+
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
5293
+
"dependencies": {
5294
+
"tslib": "^2.0.0"
5295
+
},
5296
+
"engines": {
5297
+
"node": ">=10"
5298
+
}
5299
+
},
3810
5300
"node_modules/aria-query": {
3811
5301
"version": "5.3.0",
3812
5302
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
···
4715
6205
"engines": {
4716
6206
"node": ">=8"
4717
6207
}
6208
+
},
6209
+
"node_modules/detect-node-es": {
6210
+
"version": "1.1.0",
6211
+
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
6212
+
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="
4718
6213
},
4719
6214
"node_modules/diff": {
4720
6215
"version": "8.0.2",
···
5735
7230
},
5736
7231
"funding": {
5737
7232
"url": "https://github.com/sponsors/ljharb"
7233
+
}
7234
+
},
7235
+
"node_modules/get-nonce": {
7236
+
"version": "1.0.1",
7237
+
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
7238
+
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
7239
+
"engines": {
7240
+
"node": ">=6"
5738
7241
}
5739
7242
},
5740
7243
"node_modules/get-proto": {
···
10338
11841
],
10339
11842
"license": "MIT"
10340
11843
},
11844
+
"node_modules/radix-ui": {
11845
+
"version": "1.4.3",
11846
+
"resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz",
11847
+
"integrity": "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==",
11848
+
"dependencies": {
11849
+
"@radix-ui/primitive": "1.1.3",
11850
+
"@radix-ui/react-accessible-icon": "1.1.7",
11851
+
"@radix-ui/react-accordion": "1.2.12",
11852
+
"@radix-ui/react-alert-dialog": "1.1.15",
11853
+
"@radix-ui/react-arrow": "1.1.7",
11854
+
"@radix-ui/react-aspect-ratio": "1.1.7",
11855
+
"@radix-ui/react-avatar": "1.1.10",
11856
+
"@radix-ui/react-checkbox": "1.3.3",
11857
+
"@radix-ui/react-collapsible": "1.1.12",
11858
+
"@radix-ui/react-collection": "1.1.7",
11859
+
"@radix-ui/react-compose-refs": "1.1.2",
11860
+
"@radix-ui/react-context": "1.1.2",
11861
+
"@radix-ui/react-context-menu": "2.2.16",
11862
+
"@radix-ui/react-dialog": "1.1.15",
11863
+
"@radix-ui/react-direction": "1.1.1",
11864
+
"@radix-ui/react-dismissable-layer": "1.1.11",
11865
+
"@radix-ui/react-dropdown-menu": "2.1.16",
11866
+
"@radix-ui/react-focus-guards": "1.1.3",
11867
+
"@radix-ui/react-focus-scope": "1.1.7",
11868
+
"@radix-ui/react-form": "0.1.8",
11869
+
"@radix-ui/react-hover-card": "1.1.15",
11870
+
"@radix-ui/react-label": "2.1.7",
11871
+
"@radix-ui/react-menu": "2.1.16",
11872
+
"@radix-ui/react-menubar": "1.1.16",
11873
+
"@radix-ui/react-navigation-menu": "1.2.14",
11874
+
"@radix-ui/react-one-time-password-field": "0.1.8",
11875
+
"@radix-ui/react-password-toggle-field": "0.1.3",
11876
+
"@radix-ui/react-popover": "1.1.15",
11877
+
"@radix-ui/react-popper": "1.2.8",
11878
+
"@radix-ui/react-portal": "1.1.9",
11879
+
"@radix-ui/react-presence": "1.1.5",
11880
+
"@radix-ui/react-primitive": "2.1.3",
11881
+
"@radix-ui/react-progress": "1.1.7",
11882
+
"@radix-ui/react-radio-group": "1.3.8",
11883
+
"@radix-ui/react-roving-focus": "1.1.11",
11884
+
"@radix-ui/react-scroll-area": "1.2.10",
11885
+
"@radix-ui/react-select": "2.2.6",
11886
+
"@radix-ui/react-separator": "1.1.7",
11887
+
"@radix-ui/react-slider": "1.3.6",
11888
+
"@radix-ui/react-slot": "1.2.3",
11889
+
"@radix-ui/react-switch": "1.2.6",
11890
+
"@radix-ui/react-tabs": "1.1.13",
11891
+
"@radix-ui/react-toast": "1.2.15",
11892
+
"@radix-ui/react-toggle": "1.1.10",
11893
+
"@radix-ui/react-toggle-group": "1.1.11",
11894
+
"@radix-ui/react-toolbar": "1.1.11",
11895
+
"@radix-ui/react-tooltip": "1.2.8",
11896
+
"@radix-ui/react-use-callback-ref": "1.1.1",
11897
+
"@radix-ui/react-use-controllable-state": "1.2.2",
11898
+
"@radix-ui/react-use-effect-event": "0.0.2",
11899
+
"@radix-ui/react-use-escape-keydown": "1.1.1",
11900
+
"@radix-ui/react-use-is-hydrated": "0.1.0",
11901
+
"@radix-ui/react-use-layout-effect": "1.1.1",
11902
+
"@radix-ui/react-use-size": "1.1.1",
11903
+
"@radix-ui/react-visually-hidden": "1.2.3"
11904
+
},
11905
+
"peerDependencies": {
11906
+
"@types/react": "*",
11907
+
"@types/react-dom": "*",
11908
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
11909
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
11910
+
},
11911
+
"peerDependenciesMeta": {
11912
+
"@types/react": {
11913
+
"optional": true
11914
+
},
11915
+
"@types/react-dom": {
11916
+
"optional": true
11917
+
}
11918
+
}
11919
+
},
10341
11920
"node_modules/react": {
10342
11921
"version": "19.1.1",
10343
11922
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
···
10397
11976
"license": "MIT",
10398
11977
"engines": {
10399
11978
"node": ">=0.10.0"
11979
+
}
11980
+
},
11981
+
"node_modules/react-remove-scroll": {
11982
+
"version": "2.7.1",
11983
+
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz",
11984
+
"integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==",
11985
+
"dependencies": {
11986
+
"react-remove-scroll-bar": "^2.3.7",
11987
+
"react-style-singleton": "^2.2.3",
11988
+
"tslib": "^2.1.0",
11989
+
"use-callback-ref": "^1.3.3",
11990
+
"use-sidecar": "^1.1.3"
11991
+
},
11992
+
"engines": {
11993
+
"node": ">=10"
11994
+
},
11995
+
"peerDependencies": {
11996
+
"@types/react": "*",
11997
+
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
11998
+
},
11999
+
"peerDependenciesMeta": {
12000
+
"@types/react": {
12001
+
"optional": true
12002
+
}
12003
+
}
12004
+
},
12005
+
"node_modules/react-remove-scroll-bar": {
12006
+
"version": "2.3.8",
12007
+
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
12008
+
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
12009
+
"dependencies": {
12010
+
"react-style-singleton": "^2.2.2",
12011
+
"tslib": "^2.0.0"
12012
+
},
12013
+
"engines": {
12014
+
"node": ">=10"
12015
+
},
12016
+
"peerDependencies": {
12017
+
"@types/react": "*",
12018
+
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
12019
+
},
12020
+
"peerDependenciesMeta": {
12021
+
"@types/react": {
12022
+
"optional": true
12023
+
}
12024
+
}
12025
+
},
12026
+
"node_modules/react-style-singleton": {
12027
+
"version": "2.2.3",
12028
+
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
12029
+
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
12030
+
"dependencies": {
12031
+
"get-nonce": "^1.0.0",
12032
+
"tslib": "^2.0.0"
12033
+
},
12034
+
"engines": {
12035
+
"node": ">=10"
12036
+
},
12037
+
"peerDependencies": {
12038
+
"@types/react": "*",
12039
+
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
12040
+
},
12041
+
"peerDependenciesMeta": {
12042
+
"@types/react": {
12043
+
"optional": true
12044
+
}
10400
12045
}
10401
12046
},
10402
12047
"node_modules/readdirp": {
···
10899
12544
"csstype": "^3.1.0",
10900
12545
"seroval": "~1.3.0",
10901
12546
"seroval-plugins": "~1.3.0"
12547
+
}
12548
+
},
12549
+
"node_modules/sonner": {
12550
+
"version": "2.0.7",
12551
+
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
12552
+
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
12553
+
"peerDependencies": {
12554
+
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
12555
+
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
10902
12556
}
10903
12557
},
10904
12558
"node_modules/source-map": {
···
11892
13546
"peer": true,
11893
13547
"dependencies": {
11894
13548
"punycode": "^2.1.0"
13549
+
}
13550
+
},
13551
+
"node_modules/use-callback-ref": {
13552
+
"version": "1.3.3",
13553
+
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
13554
+
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
13555
+
"dependencies": {
13556
+
"tslib": "^2.0.0"
13557
+
},
13558
+
"engines": {
13559
+
"node": ">=10"
13560
+
},
13561
+
"peerDependencies": {
13562
+
"@types/react": "*",
13563
+
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
13564
+
},
13565
+
"peerDependenciesMeta": {
13566
+
"@types/react": {
13567
+
"optional": true
13568
+
}
13569
+
}
13570
+
},
13571
+
"node_modules/use-sidecar": {
13572
+
"version": "1.1.3",
13573
+
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
13574
+
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
13575
+
"dependencies": {
13576
+
"detect-node-es": "^1.1.0",
13577
+
"tslib": "^2.0.0"
13578
+
},
13579
+
"engines": {
13580
+
"node": ">=10"
13581
+
},
13582
+
"peerDependencies": {
13583
+
"@types/react": "*",
13584
+
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
13585
+
},
13586
+
"peerDependenciesMeta": {
13587
+
"@types/react": {
13588
+
"optional": true
13589
+
}
11895
13590
}
11896
13591
},
11897
13592
"node_modules/use-sync-external-store": {
+6
package.json
+6
package.json
···
12
12
"dependencies": {
13
13
"@atproto/api": "^0.16.6",
14
14
"@atproto/oauth-client-browser": "^0.3.33",
15
+
"@radix-ui/react-dialog": "^1.1.15",
16
+
"@radix-ui/react-dropdown-menu": "^2.1.16",
17
+
"@radix-ui/react-hover-card": "^1.1.15",
18
+
"@radix-ui/react-slider": "^1.3.6",
15
19
"@tailwindcss/vite": "^4.0.6",
16
20
"@tanstack/query-sync-storage-persister": "^5.85.6",
17
21
"@tanstack/react-devtools": "^0.2.2",
···
25
29
"idb-keyval": "^6.2.2",
26
30
"jotai": "^2.13.1",
27
31
"npm": "^11.6.2",
32
+
"radix-ui": "^1.4.3",
28
33
"react": "^19.0.0",
29
34
"react-dom": "^19.0.0",
30
35
"react-player": "^3.3.2",
36
+
"sonner": "^2.0.7",
31
37
"tailwindcss": "^4.0.6",
32
38
"tanstack-router-keepalive": "^1.0.0"
33
39
},
+6
src/auto-imports.d.ts
+6
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 IconMdiClose: typeof import('~icons/mdi/close.jsx').default
24
+
const IconMdiMessageReplyTextOutline: typeof import('~icons/mdi/message-reply-text-outline.jsx').default
21
25
const IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline.jsx').default
26
+
const IconMdiShield: typeof import('~icons/mdi/shield.jsx').default
27
+
const IconMdiShieldOutline: typeof import('~icons/mdi/shield-outline.jsx').default
22
28
}
+292
src/components/Composer.tsx
+292
src/components/Composer.tsx
···
1
+
import { AppBskyRichtextFacet, RichText } from "@atproto/api";
2
+
import { useAtom } from "jotai";
3
+
import { Dialog } from "radix-ui";
4
+
import { useEffect, useRef, useState } from "react";
5
+
6
+
import { useAuth } from "~/providers/UnifiedAuthProvider";
7
+
import { composerAtom } from "~/utils/atoms";
8
+
import { useQueryPost } from "~/utils/useQuery";
9
+
10
+
import { ProfileThing } from "./Login";
11
+
import { UniversalPostRendererATURILoader } from "./UniversalPostRenderer";
12
+
13
+
const MAX_POST_LENGTH = 300;
14
+
15
+
export function Composer() {
16
+
const [composerState, setComposerState] = useAtom(composerAtom);
17
+
const { agent } = useAuth();
18
+
19
+
const [postText, setPostText] = useState("");
20
+
const [posting, setPosting] = useState(false);
21
+
const [postSuccess, setPostSuccess] = useState(false);
22
+
const [postError, setPostError] = useState<string | null>(null);
23
+
24
+
useEffect(() => {
25
+
setPostText("");
26
+
setPosting(false);
27
+
setPostSuccess(false);
28
+
setPostError(null);
29
+
}, [composerState.kind]);
30
+
31
+
const parentUri =
32
+
composerState.kind === "reply"
33
+
? composerState.parent
34
+
: composerState.kind === "quote"
35
+
? composerState.subject
36
+
: undefined;
37
+
38
+
const { data: parentPost, isLoading: isParentLoading } =
39
+
useQueryPost(parentUri);
40
+
41
+
async function handlePost() {
42
+
if (!agent || !postText.trim() || postText.length > MAX_POST_LENGTH) return;
43
+
44
+
setPosting(true);
45
+
setPostError(null);
46
+
47
+
try {
48
+
const rt = new RichText({ text: postText });
49
+
await rt.detectFacets(agent);
50
+
51
+
if (rt.facets?.length) {
52
+
rt.facets = rt.facets.filter((item) => {
53
+
if (item.$type !== "app.bsky.richtext.facet") return true;
54
+
if (!item.features?.length) return true;
55
+
56
+
item.features = item.features.filter((feature) => {
57
+
if (feature.$type !== "app.bsky.richtext.facet#mention") return true;
58
+
const did = feature.$type === "app.bsky.richtext.facet#mention" ? (feature as AppBskyRichtextFacet.Mention)?.did : undefined;
59
+
return typeof did === "string" && did.startsWith("did:");
60
+
});
61
+
62
+
return item.features.length > 0;
63
+
});
64
+
}
65
+
66
+
const record: Record<string, unknown> = {
67
+
$type: "app.bsky.feed.post",
68
+
text: rt.text,
69
+
facets: rt.facets,
70
+
createdAt: new Date().toISOString(),
71
+
};
72
+
73
+
if (composerState.kind === "reply" && parentPost) {
74
+
record.reply = {
75
+
root: parentPost.value?.reply?.root ?? {
76
+
uri: parentPost.uri,
77
+
cid: parentPost.cid,
78
+
},
79
+
parent: {
80
+
uri: parentPost.uri,
81
+
cid: parentPost.cid,
82
+
},
83
+
};
84
+
}
85
+
86
+
if (composerState.kind === "quote" && parentPost) {
87
+
record.embed = {
88
+
$type: "app.bsky.embed.record",
89
+
record: {
90
+
uri: parentPost.uri,
91
+
cid: parentPost.cid,
92
+
},
93
+
};
94
+
}
95
+
96
+
await agent.com.atproto.repo.createRecord({
97
+
collection: "app.bsky.feed.post",
98
+
repo: agent.assertDid,
99
+
record,
100
+
});
101
+
102
+
setPostSuccess(true);
103
+
setPostText("");
104
+
105
+
setTimeout(() => {
106
+
setPostSuccess(false);
107
+
setComposerState({ kind: "closed" });
108
+
}, 1500);
109
+
} catch (e: any) {
110
+
setPostError(e?.message || "Failed to post");
111
+
} finally {
112
+
setPosting(false);
113
+
}
114
+
}
115
+
// if (composerState.kind === "closed") {
116
+
// return null;
117
+
// }
118
+
119
+
const getPlaceholder = () => {
120
+
switch (composerState.kind) {
121
+
case "reply":
122
+
return "Post your reply";
123
+
case "quote":
124
+
return "Add a comment...";
125
+
case "root":
126
+
default:
127
+
return "What's happening?!";
128
+
}
129
+
};
130
+
131
+
const charsLeft = MAX_POST_LENGTH - postText.length;
132
+
const isPostButtonDisabled =
133
+
posting || !postText.trim() || isParentLoading || charsLeft < 0;
134
+
135
+
return (
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" />
144
+
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"
164
+
>
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>
170
+
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>
185
+
</div>
186
+
</div>
187
+
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}
208
+
/>
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
+
)}
214
+
</div>
215
+
)}
216
+
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>
230
+
</div>
231
+
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
+
)}
250
+
251
+
{postError && (
252
+
<div className="text-red-500 text-sm my-2 text-center">
253
+
{postError}
254
+
</div>
255
+
)}
256
+
</div>
257
+
)}
258
+
</div>
259
+
</Dialog.Content>
260
+
</Dialog.Portal>
261
+
</Dialog.Root>
262
+
);
263
+
}
264
+
265
+
function AutoGrowTextarea({
266
+
value,
267
+
className,
268
+
onChange,
269
+
...props
270
+
}: React.DetailedHTMLProps<
271
+
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
272
+
HTMLTextAreaElement
273
+
>) {
274
+
const ref = useRef<HTMLTextAreaElement>(null);
275
+
276
+
useEffect(() => {
277
+
const el = ref.current;
278
+
if (!el) return;
279
+
el.style.height = "auto";
280
+
el.style.height = el.scrollHeight + "px";
281
+
}, [value]);
282
+
283
+
return (
284
+
<textarea
285
+
ref={ref}
286
+
className={className}
287
+
value={value}
288
+
onChange={onChange}
289
+
{...props}
290
+
/>
291
+
);
292
+
}
+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"
+184
src/components/Import.tsx
+184
src/components/Import.tsx
···
1
+
import { AtUri } from "@atproto/api";
2
+
import { useNavigate, type UseNavigateResult } from "@tanstack/react-router";
3
+
import { useAtom } from "jotai";
4
+
import { useState } from "react";
5
+
6
+
import { useAuth } from "~/providers/UnifiedAuthProvider";
7
+
import { lycanURLAtom } from "~/utils/atoms";
8
+
import { useQueryLycanStatus } from "~/utils/useQuery";
9
+
10
+
/**
11
+
* Basically the best equivalent to Search that i can do
12
+
*/
13
+
export function Import({
14
+
optionaltextstring,
15
+
}: {
16
+
optionaltextstring?: string;
17
+
}) {
18
+
const [textInput, setTextInput] = useState<string | undefined>(
19
+
optionaltextstring
20
+
);
21
+
const navigate = useNavigate();
22
+
23
+
const { status } = useAuth();
24
+
const [lycandomain] = useAtom(lycanURLAtom);
25
+
const lycanExists = lycandomain !== "";
26
+
const { data: lycanstatusdata } = useQueryLycanStatus();
27
+
const lycanIndexed = lycanstatusdata?.status === "finished" || false;
28
+
const lycanIndexing = lycanstatusdata?.status === "in_progress" || false;
29
+
const lycanIndexingProgress = lycanIndexing
30
+
? lycanstatusdata?.progress
31
+
: undefined;
32
+
const authed = status === "signedIn";
33
+
34
+
const lycanReady = lycanExists && lycanIndexed && authed;
35
+
36
+
const handleEnter = () => {
37
+
if (!textInput) return;
38
+
handleImport({
39
+
text: textInput,
40
+
navigate,
41
+
lycanReady:
42
+
lycanReady || (!!lycanIndexingProgress && lycanIndexingProgress > 0),
43
+
});
44
+
};
45
+
46
+
const placeholder = lycanReady ? "Search..." : "Import...";
47
+
48
+
return (
49
+
<div className="w-full relative">
50
+
<IconMaterialSymbolsSearch className="w-5 h-5 absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" />
51
+
52
+
<input
53
+
type="text"
54
+
placeholder={placeholder}
55
+
value={textInput}
56
+
onChange={(e) => setTextInput(e.target.value)}
57
+
onKeyDown={(e) => {
58
+
if (e.key === "Enter") handleEnter();
59
+
}}
60
+
className="w-full h-12 pl-12 pr-4 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-500 box-border transition"
61
+
/>
62
+
</div>
63
+
);
64
+
}
65
+
66
+
function handleImport({
67
+
text,
68
+
navigate,
69
+
lycanReady,
70
+
}: {
71
+
text: string;
72
+
navigate: UseNavigateResult<string>;
73
+
lycanReady?: boolean;
74
+
}) {
75
+
const trimmed = text.trim();
76
+
// parse text
77
+
/**
78
+
* text might be
79
+
* 1. bsky dot app url (reddwarf link segments might be uri encoded,)
80
+
* 2. aturi
81
+
* 3. plain handle
82
+
* 4. plain did
83
+
*/
84
+
85
+
// 1. Check if itโs a URL
86
+
try {
87
+
const url = new URL(text);
88
+
const knownHosts = [
89
+
"bsky.app",
90
+
"social.daniela.lol",
91
+
"deer.social",
92
+
"reddwarf.whey.party",
93
+
"reddwarf.app",
94
+
"main.bsky.dev",
95
+
"catsky.social",
96
+
"blacksky.community",
97
+
"red-dwarf-social-app.whey.party",
98
+
"zeppelin.social",
99
+
];
100
+
if (knownHosts.includes(url.hostname)) {
101
+
// parse path to get URI or handle
102
+
const path = decodeURIComponent(url.pathname.slice(1)); // remove leading /
103
+
console.log("BSky URL path:", path);
104
+
navigate({
105
+
to: `/${path}`,
106
+
});
107
+
return;
108
+
}
109
+
} catch {
110
+
// not a URL, continue
111
+
}
112
+
113
+
// 2. Check if text looks like an at-uri
114
+
try {
115
+
if (text.startsWith("at://")) {
116
+
console.log("AT URI detected:", text);
117
+
const aturi = new AtUri(text);
118
+
switch (aturi.collection) {
119
+
case "app.bsky.feed.post": {
120
+
navigate({
121
+
to: "/profile/$did/post/$rkey",
122
+
params: {
123
+
did: aturi.host,
124
+
rkey: aturi.rkey,
125
+
},
126
+
});
127
+
return;
128
+
}
129
+
case "app.bsky.actor.profile": {
130
+
navigate({
131
+
to: "/profile/$did",
132
+
params: {
133
+
did: aturi.host,
134
+
},
135
+
});
136
+
return;
137
+
}
138
+
// todo add more handlers as more routes are added. like feeds, lists, etc etc thanks!
139
+
default: {
140
+
// continue
141
+
}
142
+
}
143
+
}
144
+
} catch {
145
+
// continue
146
+
}
147
+
148
+
// 3. Plain handle (starts with @)
149
+
try {
150
+
if (text.startsWith("@")) {
151
+
const handle = text.slice(1);
152
+
console.log("Handle detected:", handle);
153
+
navigate({ to: "/profile/$did", params: { did: handle } });
154
+
return;
155
+
}
156
+
} catch {
157
+
// continue
158
+
}
159
+
160
+
// 4. Plain DID (starts with did:)
161
+
try {
162
+
if (text.startsWith("did:")) {
163
+
console.log("did detected:", text);
164
+
navigate({ to: "/profile/$did", params: { did: text } });
165
+
return;
166
+
}
167
+
} catch {
168
+
// continue
169
+
}
170
+
171
+
// if all else fails
172
+
173
+
// try {
174
+
// // probably a user?
175
+
// navigate({ to: "/profile/$did", params: { did: text } });
176
+
// return;
177
+
// } catch {
178
+
// // continue
179
+
// }
180
+
181
+
if (lycanReady) {
182
+
navigate({ to: "/search", search: { q: text } });
183
+
}
184
+
}
+38
-7
src/components/InfiniteCustomFeed.tsx
+38
-7
src/components/InfiniteCustomFeed.tsx
···
1
+
import { useQueryClient } from "@tanstack/react-query";
1
2
import * as React from "react";
2
3
3
4
//import { useInView } from "react-intersection-observer";
···
13
14
feedUri: string;
14
15
pdsUrl?: string;
15
16
feedServiceDid?: string;
17
+
authedOverride?: boolean;
18
+
unauthedfeedurl?: string;
16
19
}
17
20
18
21
export function InfiniteCustomFeed({
19
22
feedUri,
20
23
pdsUrl,
21
24
feedServiceDid,
25
+
authedOverride,
26
+
unauthedfeedurl,
22
27
}: InfiniteCustomFeedProps) {
23
28
const { agent } = useAuth();
24
-
const authed = !!agent?.did;
29
+
const authed = authedOverride || !!agent?.did;
25
30
26
31
// const identityresultmaybe = useQueryIdentity(agent?.did);
27
32
// const identity = identityresultmaybe?.data;
···
37
42
isFetchingNextPage,
38
43
refetch,
39
44
isRefetching,
45
+
queryKey,
40
46
} = useInfiniteQueryFeedSkeleton({
41
47
feedUri: feedUri,
42
48
agent: agent ?? undefined,
43
49
isAuthed: authed ?? false,
44
50
pdsUrl: pdsUrl,
45
51
feedServiceDid: feedServiceDid,
52
+
unauthedfeedurl: unauthedfeedurl,
46
53
});
54
+
const queryClient = useQueryClient();
55
+
47
56
48
57
const handleRefresh = () => {
58
+
queryClient.removeQueries({queryKey: queryKey});
59
+
//queryClient.invalidateQueries(["infinite-feed", feedUri] as const);
49
60
refetch();
50
61
};
51
62
63
+
const allPosts = React.useMemo(() => {
64
+
const flattenedPosts = data?.pages.flatMap((page) => page?.feed) ?? [];
65
+
66
+
const seenUris = new Set<string>();
67
+
68
+
return flattenedPosts.filter((item) => {
69
+
if (!item?.post) return false;
70
+
71
+
if (seenUris.has(item.post)) {
72
+
return false;
73
+
}
74
+
75
+
seenUris.add(item.post);
76
+
77
+
return true;
78
+
});
79
+
}, [data]);
80
+
52
81
//const { ref, inView } = useInView();
53
82
54
83
// React.useEffect(() => {
···
67
96
);
68
97
}
69
98
70
-
const allPosts =
71
-
data?.pages.flatMap((page) => {
72
-
if (page) return page.feed;
73
-
}) ?? [];
99
+
// const allPosts =
100
+
// data?.pages.flatMap((page) => {
101
+
// if (page) return page.feed;
102
+
// }) ?? [];
74
103
75
104
if (!allPosts || typeof allPosts !== "object" || allPosts.length === 0) {
76
105
return (
···
116
145
className="sticky lg:bottom-4 bottom-22 ml-4 w-[42px] h-[42px] z-10 bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 text-gray-50 p-[9px] rounded-full shadow-lg transition-transform duration-200 ease-in-out hover:scale-110 disabled:dark:bg-gray-900 disabled:bg-gray-100 disabled:cursor-not-allowed"
117
146
aria-label="Refresh feed"
118
147
>
119
-
<RefreshIcon className={`h-6 w-6 text-gray-600 dark:text-gray-400 ${isRefetching && "animate-spin"}`} />
148
+
<RefreshIcon
149
+
className={`h-6 w-6 text-gray-600 dark:text-gray-400 ${isRefetching && "animate-spin"}`}
150
+
/>
120
151
</button>
121
152
</>
122
153
);
···
139
170
d="M20 11A8.1 8.1 0 0 0 4.5 9M4 5v4h4m-4 4a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4"
140
171
></path>
141
172
</svg>
142
-
);
173
+
);
+78
-34
src/components/Login.tsx
+78
-34
src/components/Login.tsx
···
1
1
// src/components/Login.tsx
2
-
import { Agent } from "@atproto/api";
2
+
import AtpAgent, { Agent } from "@atproto/api";
3
+
import { useAtom } from "jotai";
3
4
import React, { useEffect, useRef, useState } from "react";
4
5
5
6
import { useAuth } from "~/providers/UnifiedAuthProvider";
7
+
import { imgCDNAtom } from "~/utils/atoms";
8
+
import { useQueryIdentity, useQueryProfile } from "~/utils/useQuery";
6
9
7
10
// --- 1. The Main Component (Orchestrator with `compact` prop) ---
8
11
export default function Login({
···
21
24
className={
22
25
compact
23
26
? "flex items-center justify-center p-1"
24
-
: "p-6 bg-gray-100 dark:bg-gray-900 rounded-xl shadow border border-gray-200 dark:border-gray-800 mt-6 mx-4 flex justify-center items-center h-[280px]"
27
+
: "p-6 bg-gray-100 dark:bg-gray-900 rounded-xl shadow border border-gray-200 dark:border-gray-800 mt-4 mx-4 flex justify-center items-center h-[280px]"
25
28
}
26
29
>
27
30
<span
···
40
43
// Large view
41
44
if (!compact) {
42
45
return (
43
-
<div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-6 mx-4">
46
+
<div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-4 mx-4">
44
47
<div className="flex flex-col items-center justify-center text-center">
45
48
<p className="text-lg font-semibold mb-4 text-gray-800 dark:text-gray-100">
46
49
You are logged in!
···
74
77
if (!compact) {
75
78
// Large view renders the form directly in the card
76
79
return (
77
-
<div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-6 mx-4">
80
+
<div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-4 mx-4">
78
81
<UnifiedLoginForm />
79
82
</div>
80
83
);
···
110
113
// --- 3. Helper components for layouts, forms, and UI ---
111
114
112
115
// A new component to contain the logic for the compact dropdown
113
-
const CompactLoginButton = ({popup}:{popup?: boolean}) => {
116
+
const CompactLoginButton = ({ popup }: { popup?: boolean }) => {
114
117
const [showForm, setShowForm] = useState(false);
115
118
const formRef = useRef<HTMLDivElement>(null);
116
119
···
137
140
Log in
138
141
</button>
139
142
{showForm && (
140
-
<div className={`absolute ${popup ? `bottom-[calc(100%)]` :`top-full`} right-0 mt-2 w-80 bg-white dark:bg-gray-900 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-4 z-50`}>
143
+
<div
144
+
className={`absolute ${popup ? `bottom-[calc(100%)]` : `top-full`} right-0 mt-2 w-80 bg-white dark:bg-gray-900 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-4 z-50`}
145
+
>
141
146
<UnifiedLoginForm />
142
147
</div>
143
148
)}
···
158
163
onClick={onClick}
159
164
className={`px-4 py-2 text-sm font-medium transition-colors rounded-full flex-1 ${
160
165
active
161
-
? "text-gray-950 dark:text-gray-200 border-gray-500 bg-gray-400 dark:bg-gray-500"
166
+
? "text-gray-50 dark:text-gray-200 border-gray-500 bg-gray-400 dark:bg-gray-500"
162
167
: "text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200"
163
168
}`}
164
169
>
···
187
192
<p className="text-xs text-gray-500 dark:text-gray-400">
188
193
Sign in with AT. Your password is never shared.
189
194
</p>
190
-
<input
195
+
{/* <input
191
196
type="text"
192
197
placeholder="handle.bsky.social"
193
198
value={handle}
194
199
onChange={(e) => setHandle(e.target.value)}
195
200
className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500"
196
-
/>
197
-
<button
198
-
type="submit"
199
-
className="bg-gray-600 hover:bg-gray-700 text-white rounded-full px-4 py-2 font-medium text-sm transition-colors"
200
-
>
201
-
Log in
202
-
</button>
201
+
/> */}
202
+
<div className="flex flex-col gap-3">
203
+
<div className="m3input-field m3input-label m3input-border size-md flex-1">
204
+
<input
205
+
type="text"
206
+
placeholder=" "
207
+
value={handle}
208
+
onChange={(e) => setHandle(e.target.value)}
209
+
/>
210
+
<label>AT Handle</label>
211
+
</div>
212
+
<button
213
+
type="submit"
214
+
className="bg-gray-600 hover:bg-gray-700 text-white rounded-full px-4 py-2 font-medium text-sm transition-colors"
215
+
>
216
+
Log in
217
+
</button>
218
+
</div>
203
219
</form>
204
220
);
205
221
};
···
232
248
<p className="text-xs text-red-500 dark:text-red-400">
233
249
Warning: Less secure. Use an App Password.
234
250
</p>
235
-
<input
251
+
{/* <input
236
252
type="text"
237
253
placeholder="handle.bsky.social"
238
254
value={user}
···
254
270
value={serviceURL}
255
271
onChange={(e) => setServiceURL(e.target.value)}
256
272
className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500"
257
-
/>
273
+
/> */}
274
+
<div className="m3input-field m3input-label m3input-border size-md flex-1">
275
+
<input
276
+
type="text"
277
+
placeholder=" "
278
+
value={user}
279
+
onChange={(e) => setUser(e.target.value)}
280
+
/>
281
+
<label>AT Handle</label>
282
+
</div>
283
+
<div className="m3input-field m3input-label m3input-border size-md flex-1">
284
+
<input
285
+
type="text"
286
+
placeholder=" "
287
+
value={password}
288
+
onChange={(e) => setPassword(e.target.value)}
289
+
/>
290
+
<label>App Password</label>
291
+
</div>
292
+
<div className="m3input-field m3input-label m3input-border size-md flex-1">
293
+
<input
294
+
type="text"
295
+
placeholder=" "
296
+
value={serviceURL}
297
+
onChange={(e) => setServiceURL(e.target.value)}
298
+
/>
299
+
<label>PDS</label>
300
+
</div>
258
301
{error && <p className="text-xs text-red-500">{error}</p>}
259
302
<button
260
303
type="submit"
···
274
317
agent: Agent | null;
275
318
large?: boolean;
276
319
}) => {
277
-
const [profile, setProfile] = useState<any>(null);
320
+
const did = ((agent as AtpAgent)?.session?.did ??
321
+
(agent as AtpAgent)?.assertDid ??
322
+
agent?.did) as string | undefined;
323
+
const { data: identity } = useQueryIdentity(did);
324
+
const { data: profiledata } = useQueryProfile(
325
+
`at://${did}/app.bsky.actor.profile/self`
326
+
);
327
+
const profile = profiledata?.value;
278
328
279
-
useEffect(() => {
280
-
const fetchUser = async () => {
281
-
const did = (agent as any)?.session?.did ?? (agent as any)?.assertDid;
282
-
if (!did) return;
283
-
try {
284
-
const res = await agent!.getProfile({ actor: did });
285
-
setProfile(res.data);
286
-
} catch (e) {
287
-
console.error("Failed to fetch profile", e);
288
-
}
289
-
};
290
-
if (agent) fetchUser();
291
-
}, [agent]);
329
+
const [imgcdn] = useAtom(imgCDNAtom)
292
330
293
-
if (!profile) {
331
+
function getAvatarUrl(p: typeof profile) {
332
+
const link = p?.avatar?.ref?.["$link"];
333
+
if (!link || !did) return null;
334
+
return `https://${imgcdn}/img/avatar/plain/${did}/${link}@jpeg`;
335
+
}
336
+
337
+
if (!profiledata) {
294
338
return (
295
339
// Skeleton loader
296
340
<div
···
316
360
className={`flex flex-row items-center gap-2.5 ${large ? "mb-1" : ""}`}
317
361
>
318
362
<img
319
-
src={profile?.avatar}
363
+
src={getAvatarUrl(profile) ?? undefined}
320
364
alt="avatar"
321
365
className={`object-cover rounded-full ${large ? "w-10 h-10" : "w-[30px] h-[30px]"}`}
322
366
/>
···
329
373
<div
330
374
className={` ${large ? "text-gray-500 dark:text-gray-400 text-sm" : "text-gray-500 dark:text-gray-400 text-xs"}`}
331
375
>
332
-
@{profile?.handle}
376
+
@{identity?.handle}
333
377
</div>
334
378
</div>
335
379
</div>
+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
+
*/
+6
src/components/Star.tsx
+6
src/components/Star.tsx
···
1
+
import type { SVGProps } from 'react';
2
+
import React from 'react';
3
+
4
+
export function FluentEmojiHighContrastGlowingStar(props: SVGProps<SVGSVGElement>) {
5
+
return (<svg xmlns="http://www.w3.org/2000/svg" width={32} height={32} viewBox="0 0 32 32" {...props}><g fill="currentColor"><path d="m28.979 17.003l-3.108.214c-.834.06-1.178 1.079-.542 1.608l2.388 1.955c.521.428 1.314.204 1.523-.428l.709-2.127c.219-.632-.292-1.273-.97-1.222M21.75 2.691l-.72 2.9c-.2.78.66 1.41 1.34.98l2.54-1.58c.55-.34.58-1.14.05-1.52l-1.78-1.29a.912.912 0 0 0-1.43.51M6.43 4.995l2.53 1.58c.68.43 1.54-.19 1.35-.98l-.72-2.9a.92.92 0 0 0-1.43-.52l-1.78 1.29c-.53.4-.5 1.19.05 1.53M4.185 20.713l2.29-1.92c.62-.52.29-1.53-.51-1.58l-2.98-.21a.92.92 0 0 0-.94 1.2l.68 2.09c.2.62.97.84 1.46.42m13.61 7.292l-1.12-2.77c-.3-.75-1.36-.75-1.66 0l-1.12 2.77c-.24.6.2 1.26.85 1.26h2.2a.92.92 0 0 0 .85-1.26"></path><path d="m17.565 3.324l1.726 3.72c.326.694.967 1.18 1.717 1.29l4.056.624c1.835.278 2.575 2.53 1.293 3.859L23.268 16a2.28 2.28 0 0 0-.612 1.964l.71 4.374c.307 1.885-1.687 3.293-3.354 2.37l-3.405-1.894a2.25 2.25 0 0 0-2.21 0l-3.404 1.895c-1.668.922-3.661-.486-3.355-2.37l.71-4.375A2.28 2.28 0 0 0 7.736 16l-3.088-3.184c-1.293-1.34-.543-3.581 1.293-3.859l4.055-.625a2.3 2.3 0 0 0 1.717-1.29l1.727-3.719c.819-1.765 3.306-1.765 4.124 0"></path></g></svg>);
6
+
}
+618
-156
src/components/UniversalPostRenderer.tsx
+618
-156
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";
5
+
import { DropdownMenu } from "radix-ui";
6
+
import { HoverCard } from "radix-ui";
4
7
import * as React from "react";
5
8
import { type SVGProps } from "react";
6
9
7
-
import { likedPostsAtom } from "~/utils/atoms";
10
+
import {
11
+
composerAtom,
12
+
constellationURLAtom,
13
+
enableBridgyTextAtom,
14
+
enableWafrnTextAtom,
15
+
imgCDNAtom,
16
+
} from "~/utils/atoms";
8
17
import { useHydratedEmbed } from "~/utils/useHydrated";
9
18
import {
10
19
useQueryConstellation,
11
20
useQueryIdentity,
12
21
useQueryPost,
13
22
useQueryProfile,
23
+
yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks,
14
24
} from "~/utils/useQuery";
15
25
16
26
function asTyped<T extends { $type: string }>(obj: T): $Typed<T> {
···
30
40
feedviewpost?: boolean;
31
41
repostedby?: string;
32
42
style?: React.CSSProperties;
33
-
ref?: React.Ref<HTMLDivElement>;
43
+
ref?: React.RefObject<HTMLDivElement>;
34
44
dataIndexPropPass?: number;
35
45
nopics?: boolean;
36
-
lightboxCallback?: (d:LightboxProps) => void;
46
+
concise?: boolean;
47
+
lightboxCallback?: (d: LightboxProps) => void;
48
+
maxReplies?: number;
49
+
isQuote?: boolean;
50
+
filterNoReplies?: boolean;
51
+
filterMustHaveMedia?: boolean;
52
+
filterMustBeReply?: boolean;
37
53
}
38
54
39
55
// export async function cachedGetRecord({
···
142
158
ref,
143
159
dataIndexPropPass,
144
160
nopics,
161
+
concise,
145
162
lightboxCallback,
163
+
maxReplies,
164
+
isQuote,
165
+
filterNoReplies,
166
+
filterMustHaveMedia,
167
+
filterMustBeReply,
146
168
}: UniversalPostRendererATURILoaderProps) {
169
+
// todo remove this once tree rendering is implemented, use a prop like isTree
170
+
const TEMPLINEAR = true;
147
171
// /*mass comment*/ console.log("atUri", atUri);
148
172
//const { get, set } = usePersistentStore();
149
173
//const [record, setRecord] = React.useState<any>(null);
···
388
412
);
389
413
}, [links]);
390
414
415
+
// const { data: repliesData } = useQueryConstellation({
416
+
// method: "/links",
417
+
// target: atUri,
418
+
// collection: "app.bsky.feed.post",
419
+
// path: ".reply.parent.uri",
420
+
// });
421
+
422
+
const [constellationurl] = useAtom(constellationURLAtom);
423
+
424
+
const infinitequeryresults = useInfiniteQuery({
425
+
...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(
426
+
{
427
+
constellation: constellationurl,
428
+
method: "/links",
429
+
target: atUri,
430
+
collection: "app.bsky.feed.post",
431
+
path: ".reply.parent.uri",
432
+
}
433
+
),
434
+
enabled: !!atUri && !!maxReplies && !isQuote,
435
+
});
436
+
437
+
const {
438
+
data: repliesData,
439
+
// fetchNextPage,
440
+
// hasNextPage,
441
+
// isFetchingNextPage,
442
+
} = infinitequeryresults;
443
+
444
+
// auto-fetch all pages
445
+
useEffect(() => {
446
+
if (!maxReplies || isQuote || TEMPLINEAR) return;
447
+
if (
448
+
infinitequeryresults.hasNextPage &&
449
+
!infinitequeryresults.isFetchingNextPage
450
+
) {
451
+
console.log("Fetching the next page...");
452
+
infinitequeryresults.fetchNextPage();
453
+
}
454
+
}, [TEMPLINEAR, infinitequeryresults, isQuote, maxReplies]);
455
+
456
+
const replyAturis = repliesData
457
+
? repliesData.pages.flatMap((page) =>
458
+
page
459
+
? page.linking_records.map((record) => {
460
+
const aturi = `at://${record.did}/${record.collection}/${record.rkey}`;
461
+
return aturi;
462
+
})
463
+
: []
464
+
)
465
+
: [];
466
+
467
+
//const [oldestOpsReply, setOldestOpsReply] = useState<string | undefined>(undefined);
468
+
469
+
const { oldestOpsReply, oldestOpsReplyElseNewestNonOpsReply } = (() => {
470
+
if (isQuote || !replyAturis || replyAturis.length === 0 || !maxReplies)
471
+
return {
472
+
oldestOpsReply: undefined,
473
+
oldestOpsReplyElseNewestNonOpsReply: undefined,
474
+
};
475
+
476
+
const opdid = new AtUri(
477
+
//postQuery?.value.reply?.root.uri ?? postQuery?.uri ?? atUri
478
+
atUri
479
+
).host;
480
+
481
+
const opReplies = replyAturis.filter(
482
+
(aturi) => new AtUri(aturi).host === opdid
483
+
);
484
+
485
+
if (opReplies.length > 0) {
486
+
const opreply = opReplies[opReplies.length - 1];
487
+
//setOldestOpsReply(opreply);
488
+
return {
489
+
oldestOpsReply: opreply,
490
+
oldestOpsReplyElseNewestNonOpsReply: opreply,
491
+
};
492
+
} else {
493
+
return {
494
+
oldestOpsReply: undefined,
495
+
oldestOpsReplyElseNewestNonOpsReply: replyAturis[0],
496
+
};
497
+
}
498
+
})();
499
+
391
500
// const navigateToProfile = (e: React.MouseEvent) => {
392
501
// e.stopPropagation();
393
502
// if (resolved?.did) {
···
403
512
}
404
513
405
514
return (
406
-
<UniversalPostRendererRawRecordShim
407
-
detailed={detailed}
408
-
postRecord={postQuery}
409
-
profileRecord={opProfile}
410
-
aturi={atUri}
411
-
resolved={resolved}
412
-
likesCount={likes}
413
-
repostsCount={reposts}
414
-
repliesCount={replies}
415
-
bottomReplyLine={bottomReplyLine}
416
-
topReplyLine={topReplyLine}
417
-
bottomBorder={bottomBorder}
418
-
feedviewpost={feedviewpost}
419
-
repostedby={repostedby}
420
-
style={style}
421
-
ref={ref}
422
-
dataIndexPropPass={dataIndexPropPass}
423
-
nopics={nopics}
424
-
lightboxCallback={lightboxCallback}
425
-
/>
515
+
<>
516
+
{/* <span>uprrs {maxReplies} {!!maxReplies&&!!oldestOpsReplyElseNewestNonOpsReply ? "true" : "false"}</span> */}
517
+
<UniversalPostRendererRawRecordShim
518
+
detailed={detailed}
519
+
postRecord={postQuery}
520
+
profileRecord={opProfile}
521
+
aturi={atUri}
522
+
resolved={resolved}
523
+
likesCount={likes}
524
+
repostsCount={reposts}
525
+
repliesCount={replies}
526
+
bottomReplyLine={
527
+
maxReplies && oldestOpsReplyElseNewestNonOpsReply
528
+
? true
529
+
: maxReplies && !oldestOpsReplyElseNewestNonOpsReply
530
+
? false
531
+
: maxReplies === 0 && (!replies || (!!replies && replies === 0))
532
+
? false
533
+
: bottomReplyLine
534
+
}
535
+
topReplyLine={topReplyLine}
536
+
//bottomBorder={maxReplies&&oldestOpsReplyElseNewestNonOpsReply ? false : bottomBorder}
537
+
bottomBorder={
538
+
maxReplies && oldestOpsReplyElseNewestNonOpsReply
539
+
? false
540
+
: maxReplies === 0
541
+
? false
542
+
: bottomBorder
543
+
}
544
+
feedviewpost={feedviewpost}
545
+
repostedby={repostedby}
546
+
//style={{...style, background: oldestOpsReply === atUri ? "Red" : undefined}}
547
+
style={style}
548
+
ref={ref}
549
+
dataIndexPropPass={dataIndexPropPass}
550
+
nopics={nopics}
551
+
concise={concise}
552
+
lightboxCallback={lightboxCallback}
553
+
maxReplies={maxReplies}
554
+
isQuote={isQuote}
555
+
filterNoReplies={filterNoReplies}
556
+
filterMustHaveMedia={filterMustHaveMedia}
557
+
filterMustBeReply={filterMustBeReply}
558
+
/>
559
+
<>
560
+
{maxReplies && maxReplies === 0 && replies && replies > 0 ? (
561
+
<>
562
+
{/* <div>hello</div> */}
563
+
<MoreReplies atUri={atUri} />
564
+
</>
565
+
) : (
566
+
<></>
567
+
)}
568
+
</>
569
+
{!isQuote && oldestOpsReplyElseNewestNonOpsReply && (
570
+
<>
571
+
{/* <span>hello {maxReplies}</span> */}
572
+
<UniversalPostRendererATURILoader
573
+
//detailed={detailed}
574
+
atUri={oldestOpsReplyElseNewestNonOpsReply}
575
+
bottomReplyLine={(maxReplies ?? 0) > 0}
576
+
topReplyLine={
577
+
(!!(maxReplies && maxReplies - 1 === 0) &&
578
+
!!(replies && replies > 0)) ||
579
+
!!((maxReplies ?? 0) > 1)
580
+
}
581
+
bottomBorder={bottomBorder}
582
+
feedviewpost={feedviewpost}
583
+
repostedby={repostedby}
584
+
style={style}
585
+
ref={ref}
586
+
dataIndexPropPass={dataIndexPropPass}
587
+
nopics={nopics}
588
+
concise={concise}
589
+
lightboxCallback={lightboxCallback}
590
+
maxReplies={
591
+
maxReplies && maxReplies > 0 ? maxReplies - 1 : undefined
592
+
}
593
+
/>
594
+
</>
595
+
)}
596
+
</>
597
+
);
598
+
}
599
+
600
+
function MoreReplies({ atUri }: { atUri: string }) {
601
+
const navigate = useNavigate();
602
+
const aturio = new AtUri(atUri);
603
+
return (
604
+
<div
605
+
onClick={() =>
606
+
navigate({
607
+
to: "/profile/$did/post/$rkey",
608
+
params: { did: aturio.host, rkey: aturio.rkey },
609
+
})
610
+
}
611
+
className="border-b border-gray-300 dark:border-gray-800 flex flex-row px-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-900 transition-colors"
612
+
>
613
+
<div className="w-[42px] h-12 flex flex-col items-center justify-center">
614
+
<div
615
+
style={{
616
+
width: 2,
617
+
height: "100%",
618
+
backgroundImage:
619
+
"repeating-linear-gradient(to bottom, var(--color-gray-500) 0, var(--color-gray-500) 4px, transparent 4px, transparent 8px)",
620
+
opacity: 0.5,
621
+
}}
622
+
className="dark:bg-[repeating-linear-gradient(to_bottom,var(--color-gray-500)_0,var(--color-gray-400)_4px,transparent_4px,transparent_8px)]"
623
+
//className="border-gray-400 dark:border-gray-500"
624
+
/>
625
+
</div>
626
+
627
+
<div className="flex items-center pl-3 text-sm text-gray-500 dark:text-gray-400 select-none">
628
+
More Replies
629
+
</div>
630
+
</div>
426
631
);
427
632
}
428
633
429
-
function getAvatarUrl(opProfile: any, did: string) {
634
+
function getAvatarUrl(opProfile: any, did: string, cdn: string) {
430
635
const link = opProfile?.value?.avatar?.ref?.["$link"];
431
636
if (!link) return null;
432
-
return `https://cdn.bsky.app/img/avatar/plain/${did}/${link}@jpeg`;
637
+
return `https://${cdn}/img/avatar/plain/${did}/${link}@jpeg`;
433
638
}
434
639
435
640
export function UniversalPostRendererRawRecordShim({
···
450
655
ref,
451
656
dataIndexPropPass,
452
657
nopics,
658
+
concise,
453
659
lightboxCallback,
660
+
maxReplies,
661
+
isQuote,
662
+
filterNoReplies,
663
+
filterMustHaveMedia,
664
+
filterMustBeReply,
454
665
}: {
455
666
postRecord: any;
456
667
profileRecord: any;
···
466
677
feedviewpost?: boolean;
467
678
repostedby?: string;
468
679
style?: React.CSSProperties;
469
-
ref?: React.Ref<HTMLDivElement>;
680
+
ref?: React.RefObject<HTMLDivElement>;
470
681
dataIndexPropPass?: number;
471
682
nopics?: boolean;
472
-
lightboxCallback?: (d:LightboxProps) => void;
683
+
concise?: boolean;
684
+
lightboxCallback?: (d: LightboxProps) => void;
685
+
maxReplies?: number;
686
+
isQuote?: boolean;
687
+
filterNoReplies?: boolean;
688
+
filterMustHaveMedia?: boolean;
689
+
filterMustBeReply?: boolean;
473
690
}) {
474
691
// /*mass comment*/ console.log(`received aturi: ${aturi} of post content: ${postRecord}`);
475
692
const navigate = useNavigate();
···
540
757
// run();
541
758
// }, [postRecord, resolved?.did]);
542
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);
776
+
543
777
const {
544
778
data: hydratedEmbed,
545
779
isLoading: isEmbedLoading,
546
780
error: embedError,
547
781
} = useHydratedEmbed(postRecord?.value?.embed, resolved?.did);
548
782
783
+
const [imgcdn] = useAtom(imgCDNAtom);
784
+
549
785
const parsedaturi = new AtUri(aturi); //parseAtUri(aturi);
550
786
787
+
const fakeprofileviewbasic = React.useMemo<AppBskyActorDefs.ProfileViewBasic>(
788
+
() => ({
789
+
did: resolved?.did || "",
790
+
handle: resolved?.handle || "",
791
+
displayName: profileRecord?.value?.displayName || "",
792
+
avatar: getAvatarUrl(profileRecord, resolved?.did, imgcdn) || "",
793
+
viewer: undefined,
794
+
labels: profileRecord?.labels || undefined,
795
+
verification: undefined,
796
+
}),
797
+
[imgcdn, profileRecord, resolved?.did, resolved?.handle]
798
+
);
799
+
800
+
const fakeprofileviewdetailed =
801
+
React.useMemo<AppBskyActorDefs.ProfileViewDetailed>(
802
+
() => ({
803
+
...fakeprofileviewbasic,
804
+
$type: "app.bsky.actor.defs#profileViewDetailed",
805
+
description: profileRecord?.value?.description || undefined,
806
+
}),
807
+
[fakeprofileviewbasic, profileRecord?.value?.description]
808
+
);
809
+
551
810
const fakepost = React.useMemo<AppBskyFeedDefs.PostView>(
552
811
() => ({
553
812
$type: "app.bsky.feed.defs#postView",
554
813
uri: aturi,
555
814
cid: postRecord?.cid || "",
556
-
author: {
557
-
did: resolved?.did || "",
558
-
handle: resolved?.handle || "",
559
-
displayName: profileRecord?.value?.displayName || "",
560
-
avatar: getAvatarUrl(profileRecord, resolved?.did) || "",
561
-
viewer: undefined,
562
-
labels: profileRecord?.labels || undefined,
563
-
verification: undefined,
564
-
},
815
+
author: fakeprofileviewbasic,
565
816
record: postRecord?.value || {},
566
817
embed: hydratedEmbed ?? undefined,
567
818
replyCount: repliesCount ?? 0,
···
578
829
postRecord?.cid,
579
830
postRecord?.value,
580
831
postRecord?.labels,
581
-
resolved?.did,
582
-
resolved?.handle,
583
-
profileRecord,
832
+
fakeprofileviewbasic,
584
833
hydratedEmbed,
585
834
repliesCount,
586
835
repostsCount,
···
619
868
// }, [fakepost, get, set]);
620
869
const thereply = (fakepost?.record as AppBskyFeedPost.Record)?.reply?.parent
621
870
?.uri;
622
-
const feedviewpostreplydid = thereply ? new AtUri(thereply).host : undefined;
871
+
const feedviewpostreplydid =
872
+
thereply && !filterNoReplies ? new AtUri(thereply).host : undefined;
623
873
const replyhookvalue = useQueryIdentity(
624
874
feedviewpost ? feedviewpostreplydid : undefined
625
875
);
···
630
880
repostedby ? aturirepostbydid : undefined
631
881
);
632
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
+
633
890
return (
634
891
<>
635
892
{/* <p>
636
893
{postRecord?.value?.embed.$type + " " + JSON.stringify(hydratedEmbed)}
637
894
</p> */}
895
+
{/* <span>filtermustbereply is {filterMustBeReply ? "true" : "false"}</span>
896
+
<span>thereply is {thereply ? "true" : "false"}</span> */}
638
897
<UniversalPostRenderer
639
898
expanded={detailed}
640
899
onPostClick={() =>
···
657
916
}
658
917
}}
659
918
post={fakepost}
919
+
uprrrsauthor={fakeprofileviewdetailed}
660
920
salt={aturi}
661
921
bottomReplyLine={bottomReplyLine}
662
922
topReplyLine={topReplyLine}
···
668
928
ref={ref}
669
929
dataIndexPropPass={dataIndexPropPass}
670
930
nopics={nopics}
931
+
concise={concise}
671
932
lightboxCallback={lightboxCallback}
933
+
maxReplies={maxReplies}
934
+
isQuote={isQuote}
672
935
/>
673
936
</>
674
937
);
···
707
970
{...props}
708
971
>
709
972
<path
710
-
fill="oklch(0.704 0.05 28)"
973
+
fill="var(--color-gray-400)"
711
974
d="M9 22a1 1 0 0 1-1-1v-3H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-6.1l-3.7 3.71c-.2.19-.45.29-.7.29zm1-6v3.08L13.08 16H20V4H4v12z"
712
975
></path>
713
976
</svg>
···
724
987
{...props}
725
988
>
726
989
<path
727
-
fill="oklch(0.704 0.05 28)"
990
+
fill="var(--color-gray-400)"
728
991
d="M17 17H7v-3l-4 4l4 4v-3h12v-6h-2M7 7h10v3l4-4l-4-4v3H5v6h2z"
729
992
></path>
730
993
</svg>
···
775
1038
{...props}
776
1039
>
777
1040
<path
778
-
fill="oklch(0.704 0.05 28)"
1041
+
fill="var(--color-gray-400)"
779
1042
d="m12.1 18.55l-.1.1l-.11-.1C7.14 14.24 4 11.39 4 8.5C4 6.5 5.5 5 7.5 5c1.54 0 3.04 1 3.57 2.36h1.86C13.46 6 14.96 5 16.5 5c2 0 3.5 1.5 3.5 3.5c0 2.89-3.14 5.74-7.9 10.05M16.5 3c-1.74 0-3.41.81-4.5 2.08C10.91 3.81 9.24 3 7.5 3C4.42 3 2 5.41 2 8.5c0 3.77 3.4 6.86 8.55 11.53L12 21.35l1.45-1.32C18.6 15.36 22 12.27 22 8.5C22 5.41 19.58 3 16.5 3"
780
1043
></path>
781
1044
</svg>
···
792
1055
{...props}
793
1056
>
794
1057
<path
795
-
fill="oklch(0.704 0.05 28)"
1058
+
fill="var(--color-gray-400)"
796
1059
d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81a3 3 0 0 0 3-3a3 3 0 0 0-3-3a3 3 0 0 0-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9a3 3 0 0 0-3 3a3 3 0 0 0 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.15c-.05.21-.08.43-.08.66c0 1.61 1.31 2.91 2.92 2.91s2.92-1.3 2.92-2.91A2.92 2.92 0 0 0 18 16.08"
797
1060
></path>
798
1061
</svg>
···
809
1072
{...props}
810
1073
>
811
1074
<path
812
-
fill="oklch(0.704 0.05 28)"
1075
+
fill="var(--color-gray-400)"
813
1076
d="M16 12a2 2 0 0 1 2-2a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2m-6 0a2 2 0 0 1 2-2a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2m-6 0a2 2 0 0 1 2-2a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2"
814
1077
></path>
815
1078
</svg>
···
826
1089
{...props}
827
1090
>
828
1091
<path
829
-
fill="oklch(0.704 0.05 28)"
1092
+
fill="var(--color-gray-400)"
830
1093
d="M17.9 17.39c-.26-.8-1.01-1.39-1.9-1.39h-1v-3a1 1 0 0 0-1-1H8v-2h2a1 1 0 0 0 1-1V7h2a2 2 0 0 0 2-2v-.41a7.984 7.984 0 0 1 2.9 12.8M11 19.93c-3.95-.49-7-3.85-7-7.93c0-.62.08-1.22.21-1.79L9 15v1a2 2 0 0 0 2 2m1-16A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2"
831
1094
></path>
832
1095
</svg>
···
860
1123
{...props}
861
1124
>
862
1125
<path
863
-
fill="oklch(0.704 0.05 28)"
1126
+
fill="var(--color-gray-400)"
864
1127
d="M10 9V5l-7 7l7 7v-4.1c5 0 8.5 1.6 11 5.1c-1-5-4-10-11-11"
865
1128
></path>
866
1129
</svg>
···
914
1177
{...props}
915
1178
>
916
1179
<path
917
-
fill="oklch(0.704 0.05 28)"
1180
+
fill="var(--color-gray-400)"
918
1181
d="M17 17H7v-3l-4 4l4 4v-3h12v-6h-2M7 7h10v3l4-4l-4-4v3H5v6h2z"
919
1182
></path>
920
1183
</svg>
···
931
1194
{...props}
932
1195
>
933
1196
<path
934
-
fill="oklch(0.704 0.05 28)"
1197
+
fill="var(--color-gray-400)"
935
1198
d="M6 5.75L10.25 10H7v6h6.5l2 2H7a2 2 0 0 1-2-2v-6H1.75zm12 12.5L13.75 14H17V8h-6.5l-2-2H17a2 2 0 0 1 2 2v6h3.25z"
936
1199
></path>
937
1200
</svg>
···
959
1222
//import Masonry from "@mui/lab/Masonry";
960
1223
import {
961
1224
type $Typed,
1225
+
AppBskyActorDefs,
962
1226
AppBskyEmbedDefs,
963
1227
AppBskyEmbedExternal,
964
1228
AppBskyEmbedImages,
···
982
1246
PostView,
983
1247
//ThreadViewPost,
984
1248
} from "@atproto/api/dist/client/types/app/bsky/feed/defs";
1249
+
import { useInfiniteQuery } from "@tanstack/react-query";
985
1250
import { useEffect, useRef, useState } from "react";
986
1251
import ReactPlayer from "react-player";
987
1252
988
1253
import defaultpfp from "~/../public/favicon.png";
989
1254
import { useAuth } from "~/providers/UnifiedAuthProvider";
1255
+
import { renderSnack } from "~/routes/__root";
1256
+
import {
1257
+
FeedItemRenderAturiLoader,
1258
+
FollowButton,
1259
+
Mutual,
1260
+
} from "~/routes/profile.$did";
990
1261
import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i";
1262
+
import { useFastLike } from "~/utils/likeMutationQueue";
991
1263
// import type { OutputSchema } from "@atproto/api/dist/client/types/app/bsky/feed/getFeed";
992
1264
// import type {
993
1265
// ViewRecord,
···
1095
1367
1096
1368
function UniversalPostRenderer({
1097
1369
post,
1370
+
uprrrsauthor,
1098
1371
//setMainItem,
1099
1372
//isMainItem,
1100
1373
onPostClick,
···
1115
1388
ref,
1116
1389
dataIndexPropPass,
1117
1390
nopics,
1118
-
lightboxCallback
1391
+
concise,
1392
+
lightboxCallback,
1393
+
maxReplies,
1119
1394
}: {
1120
1395
post: PostView;
1396
+
uprrrsauthor?: AppBskyActorDefs.ProfileViewDetailed;
1121
1397
// optional for now because i havent ported every use to this yet
1122
1398
// setMainItem?: React.Dispatch<
1123
1399
// React.SetStateAction<AppBskyFeedDefs.FeedViewPost>
···
1136
1412
depth?: number;
1137
1413
repostedby?: string;
1138
1414
style?: React.CSSProperties;
1139
-
ref?: React.Ref<HTMLDivElement>;
1415
+
ref?: React.RefObject<HTMLDivElement>;
1140
1416
dataIndexPropPass?: number;
1141
1417
nopics?: boolean;
1142
-
lightboxCallback?: (d:LightboxProps) => void;
1418
+
concise?: boolean;
1419
+
lightboxCallback?: (d: LightboxProps) => void;
1420
+
maxReplies?: number;
1143
1421
}) {
1144
1422
const parsed = new AtUri(post.uri);
1145
1423
const navigate = useNavigate();
1146
-
const [likedPosts, setLikedPosts] = useAtom(likedPostsAtom);
1147
1424
const [hasRetweeted, setHasRetweeted] = useState<boolean>(
1148
1425
post.viewer?.repost ? true : false
1149
1426
);
1150
-
const [hasLiked, setHasLiked] = useState<boolean>(
1151
-
post.uri in likedPosts || post.viewer?.like ? true : false
1152
-
);
1427
+
const [, setComposerPost] = useAtom(composerAtom);
1153
1428
const { agent } = useAuth();
1154
-
const [likeUri, setLikeUri] = useState<string | undefined>(post.viewer?.like);
1155
1429
const [retweetUri, setRetweetUri] = useState<string | undefined>(
1156
1430
post.viewer?.repost
1157
1431
);
1158
-
1159
-
const likeOrUnlikePost = async () => {
1160
-
const newLikedPosts = { ...likedPosts };
1161
-
if (!agent) {
1162
-
console.error("Agent is null or undefined");
1163
-
return;
1164
-
}
1165
-
if (hasLiked) {
1166
-
if (post.uri in likedPosts) {
1167
-
const likeUri = likedPosts[post.uri];
1168
-
setLikeUri(likeUri);
1169
-
}
1170
-
if (likeUri) {
1171
-
await agent.deleteLike(likeUri);
1172
-
setHasLiked(false);
1173
-
delete newLikedPosts[post.uri];
1174
-
}
1175
-
} else {
1176
-
const { uri } = await agent.like(post.uri, post.cid);
1177
-
setLikeUri(uri);
1178
-
setHasLiked(true);
1179
-
newLikedPosts[post.uri] = uri;
1180
-
}
1181
-
setLikedPosts(newLikedPosts);
1182
-
};
1432
+
const { liked, toggle, backfill } = useFastLike(post.uri, post.cid);
1433
+
// const bovref = useBackfillOnView(post.uri, post.cid);
1434
+
// React.useLayoutEffect(()=>{
1435
+
// if (expanded && !isQuote) {
1436
+
// backfill();
1437
+
// }
1438
+
// },[backfill, expanded, isQuote])
1183
1439
1184
1440
const repostOrUnrepostPost = async () => {
1185
1441
if (!agent) {
···
1210
1466
: undefined;
1211
1467
1212
1468
const emergencySalt = randomString();
1213
-
const fedi = (post.record as { bridgyOriginalText?: string })
1469
+
1470
+
const [showBridgyText] = useAtom(enableBridgyTextAtom);
1471
+
const [showWafrnText] = useAtom(enableWafrnTextAtom);
1472
+
1473
+
const unfedibridgy = (post.record as { bridgyOriginalText?: string })
1214
1474
.bridgyOriginalText;
1475
+
const unfediwafrnPartial = (post.record as { fullText?: string }).fullText;
1476
+
const unfediwafrnTags = (post.record as { fullTags?: string }).fullTags;
1477
+
const unfediwafrnUnHost = (post.record as { fediverseId?: string })
1478
+
.fediverseId;
1479
+
1480
+
const undfediwafrnHost = unfediwafrnUnHost
1481
+
? new URL(unfediwafrnUnHost).hostname
1482
+
: undefined;
1483
+
1484
+
const tags = unfediwafrnTags
1485
+
? unfediwafrnTags
1486
+
.split("\n")
1487
+
.map((t) => t.trim())
1488
+
.filter(Boolean)
1489
+
: undefined;
1490
+
1491
+
const links = tags
1492
+
? tags
1493
+
.map((tag) => {
1494
+
const encoded = encodeURIComponent(tag);
1495
+
return `<a href="https://${undfediwafrnHost}/search/${encoded}" target="_blank">#${tag.replaceAll(" ", "-")}</a>`;
1496
+
})
1497
+
.join("<br>")
1498
+
: "";
1499
+
1500
+
const unfediwafrn = unfediwafrnPartial
1501
+
? unfediwafrnPartial + (links ? `<br>${links}` : "")
1502
+
: undefined;
1503
+
1504
+
const fedi =
1505
+
(showBridgyText ? unfedibridgy : undefined) ??
1506
+
(showWafrnText ? unfediwafrn : undefined);
1215
1507
1216
1508
/* fuck you */
1217
1509
const isMainItem = false;
1218
1510
const setMainItem = (any: any) => {};
1219
1511
// eslint-disable-next-line react-hooks/refs
1220
-
console.log("Received ref in UniversalPostRenderer:", ref);
1512
+
//console.log("Received ref in UniversalPostRenderer:", usedref);
1221
1513
return (
1222
1514
<div ref={ref} style={style} data-index={dataIndexPropPass}>
1223
1515
<div
···
1246
1538
paddingLeft: isQuote ? 12 : 16,
1247
1539
paddingRight: isQuote ? 12 : 16,
1248
1540
//paddingTop: 16,
1249
-
paddingTop: isRepost ? 10 : isQuote ? 12 : 16,
1541
+
paddingTop: isRepost ? 10 : isQuote ? 12 : topReplyLine ? 8 : 16,
1250
1542
//paddingBottom: bottomReplyLine ? 0 : 16,
1251
1543
paddingBottom: 0,
1252
1544
fontFamily: "system-ui, sans-serif",
···
1287
1579
//left: 16 + (42 / 2),
1288
1580
width: 2,
1289
1581
//height: "100%",
1290
-
height: isRepost ? "calc(16px + 1rem - 6px)" : 16 - 6,
1582
+
height: isRepost
1583
+
? "calc(16px + 1rem - 6px)"
1584
+
: topReplyLine
1585
+
? 8 - 6
1586
+
: 16 - 6,
1291
1587
// background: theme.textSecondary,
1292
1588
//opacity: 0.5,
1293
1589
// no flex here
···
1295
1591
className="bg-gray-500 dark:bg-gray-400"
1296
1592
/>
1297
1593
)}
1298
-
<div
1299
-
style={{
1300
-
position: "absolute",
1301
-
//top: isRepost ? "calc(16px + 1rem)" : 16,
1302
-
//left: 16,
1303
-
zIndex: 1,
1304
-
top: isRepost ? "calc(16px + 1rem)" : isQuote ? 12 : 16,
1305
-
left: isQuote ? 12 : 16,
1306
-
}}
1307
-
onClick={onProfileClick}
1308
-
>
1309
-
<img
1310
-
src={post.author.avatar || defaultpfp}
1311
-
alt="avatar"
1312
-
// transition={{
1313
-
// type: "spring",
1314
-
// stiffness: 260,
1315
-
// damping: 20,
1316
-
// }}
1317
-
style={{
1318
-
borderRadius: "50%",
1319
-
marginRight: 12,
1320
-
objectFit: "cover",
1321
-
//background: theme.border,
1322
-
//border: `1px solid ${theme.border}`,
1323
-
width: isQuote ? 16 : 42,
1324
-
height: isQuote ? 16 : 42,
1325
-
}}
1326
-
className="border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600"
1327
-
/>
1328
-
</div>
1594
+
<HoverCard.Root>
1595
+
<HoverCard.Trigger asChild>
1596
+
<div
1597
+
className={`absolute`}
1598
+
style={{
1599
+
top: isRepost
1600
+
? "calc(16px + 1rem)"
1601
+
: isQuote
1602
+
? 12
1603
+
: topReplyLine
1604
+
? 8
1605
+
: 16,
1606
+
left: isQuote ? 12 : 16,
1607
+
}}
1608
+
onClick={onProfileClick}
1609
+
>
1610
+
<img
1611
+
src={post.author.avatar || defaultpfp}
1612
+
alt="avatar"
1613
+
className={`rounded-full object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600`}
1614
+
style={{
1615
+
width: isQuote ? 16 : 42,
1616
+
height: isQuote ? 16 : 42,
1617
+
}}
1618
+
/>
1619
+
</div>
1620
+
</HoverCard.Trigger>
1621
+
<HoverCard.Portal>
1622
+
<HoverCard.Content
1623
+
className="rounded-md p-4 w-72 bg-gray-50 dark:bg-gray-900 shadow-lg border border-gray-300 dark:border-gray-800 animate-slide-fade z-50"
1624
+
side={"bottom"}
1625
+
sideOffset={5}
1626
+
onClick={onProfileClick}
1627
+
>
1628
+
<div className="flex flex-col gap-2">
1629
+
<div className="flex flex-row">
1630
+
<img
1631
+
src={post.author.avatar || defaultpfp}
1632
+
alt="avatar"
1633
+
className="rounded-full w-[58px] h-[58px] object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600"
1634
+
/>
1635
+
<div className=" flex-1 flex flex-row align-middle justify-end">
1636
+
<FollowButton targetdidorhandle={post.author.did} />
1637
+
</div>
1638
+
</div>
1639
+
<div className="flex flex-col gap-3">
1640
+
<div>
1641
+
<div className="text-gray-900 dark:text-gray-100 font-medium text-md">
1642
+
{post.author.displayName || post.author.handle}{" "}
1643
+
</div>
1644
+
<div className="text-gray-500 dark:text-gray-400 text-md flex flex-row gap-1">
1645
+
<Mutual targetdidorhandle={post.author.did} />@
1646
+
{post.author.handle}{" "}
1647
+
</div>
1648
+
</div>
1649
+
{uprrrsauthor?.description && (
1650
+
<div className="text-gray-700 dark:text-gray-300 text-sm text-left break-words line-clamp-3">
1651
+
{uprrrsauthor.description}
1652
+
</div>
1653
+
)}
1654
+
{/* <div className="flex gap-4">
1655
+
<div className="flex gap-1">
1656
+
<div className="font-medium text-gray-900 dark:text-gray-100">
1657
+
0
1658
+
</div>
1659
+
<div className="text-gray-500 dark:text-gray-400">
1660
+
Following
1661
+
</div>
1662
+
</div>
1663
+
<div className="flex gap-1">
1664
+
<div className="font-medium text-gray-900 dark:text-gray-100">
1665
+
2,900
1666
+
</div>
1667
+
<div className="text-gray-500 dark:text-gray-400">
1668
+
Followers
1669
+
</div>
1670
+
</div>
1671
+
</div> */}
1672
+
</div>
1673
+
</div>
1674
+
1675
+
{/* <HoverCard.Arrow className="fill-gray-50 dark:fill-gray-900" /> */}
1676
+
</HoverCard.Content>
1677
+
</HoverCard.Portal>
1678
+
</HoverCard.Root>
1679
+
1329
1680
<div style={{ display: "flex", alignItems: "flex-start", zIndex: 2 }}>
1330
1681
<div
1331
1682
style={{
···
1339
1690
}}
1340
1691
>
1341
1692
{/* dummy for later use */}
1342
-
<div style={{ width: 42, height: 42 + 8, minHeight: 42 + 8 }} />
1693
+
<div style={{ width: 42, height: 42 + 6, minHeight: 42 + 6 }} />
1343
1694
{/* reply line !!!! bottomReplyLine */}
1344
1695
{bottomReplyLine && (
1345
1696
<div
···
1485
1836
<div
1486
1837
style={{
1487
1838
fontSize: 16,
1488
-
marginBottom: !post.embed /*|| depth > 0*/ ? 0 : 8,
1839
+
marginBottom: !post.embed || concise ? 0 : 8,
1489
1840
whiteSpace: "pre-wrap",
1490
1841
textAlign: "left",
1491
1842
overflowWrap: "anywhere",
1492
1843
wordBreak: "break-word",
1493
-
//color: theme.text,
1844
+
...(concise && {
1845
+
display: "-webkit-box",
1846
+
WebkitBoxOrient: "vertical",
1847
+
WebkitLineClamp: 2,
1848
+
overflow: "hidden",
1849
+
}),
1494
1850
}}
1495
1851
className="text-gray-900 dark:text-gray-100"
1496
1852
>
1497
1853
{fedi ? (
1498
1854
<>
1499
-
<span className="dangerousFediContent"
1855
+
<span
1856
+
className="dangerousFediContent"
1500
1857
dangerouslySetInnerHTML={{
1501
1858
__html: DOMPurify.sanitize(fedi),
1502
1859
}}
···
1512
1869
</>
1513
1870
)}
1514
1871
</div>
1515
-
{post.embed && depth < 1 ? (
1872
+
{post.embed && depth < 1 && !concise ? (
1516
1873
<PostEmbeds
1517
1874
embed={post.embed}
1518
1875
//moderation={moderation}
···
1534
1891
</div>
1535
1892
</>
1536
1893
)}
1537
-
<div style={{ paddingTop: post.embed && depth < 1 ? 4 : 0 }}>
1894
+
<div
1895
+
style={{
1896
+
paddingTop: post.embed && !concise && depth < 1 ? 4 : 0,
1897
+
}}
1898
+
>
1538
1899
<>
1539
1900
{expanded && (
1540
1901
<div
···
1571
1932
}}
1572
1933
className="text-gray-500 dark:text-gray-400"
1573
1934
>
1574
-
<span style={btnstyle}>
1575
-
<MdiCommentOutline />
1576
-
{post.replyCount}
1577
-
</span>
1578
1935
<HitSlopButton
1579
1936
onClick={() => {
1580
-
repostOrUnrepostPost();
1937
+
setComposerPost({ kind: "reply", parent: post.uri });
1581
1938
}}
1582
1939
style={{
1583
1940
...btnstyle,
1584
-
...(hasRetweeted ? { color: "#5CEFAA" } : {}),
1585
1941
}}
1586
1942
>
1587
-
{hasRetweeted ? <MdiRepeatGreen /> : <MdiRepeat />}
1588
-
{(post.repostCount || 0) + (hasRetweeted ? 1 : 0)}
1943
+
<MdiCommentOutline />
1944
+
{post.replyCount}
1589
1945
</HitSlopButton>
1946
+
<DropdownMenu.Root modal={false}>
1947
+
<DropdownMenu.Trigger asChild>
1948
+
<div
1949
+
style={{
1950
+
...btnstyle,
1951
+
...(hasRetweeted ? { color: "#5CEFAA" } : {}),
1952
+
}}
1953
+
aria-label="Repost or quote post"
1954
+
>
1955
+
{hasRetweeted ? <MdiRepeatGreen /> : <MdiRepeat />}
1956
+
{post.repostCount ?? 0}
1957
+
</div>
1958
+
</DropdownMenu.Trigger>
1959
+
1960
+
<DropdownMenu.Portal>
1961
+
<DropdownMenu.Content
1962
+
align="start"
1963
+
sideOffset={5}
1964
+
className="bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 w-32 z-50 overflow-hidden"
1965
+
>
1966
+
<DropdownMenu.Item
1967
+
onSelect={repostOrUnrepostPost}
1968
+
className="px-3 py-2 text-sm flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-700"
1969
+
>
1970
+
<MdiRepeat
1971
+
className={hasRetweeted ? "text-green-400" : ""}
1972
+
/>
1973
+
<span>{hasRetweeted ? "Undo Repost" : "Repost"}</span>
1974
+
</DropdownMenu.Item>
1975
+
1976
+
<DropdownMenu.Item
1977
+
onSelect={() => {
1978
+
setComposerPost({
1979
+
kind: "quote",
1980
+
subject: post.uri,
1981
+
});
1982
+
}}
1983
+
className="px-3 py-2 text-sm flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-700"
1984
+
>
1985
+
{/* You might want a specific quote icon here */}
1986
+
<MdiCommentOutline />
1987
+
<span>Quote</span>
1988
+
</DropdownMenu.Item>
1989
+
</DropdownMenu.Content>
1990
+
</DropdownMenu.Portal>
1991
+
</DropdownMenu.Root>
1590
1992
<HitSlopButton
1591
1993
onClick={() => {
1592
-
likeOrUnlikePost();
1994
+
toggle();
1593
1995
}}
1594
1996
style={{
1595
1997
...btnstyle,
1596
-
...(hasLiked ? { color: "#EC4899" } : {}),
1998
+
...(liked ? { color: "#EC4899" } : {}),
1597
1999
}}
1598
2000
>
1599
-
{hasLiked ? <MdiCardsHeart /> : <MdiCardsHeartOutline />}
1600
-
{(post.likeCount || 0) + (hasLiked ? 1 : 0)}
2001
+
{liked ? <MdiCardsHeart /> : <MdiCardsHeartOutline />}
2002
+
{(post.likeCount || 0) + (liked ? 1 : 0)}
1601
2003
</HitSlopButton>
1602
2004
<div style={{ display: "flex", gap: 8 }}>
1603
2005
<HitSlopButton
···
1611
2013
"/post/" +
1612
2014
post.uri.split("/").pop()
1613
2015
);
2016
+
renderSnack({
2017
+
title: "Copied to clipboard!",
2018
+
});
1614
2019
} catch (_e) {
1615
2020
// idk
2021
+
renderSnack({
2022
+
title: "Failed to copy link",
2023
+
});
1616
2024
}
1617
2025
}}
1618
2026
style={{
···
1621
2029
>
1622
2030
<MdiShareVariant />
1623
2031
</HitSlopButton>
1624
-
<span style={btnstyle}>
1625
-
<MdiMoreHoriz />
1626
-
</span>
2032
+
<HitSlopButton
2033
+
onClick={() => {
2034
+
renderSnack({
2035
+
title: "Not implemented yet...",
2036
+
});
2037
+
}}
2038
+
>
2039
+
<span style={btnstyle}>
2040
+
<MdiMoreHoriz />
2041
+
</span>
2042
+
</HitSlopButton>
1627
2043
</div>
1628
2044
</div>
1629
2045
)}
···
1728
2144
navigate,
1729
2145
postid,
1730
2146
nopics,
1731
-
lightboxCallback
2147
+
lightboxCallback,
1732
2148
}: {
1733
2149
embed?: Embed;
1734
2150
moderation?: ModerationDecision;
···
1739
2155
navigate: (_: any) => void;
1740
2156
postid?: { did: string; rkey: string };
1741
2157
nopics?: boolean;
1742
-
lightboxCallback?: (d:LightboxProps) => void;
2158
+
lightboxCallback?: (d: LightboxProps) => void;
1743
2159
}) {
1744
2160
//const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
1745
-
function setLightboxIndex(number:number) {
2161
+
function setLightboxIndex(number: number) {
1746
2162
navigate({
1747
-
to: "/profile/$did/post/$rkey/image/$i",
1748
-
params: {
1749
-
did: postid?.did,
1750
-
rkey: postid?.rkey,
1751
-
i: number.toString(),
1752
-
},
1753
-
});
2163
+
to: "/profile/$did/post/$rkey/image/$i",
2164
+
params: {
2165
+
did: postid?.did,
2166
+
rkey: postid?.rkey,
2167
+
i: number.toString(),
2168
+
},
2169
+
});
1754
2170
}
1755
2171
if (
1756
2172
AppBskyEmbedRecordWithMedia.isView(embed) &&
···
1849
2265
}
1850
2266
1851
2267
if (AppBskyEmbedRecord.isView(embed)) {
2268
+
// hey im really lazy and im gonna do it the bad way
2269
+
const reallybaduri = (embed?.record as any)?.uri as string | undefined;
2270
+
const reallybadaturi = reallybaduri ? new AtUri(reallybaduri) : undefined;
2271
+
1852
2272
// custom feed embed (i.e. generator view)
1853
2273
if (AppBskyFeedDefs.isGeneratorView(embed.record)) {
1854
2274
// stopgap sorry
···
1858
2278
// <MaybeFeedCard view={embed.record} />
1859
2279
// </div>
1860
2280
// )
2281
+
} else if (
2282
+
!!reallybaduri &&
2283
+
!!reallybadaturi &&
2284
+
reallybadaturi.collection === "app.bsky.feed.generator"
2285
+
) {
2286
+
return (
2287
+
<div className="rounded-xl border">
2288
+
<FeedItemRenderAturiLoader aturi={reallybaduri} disableBottomBorder />
2289
+
</div>
2290
+
);
1861
2291
}
1862
2292
1863
2293
// list embed
···
1869
2299
// <MaybeListCard view={embed.record} />
1870
2300
// </div>
1871
2301
// )
2302
+
} else if (
2303
+
!!reallybaduri &&
2304
+
!!reallybadaturi &&
2305
+
reallybadaturi.collection === "app.bsky.graph.list"
2306
+
) {
2307
+
return (
2308
+
<div className="rounded-xl border">
2309
+
<FeedItemRenderAturiLoader
2310
+
aturi={reallybaduri}
2311
+
disableBottomBorder
2312
+
listmode
2313
+
disablePropagation
2314
+
/>
2315
+
</div>
2316
+
);
1872
2317
}
1873
2318
1874
2319
// starter pack embed
···
1880
2325
// <StarterPackCard starterPack={embed.record} />
1881
2326
// </div>
1882
2327
// )
2328
+
} else if (
2329
+
!!reallybaduri &&
2330
+
!!reallybadaturi &&
2331
+
reallybadaturi.collection === "app.bsky.graph.starterpack"
2332
+
) {
2333
+
return (
2334
+
<div className="rounded-xl border">
2335
+
<FeedItemRenderAturiLoader
2336
+
aturi={reallybaduri}
2337
+
disableBottomBorder
2338
+
listmode
2339
+
disablePropagation
2340
+
/>
2341
+
</div>
2342
+
);
1883
2343
}
1884
2344
1885
2345
// quote post
···
1939
2399
</div>
1940
2400
);
1941
2401
} else {
2402
+
console.log("what the hell is a ", embed);
1942
2403
return <>sorry</>;
1943
2404
}
1944
2405
//return <QuotePostRenderer record={embed.record} moderation={moderation} />;
···
1962
2423
src: img.fullsize,
1963
2424
alt: img.alt,
1964
2425
}));
1965
-
console.log("rendering images")
2426
+
console.log("rendering images");
1966
2427
if (lightboxCallback) {
1967
-
lightboxCallback({images: lightboxImages})
1968
-
console.log("rendering images")
1969
-
};
2428
+
lightboxCallback({ images: lightboxImages });
2429
+
console.log("rendering images");
2430
+
}
1970
2431
1971
2432
if (nopics) return;
1972
2433
···
2248
2709
// =
2249
2710
if (AppBskyEmbedVideo.isView(embed)) {
2250
2711
// hls playlist
2712
+
if (nopics) return;
2251
2713
const playlist = embed.playlist;
2252
2714
return (
2253
2715
<SmartHLSPlayer
···
2339
2801
return { start, end, feature: f.features[0] };
2340
2802
});
2341
2803
}
2342
-
function renderTextWithFacets({
2804
+
export function renderTextWithFacets({
2343
2805
text,
2344
2806
facets,
2345
2807
navigate,
···
2371
2833
className="link"
2372
2834
style={{
2373
2835
textDecoration: "none",
2374
-
color: "rgb(29, 122, 242)",
2836
+
color: "var(--link-text-color)",
2375
2837
wordBreak: "break-all",
2376
2838
}}
2377
2839
target="_blank"
···
2391
2853
result.push(
2392
2854
<span
2393
2855
key={start}
2394
-
style={{ color: "rgb(29, 122, 242)" }}
2856
+
style={{ color: "var(--link-text-color)" }}
2395
2857
className=" cursor-pointer"
2396
2858
onClick={(e) => {
2397
2859
e.stopPropagation();
···
2409
2871
result.push(
2410
2872
<span
2411
2873
key={start}
2412
-
style={{ color: "rgb(29, 122, 242)" }}
2874
+
style={{ color: "var(--link-text-color)" }}
2413
2875
onClick={(e) => {
2414
2876
e.stopPropagation();
2415
2877
}}
+2
src/main.tsx
+2
src/main.tsx
+163
src/providers/LikeMutationQueueProvider.tsx
+163
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 { renderSnack } from "~/routes/__root";
9
+
import { constellationURLAtom, internalLikedPostsAtom } from "~/utils/atoms";
10
+
import { constructArbitraryQuery, constructConstellationQuery, type linksRecordsResponse } from "~/utils/useQuery";
11
+
12
+
export type LikeRecord = { uri: string; target: string; cid: string };
13
+
export type LikeMutation = { type: 'like'; target: string; cid: string };
14
+
export type UnlikeMutation = { type: 'unlike'; likeRecordUri: string; target: string, originalRecord: LikeRecord };
15
+
export type Mutation = LikeMutation | UnlikeMutation;
16
+
17
+
interface LikeMutationQueueContextType {
18
+
fastState: (target: string) => LikeRecord | null | undefined;
19
+
fastToggle: (target:string, cid:string) => void;
20
+
backfillState: (target: string, user: string) => Promise<void>;
21
+
}
22
+
23
+
const LikeMutationQueueContext = createContext<LikeMutationQueueContextType | undefined>(undefined);
24
+
25
+
export function LikeMutationQueueProvider({ children }: { children: React.ReactNode }) {
26
+
const { agent } = useAuth();
27
+
const queryClient = useQueryClient();
28
+
const [likedPosts, setLikedPosts] = useAtom(internalLikedPostsAtom);
29
+
const [constellationurl] = useAtom(constellationURLAtom);
30
+
31
+
const likedPostsRef = useRef(likedPosts);
32
+
useEffect(() => {
33
+
likedPostsRef.current = likedPosts;
34
+
}, [likedPosts]);
35
+
36
+
const queueRef = useRef<Mutation[]>([]);
37
+
const runningRef = useRef(false);
38
+
39
+
const fastState = (target: string) => likedPosts[target];
40
+
41
+
const setFastState = useCallback(
42
+
(target: string, record: LikeRecord | null) =>
43
+
setLikedPosts((prev) => ({ ...prev, [target]: record })),
44
+
[setLikedPosts]
45
+
);
46
+
47
+
const enqueue = (mutation: Mutation) => queueRef.current.push(mutation);
48
+
49
+
const fastToggle = useCallback((target: string, cid: string) => {
50
+
const likedRecord = likedPostsRef.current[target];
51
+
52
+
if (likedRecord) {
53
+
setFastState(target, null);
54
+
if (likedRecord.uri !== 'pending') {
55
+
enqueue({ type: "unlike", likeRecordUri: likedRecord.uri, target, originalRecord: likedRecord });
56
+
}
57
+
} else {
58
+
setFastState(target, { uri: "pending", target, cid });
59
+
enqueue({ type: "like", target, cid });
60
+
}
61
+
}, [setFastState]);
62
+
63
+
/**
64
+
*
65
+
* @deprecated dont use it yet, will cause infinite rerenders
66
+
*/
67
+
const backfillState = async (target: string, user: string) => {
68
+
const query = constructConstellationQuery({
69
+
constellation: constellationurl,
70
+
method: "/links",
71
+
target,
72
+
collection: "app.bsky.feed.like",
73
+
path: ".subject.uri",
74
+
dids: [user],
75
+
});
76
+
const data = await queryClient.fetchQuery(query);
77
+
const likes = (data as linksRecordsResponse)?.linking_records?.slice(0, 50) ?? [];
78
+
const found = likes.find((r) => r.did === user);
79
+
if (found) {
80
+
const uri = `at://${found.did}/${found.collection}/${found.rkey}`;
81
+
const ciddata = await queryClient.fetchQuery(
82
+
constructArbitraryQuery(uri)
83
+
);
84
+
if (ciddata?.cid)
85
+
setFastState(target, { uri, target, cid: ciddata?.cid });
86
+
} else {
87
+
setFastState(target, null);
88
+
}
89
+
};
90
+
91
+
92
+
useEffect(() => {
93
+
if (!agent?.did) return;
94
+
95
+
const processQueue = async () => {
96
+
if (runningRef.current || queueRef.current.length === 0) return;
97
+
runningRef.current = true;
98
+
99
+
while (queueRef.current.length > 0) {
100
+
const mutation = queueRef.current.shift()!;
101
+
try {
102
+
if (mutation.type === "like") {
103
+
const newRecord = {
104
+
repo: agent.did!,
105
+
collection: "app.bsky.feed.like",
106
+
rkey: TID.next().toString(),
107
+
record: {
108
+
$type: "app.bsky.feed.like",
109
+
subject: { uri: mutation.target, cid: mutation.cid },
110
+
createdAt: new Date().toISOString(),
111
+
},
112
+
};
113
+
const response = await agent.com.atproto.repo.createRecord(newRecord);
114
+
if (!response.success) throw new Error("createRecord failed");
115
+
116
+
const uri = `at://${agent.did}/${newRecord.collection}/${newRecord.rkey}`;
117
+
setFastState(mutation.target, {
118
+
uri,
119
+
target: mutation.target,
120
+
cid: mutation.cid,
121
+
});
122
+
} else if (mutation.type === "unlike") {
123
+
const aturi = new AtUri(mutation.likeRecordUri);
124
+
await agent.com.atproto.repo.deleteRecord({ repo: agent.did!, collection: aturi.collection, rkey: aturi.rkey });
125
+
setFastState(mutation.target, null);
126
+
}
127
+
} catch (err) {
128
+
console.error("Like mutation failed, reverting:", err);
129
+
renderSnack({
130
+
title: 'Like Mutation Failed',
131
+
description: 'Please try again.',
132
+
//button: { label: 'Try Again', onClick: () => console.log('whatever') },
133
+
})
134
+
if (mutation.type === 'like') {
135
+
setFastState(mutation.target, null);
136
+
} else if (mutation.type === 'unlike') {
137
+
setFastState(mutation.target, mutation.originalRecord);
138
+
}
139
+
}
140
+
}
141
+
runningRef.current = false;
142
+
};
143
+
144
+
const interval = setInterval(processQueue, 1000);
145
+
return () => clearInterval(interval);
146
+
}, [agent, setFastState]);
147
+
148
+
const value = { fastState, fastToggle, backfillState };
149
+
150
+
return (
151
+
<LikeMutationQueueContext value={value}>
152
+
{children}
153
+
</LikeMutationQueueContext>
154
+
);
155
+
}
156
+
157
+
export function useLikeMutationQueue() {
158
+
const context = use(LikeMutationQueueContext);
159
+
if (context === undefined) {
160
+
throw new Error('useLikeMutationQueue must be used within a LikeMutationQueueProvider');
161
+
}
162
+
return context;
163
+
}
+26
-23
src/providers/UnifiedAuthProvider.tsx
+26
-23
src/providers/UnifiedAuthProvider.tsx
···
1
-
// src/providers/UnifiedAuthProvider.tsx
2
-
// Import both Agent and the (soon to be deprecated) AtpAgent
3
1
import { Agent, AtpAgent, type AtpSessionData } from "@atproto/api";
4
2
import {
5
3
type OAuthSession,
···
7
5
TokenRefreshError,
8
6
TokenRevokedError,
9
7
} from "@atproto/oauth-client-browser";
8
+
import { useAtom } from "jotai";
10
9
import React, {
11
10
createContext,
12
11
use,
···
15
14
useState,
16
15
} from "react";
17
16
18
-
import { oauthClient } from "../utils/oauthClient"; // Adjust path if needed
17
+
import { quickAuthAtom } from "~/utils/atoms";
18
+
19
+
import { oauthClient } from "../utils/oauthClient";
19
20
20
-
// Define the unified status and authentication method
21
21
type AuthStatus = "loading" | "signedIn" | "signedOut";
22
22
type AuthMethod = "password" | "oauth" | null;
23
23
24
24
interface AuthContextValue {
25
-
agent: Agent | null; // The agent is typed as the base class `Agent`
25
+
agent: Agent | null;
26
26
status: AuthStatus;
27
27
authMethod: AuthMethod;
28
28
loginWithPassword: (
···
41
41
}: {
42
42
children: React.ReactNode;
43
43
}) => {
44
-
// The state is typed as the base class `Agent`, which accepts both `Agent` and `AtpAgent` instances.
45
44
const [agent, setAgent] = useState<Agent | null>(null);
46
45
const [status, setStatus] = useState<AuthStatus>("loading");
47
46
const [authMethod, setAuthMethod] = useState<AuthMethod>(null);
48
47
const [oauthSession, setOauthSession] = useState<OAuthSession | null>(null);
48
+
const [quickAuth, setQuickAuth] = useAtom(quickAuthAtom);
49
49
50
-
// Unified Initialization Logic
51
50
const initialize = useCallback(async () => {
52
-
// --- 1. Try OAuth initialization first ---
53
51
try {
54
52
const oauthResult = await oauthClient.init();
55
53
if (oauthResult) {
56
54
// /*mass comment*/ console.log("OAuth session restored.");
57
-
const apiAgent = new Agent(oauthResult.session); // Standard Agent
55
+
const apiAgent = new Agent(oauthResult.session);
58
56
setAgent(apiAgent);
59
57
setOauthSession(oauthResult.session);
60
58
setAuthMethod("oauth");
61
59
setStatus("signedIn");
62
-
return; // Success
60
+
setQuickAuth(apiAgent?.did || null);
61
+
return;
63
62
}
64
63
} catch (e) {
65
64
console.error("OAuth init failed, checking password session.", e);
65
+
if (!quickAuth) {
66
+
// quickAuth restoration. if last used method is oauth we immediately call for oauth redo
67
+
// (and set a persistent atom somewhere to not retry again if it failed)
68
+
}
66
69
}
67
70
68
-
// --- 2. If no OAuth, try password-based session using AtpAgent ---
69
71
try {
70
72
const service = localStorage.getItem("service");
71
73
const sessionString = localStorage.getItem("sess");
72
74
73
75
if (service && sessionString) {
74
76
// /*mass comment*/ console.log("Resuming password-based session using AtpAgent...");
75
-
// Use the original, working AtpAgent logic
76
77
const apiAgent = new AtpAgent({ service });
77
78
const session: AtpSessionData = JSON.parse(sessionString);
78
79
await apiAgent.resumeSession(session);
79
80
80
81
// /*mass comment*/ console.log("Password-based session resumed successfully.");
81
-
setAgent(apiAgent); // This works because AtpAgent is a subclass of Agent
82
+
setAgent(apiAgent);
82
83
setAuthMethod("password");
83
84
setStatus("signedIn");
84
-
return; // Success
85
+
setQuickAuth(apiAgent?.did || null);
86
+
return;
85
87
}
86
88
} catch (e) {
87
89
console.error("Failed to resume password-based session.", e);
···
89
91
localStorage.removeItem("service");
90
92
}
91
93
92
-
// --- 3. If neither worked, user is signed out ---
93
94
// /*mass comment*/ console.log("No active session found.");
94
95
setStatus("signedOut");
95
96
setAgent(null);
96
97
setAuthMethod(null);
97
-
}, []);
98
+
// do we want to null it here?
99
+
setQuickAuth(null);
100
+
}, [quickAuth, setQuickAuth]);
98
101
99
102
useEffect(() => {
100
103
const handleOAuthSessionDeleted = (
···
105
108
setOauthSession(null);
106
109
setAuthMethod(null);
107
110
setStatus("signedOut");
111
+
setQuickAuth(null);
108
112
};
109
113
110
114
oauthClient.addEventListener("deleted", handleOAuthSessionDeleted as EventListener);
···
113
117
return () => {
114
118
oauthClient.removeEventListener("deleted", handleOAuthSessionDeleted as EventListener);
115
119
};
116
-
}, [initialize]);
120
+
}, [initialize, setQuickAuth]);
117
121
118
-
// --- Login Methods ---
119
122
const loginWithPassword = async (
120
123
user: string,
121
124
password: string,
···
125
128
setStatus("loading");
126
129
try {
127
130
let sessionData: AtpSessionData | undefined;
128
-
// Use the AtpAgent for its simple login and session persistence
129
131
const apiAgent = new AtpAgent({
130
132
service,
131
133
persistSession: (_evt, sess) => {
···
137
139
if (sessionData) {
138
140
localStorage.setItem("service", service);
139
141
localStorage.setItem("sess", JSON.stringify(sessionData));
140
-
setAgent(apiAgent); // Store the AtpAgent instance in our state
142
+
setAgent(apiAgent);
141
143
setAuthMethod("password");
142
144
setStatus("signedIn");
145
+
setQuickAuth(apiAgent?.did || null);
143
146
// /*mass comment*/ console.log("Successfully logged in with password.");
144
147
} else {
145
148
throw new Error("Session data not persisted after login.");
···
147
150
} catch (e) {
148
151
console.error("Password login failed:", e);
149
152
setStatus("signedOut");
153
+
setQuickAuth(null);
150
154
throw e;
151
155
}
152
156
};
···
161
165
}
162
166
}, [status]);
163
167
164
-
// --- Unified Logout ---
165
168
const logout = useCallback(async () => {
166
169
if (status !== "signedIn" || !agent) return;
167
170
setStatus("loading");
···
173
176
} else if (authMethod === "password") {
174
177
localStorage.removeItem("service");
175
178
localStorage.removeItem("sess");
176
-
// AtpAgent has its own logout methods
177
179
await (agent as AtpAgent).com.atproto.server.deleteSession();
178
180
// /*mass comment*/ console.log("Password-based session deleted.");
179
181
}
···
184
186
setAuthMethod(null);
185
187
setOauthSession(null);
186
188
setStatus("signedOut");
189
+
setQuickAuth(null);
187
190
}
188
-
}, [status, authMethod, agent, oauthSession]);
191
+
}, [status, agent, authMethod, oauthSession, setQuickAuth]);
189
192
190
193
return (
191
194
<AuthContext
+150
src/routeTree.gen.ts
+150
src/routeTree.gen.ts
···
12
12
import { Route as SettingsRouteImport } from './routes/settings'
13
13
import { Route as SearchRouteImport } from './routes/search'
14
14
import { Route as NotificationsRouteImport } from './routes/notifications'
15
+
import { Route as ModerationRouteImport } from './routes/moderation'
15
16
import { Route as FeedsRouteImport } from './routes/feeds'
16
17
import { Route as PathlessLayoutRouteImport } from './routes/_pathlessLayout'
17
18
import { Route as IndexRouteImport } from './routes/index'
18
19
import { Route as CallbackIndexRouteImport } from './routes/callback/index'
19
20
import { Route as PathlessLayoutNestedLayoutRouteImport } from './routes/_pathlessLayout/_nested-layout'
20
21
import { Route as ProfileDidIndexRouteImport } from './routes/profile.$did/index'
22
+
import { Route as ProfileDidFollowsRouteImport } from './routes/profile.$did/follows'
23
+
import { Route as ProfileDidFollowersRouteImport } from './routes/profile.$did/followers'
21
24
import { Route as PathlessLayoutNestedLayoutRouteBRouteImport } from './routes/_pathlessLayout/_nested-layout/route-b'
22
25
import { Route as PathlessLayoutNestedLayoutRouteARouteImport } from './routes/_pathlessLayout/_nested-layout/route-a'
23
26
import { Route as ProfileDidPostRkeyRouteImport } from './routes/profile.$did/post.$rkey'
27
+
import { Route as ProfileDidFeedRkeyRouteImport } from './routes/profile.$did/feed.$rkey'
28
+
import { Route as ProfileDidPostRkeyRepostedByRouteImport } from './routes/profile.$did/post.$rkey.reposted-by'
29
+
import { Route as ProfileDidPostRkeyQuotesRouteImport } from './routes/profile.$did/post.$rkey.quotes'
30
+
import { Route as ProfileDidPostRkeyLikedByRouteImport } from './routes/profile.$did/post.$rkey.liked-by'
24
31
import { Route as ProfileDidPostRkeyImageIRouteImport } from './routes/profile.$did/post.$rkey.image.$i'
25
32
26
33
const SettingsRoute = SettingsRouteImport.update({
···
38
45
path: '/notifications',
39
46
getParentRoute: () => rootRouteImport,
40
47
} as any)
48
+
const ModerationRoute = ModerationRouteImport.update({
49
+
id: '/moderation',
50
+
path: '/moderation',
51
+
getParentRoute: () => rootRouteImport,
52
+
} as any)
41
53
const FeedsRoute = FeedsRouteImport.update({
42
54
id: '/feeds',
43
55
path: '/feeds',
···
67
79
path: '/profile/$did/',
68
80
getParentRoute: () => rootRouteImport,
69
81
} as any)
82
+
const ProfileDidFollowsRoute = ProfileDidFollowsRouteImport.update({
83
+
id: '/profile/$did/follows',
84
+
path: '/profile/$did/follows',
85
+
getParentRoute: () => rootRouteImport,
86
+
} as any)
87
+
const ProfileDidFollowersRoute = ProfileDidFollowersRouteImport.update({
88
+
id: '/profile/$did/followers',
89
+
path: '/profile/$did/followers',
90
+
getParentRoute: () => rootRouteImport,
91
+
} as any)
70
92
const PathlessLayoutNestedLayoutRouteBRoute =
71
93
PathlessLayoutNestedLayoutRouteBRouteImport.update({
72
94
id: '/route-b',
···
84
106
path: '/profile/$did/post/$rkey',
85
107
getParentRoute: () => rootRouteImport,
86
108
} as any)
109
+
const ProfileDidFeedRkeyRoute = ProfileDidFeedRkeyRouteImport.update({
110
+
id: '/profile/$did/feed/$rkey',
111
+
path: '/profile/$did/feed/$rkey',
112
+
getParentRoute: () => rootRouteImport,
113
+
} as any)
114
+
const ProfileDidPostRkeyRepostedByRoute =
115
+
ProfileDidPostRkeyRepostedByRouteImport.update({
116
+
id: '/reposted-by',
117
+
path: '/reposted-by',
118
+
getParentRoute: () => ProfileDidPostRkeyRoute,
119
+
} as any)
120
+
const ProfileDidPostRkeyQuotesRoute =
121
+
ProfileDidPostRkeyQuotesRouteImport.update({
122
+
id: '/quotes',
123
+
path: '/quotes',
124
+
getParentRoute: () => ProfileDidPostRkeyRoute,
125
+
} as any)
126
+
const ProfileDidPostRkeyLikedByRoute =
127
+
ProfileDidPostRkeyLikedByRouteImport.update({
128
+
id: '/liked-by',
129
+
path: '/liked-by',
130
+
getParentRoute: () => ProfileDidPostRkeyRoute,
131
+
} as any)
87
132
const ProfileDidPostRkeyImageIRoute =
88
133
ProfileDidPostRkeyImageIRouteImport.update({
89
134
id: '/image/$i',
···
94
139
export interface FileRoutesByFullPath {
95
140
'/': typeof IndexRoute
96
141
'/feeds': typeof FeedsRoute
142
+
'/moderation': typeof ModerationRoute
97
143
'/notifications': typeof NotificationsRoute
98
144
'/search': typeof SearchRoute
99
145
'/settings': typeof SettingsRoute
100
146
'/callback': typeof CallbackIndexRoute
101
147
'/route-a': typeof PathlessLayoutNestedLayoutRouteARoute
102
148
'/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute
149
+
'/profile/$did/followers': typeof ProfileDidFollowersRoute
150
+
'/profile/$did/follows': typeof ProfileDidFollowsRoute
103
151
'/profile/$did': typeof ProfileDidIndexRoute
152
+
'/profile/$did/feed/$rkey': typeof ProfileDidFeedRkeyRoute
104
153
'/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren
154
+
'/profile/$did/post/$rkey/liked-by': typeof ProfileDidPostRkeyLikedByRoute
155
+
'/profile/$did/post/$rkey/quotes': typeof ProfileDidPostRkeyQuotesRoute
156
+
'/profile/$did/post/$rkey/reposted-by': typeof ProfileDidPostRkeyRepostedByRoute
105
157
'/profile/$did/post/$rkey/image/$i': typeof ProfileDidPostRkeyImageIRoute
106
158
}
107
159
export interface FileRoutesByTo {
108
160
'/': typeof IndexRoute
109
161
'/feeds': typeof FeedsRoute
162
+
'/moderation': typeof ModerationRoute
110
163
'/notifications': typeof NotificationsRoute
111
164
'/search': typeof SearchRoute
112
165
'/settings': typeof SettingsRoute
113
166
'/callback': typeof CallbackIndexRoute
114
167
'/route-a': typeof PathlessLayoutNestedLayoutRouteARoute
115
168
'/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute
169
+
'/profile/$did/followers': typeof ProfileDidFollowersRoute
170
+
'/profile/$did/follows': typeof ProfileDidFollowsRoute
116
171
'/profile/$did': typeof ProfileDidIndexRoute
172
+
'/profile/$did/feed/$rkey': typeof ProfileDidFeedRkeyRoute
117
173
'/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren
174
+
'/profile/$did/post/$rkey/liked-by': typeof ProfileDidPostRkeyLikedByRoute
175
+
'/profile/$did/post/$rkey/quotes': typeof ProfileDidPostRkeyQuotesRoute
176
+
'/profile/$did/post/$rkey/reposted-by': typeof ProfileDidPostRkeyRepostedByRoute
118
177
'/profile/$did/post/$rkey/image/$i': typeof ProfileDidPostRkeyImageIRoute
119
178
}
120
179
export interface FileRoutesById {
···
122
181
'/': typeof IndexRoute
123
182
'/_pathlessLayout': typeof PathlessLayoutRouteWithChildren
124
183
'/feeds': typeof FeedsRoute
184
+
'/moderation': typeof ModerationRoute
125
185
'/notifications': typeof NotificationsRoute
126
186
'/search': typeof SearchRoute
127
187
'/settings': typeof SettingsRoute
···
129
189
'/callback/': typeof CallbackIndexRoute
130
190
'/_pathlessLayout/_nested-layout/route-a': typeof PathlessLayoutNestedLayoutRouteARoute
131
191
'/_pathlessLayout/_nested-layout/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute
192
+
'/profile/$did/followers': typeof ProfileDidFollowersRoute
193
+
'/profile/$did/follows': typeof ProfileDidFollowsRoute
132
194
'/profile/$did/': typeof ProfileDidIndexRoute
195
+
'/profile/$did/feed/$rkey': typeof ProfileDidFeedRkeyRoute
133
196
'/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren
197
+
'/profile/$did/post/$rkey/liked-by': typeof ProfileDidPostRkeyLikedByRoute
198
+
'/profile/$did/post/$rkey/quotes': typeof ProfileDidPostRkeyQuotesRoute
199
+
'/profile/$did/post/$rkey/reposted-by': typeof ProfileDidPostRkeyRepostedByRoute
134
200
'/profile/$did/post/$rkey/image/$i': typeof ProfileDidPostRkeyImageIRoute
135
201
}
136
202
export interface FileRouteTypes {
···
138
204
fullPaths:
139
205
| '/'
140
206
| '/feeds'
207
+
| '/moderation'
141
208
| '/notifications'
142
209
| '/search'
143
210
| '/settings'
144
211
| '/callback'
145
212
| '/route-a'
146
213
| '/route-b'
214
+
| '/profile/$did/followers'
215
+
| '/profile/$did/follows'
147
216
| '/profile/$did'
217
+
| '/profile/$did/feed/$rkey'
148
218
| '/profile/$did/post/$rkey'
219
+
| '/profile/$did/post/$rkey/liked-by'
220
+
| '/profile/$did/post/$rkey/quotes'
221
+
| '/profile/$did/post/$rkey/reposted-by'
149
222
| '/profile/$did/post/$rkey/image/$i'
150
223
fileRoutesByTo: FileRoutesByTo
151
224
to:
152
225
| '/'
153
226
| '/feeds'
227
+
| '/moderation'
154
228
| '/notifications'
155
229
| '/search'
156
230
| '/settings'
157
231
| '/callback'
158
232
| '/route-a'
159
233
| '/route-b'
234
+
| '/profile/$did/followers'
235
+
| '/profile/$did/follows'
160
236
| '/profile/$did'
237
+
| '/profile/$did/feed/$rkey'
161
238
| '/profile/$did/post/$rkey'
239
+
| '/profile/$did/post/$rkey/liked-by'
240
+
| '/profile/$did/post/$rkey/quotes'
241
+
| '/profile/$did/post/$rkey/reposted-by'
162
242
| '/profile/$did/post/$rkey/image/$i'
163
243
id:
164
244
| '__root__'
165
245
| '/'
166
246
| '/_pathlessLayout'
167
247
| '/feeds'
248
+
| '/moderation'
168
249
| '/notifications'
169
250
| '/search'
170
251
| '/settings'
···
172
253
| '/callback/'
173
254
| '/_pathlessLayout/_nested-layout/route-a'
174
255
| '/_pathlessLayout/_nested-layout/route-b'
256
+
| '/profile/$did/followers'
257
+
| '/profile/$did/follows'
175
258
| '/profile/$did/'
259
+
| '/profile/$did/feed/$rkey'
176
260
| '/profile/$did/post/$rkey'
261
+
| '/profile/$did/post/$rkey/liked-by'
262
+
| '/profile/$did/post/$rkey/quotes'
263
+
| '/profile/$did/post/$rkey/reposted-by'
177
264
| '/profile/$did/post/$rkey/image/$i'
178
265
fileRoutesById: FileRoutesById
179
266
}
···
181
268
IndexRoute: typeof IndexRoute
182
269
PathlessLayoutRoute: typeof PathlessLayoutRouteWithChildren
183
270
FeedsRoute: typeof FeedsRoute
271
+
ModerationRoute: typeof ModerationRoute
184
272
NotificationsRoute: typeof NotificationsRoute
185
273
SearchRoute: typeof SearchRoute
186
274
SettingsRoute: typeof SettingsRoute
187
275
CallbackIndexRoute: typeof CallbackIndexRoute
276
+
ProfileDidFollowersRoute: typeof ProfileDidFollowersRoute
277
+
ProfileDidFollowsRoute: typeof ProfileDidFollowsRoute
188
278
ProfileDidIndexRoute: typeof ProfileDidIndexRoute
279
+
ProfileDidFeedRkeyRoute: typeof ProfileDidFeedRkeyRoute
189
280
ProfileDidPostRkeyRoute: typeof ProfileDidPostRkeyRouteWithChildren
190
281
}
191
282
···
212
303
preLoaderRoute: typeof NotificationsRouteImport
213
304
parentRoute: typeof rootRouteImport
214
305
}
306
+
'/moderation': {
307
+
id: '/moderation'
308
+
path: '/moderation'
309
+
fullPath: '/moderation'
310
+
preLoaderRoute: typeof ModerationRouteImport
311
+
parentRoute: typeof rootRouteImport
312
+
}
215
313
'/feeds': {
216
314
id: '/feeds'
217
315
path: '/feeds'
···
254
352
preLoaderRoute: typeof ProfileDidIndexRouteImport
255
353
parentRoute: typeof rootRouteImport
256
354
}
355
+
'/profile/$did/follows': {
356
+
id: '/profile/$did/follows'
357
+
path: '/profile/$did/follows'
358
+
fullPath: '/profile/$did/follows'
359
+
preLoaderRoute: typeof ProfileDidFollowsRouteImport
360
+
parentRoute: typeof rootRouteImport
361
+
}
362
+
'/profile/$did/followers': {
363
+
id: '/profile/$did/followers'
364
+
path: '/profile/$did/followers'
365
+
fullPath: '/profile/$did/followers'
366
+
preLoaderRoute: typeof ProfileDidFollowersRouteImport
367
+
parentRoute: typeof rootRouteImport
368
+
}
257
369
'/_pathlessLayout/_nested-layout/route-b': {
258
370
id: '/_pathlessLayout/_nested-layout/route-b'
259
371
path: '/route-b'
···
275
387
preLoaderRoute: typeof ProfileDidPostRkeyRouteImport
276
388
parentRoute: typeof rootRouteImport
277
389
}
390
+
'/profile/$did/feed/$rkey': {
391
+
id: '/profile/$did/feed/$rkey'
392
+
path: '/profile/$did/feed/$rkey'
393
+
fullPath: '/profile/$did/feed/$rkey'
394
+
preLoaderRoute: typeof ProfileDidFeedRkeyRouteImport
395
+
parentRoute: typeof rootRouteImport
396
+
}
397
+
'/profile/$did/post/$rkey/reposted-by': {
398
+
id: '/profile/$did/post/$rkey/reposted-by'
399
+
path: '/reposted-by'
400
+
fullPath: '/profile/$did/post/$rkey/reposted-by'
401
+
preLoaderRoute: typeof ProfileDidPostRkeyRepostedByRouteImport
402
+
parentRoute: typeof ProfileDidPostRkeyRoute
403
+
}
404
+
'/profile/$did/post/$rkey/quotes': {
405
+
id: '/profile/$did/post/$rkey/quotes'
406
+
path: '/quotes'
407
+
fullPath: '/profile/$did/post/$rkey/quotes'
408
+
preLoaderRoute: typeof ProfileDidPostRkeyQuotesRouteImport
409
+
parentRoute: typeof ProfileDidPostRkeyRoute
410
+
}
411
+
'/profile/$did/post/$rkey/liked-by': {
412
+
id: '/profile/$did/post/$rkey/liked-by'
413
+
path: '/liked-by'
414
+
fullPath: '/profile/$did/post/$rkey/liked-by'
415
+
preLoaderRoute: typeof ProfileDidPostRkeyLikedByRouteImport
416
+
parentRoute: typeof ProfileDidPostRkeyRoute
417
+
}
278
418
'/profile/$did/post/$rkey/image/$i': {
279
419
id: '/profile/$did/post/$rkey/image/$i'
280
420
path: '/image/$i'
···
316
456
)
317
457
318
458
interface ProfileDidPostRkeyRouteChildren {
459
+
ProfileDidPostRkeyLikedByRoute: typeof ProfileDidPostRkeyLikedByRoute
460
+
ProfileDidPostRkeyQuotesRoute: typeof ProfileDidPostRkeyQuotesRoute
461
+
ProfileDidPostRkeyRepostedByRoute: typeof ProfileDidPostRkeyRepostedByRoute
319
462
ProfileDidPostRkeyImageIRoute: typeof ProfileDidPostRkeyImageIRoute
320
463
}
321
464
322
465
const ProfileDidPostRkeyRouteChildren: ProfileDidPostRkeyRouteChildren = {
466
+
ProfileDidPostRkeyLikedByRoute: ProfileDidPostRkeyLikedByRoute,
467
+
ProfileDidPostRkeyQuotesRoute: ProfileDidPostRkeyQuotesRoute,
468
+
ProfileDidPostRkeyRepostedByRoute: ProfileDidPostRkeyRepostedByRoute,
323
469
ProfileDidPostRkeyImageIRoute: ProfileDidPostRkeyImageIRoute,
324
470
}
325
471
···
330
476
IndexRoute: IndexRoute,
331
477
PathlessLayoutRoute: PathlessLayoutRouteWithChildren,
332
478
FeedsRoute: FeedsRoute,
479
+
ModerationRoute: ModerationRoute,
333
480
NotificationsRoute: NotificationsRoute,
334
481
SearchRoute: SearchRoute,
335
482
SettingsRoute: SettingsRoute,
336
483
CallbackIndexRoute: CallbackIndexRoute,
484
+
ProfileDidFollowersRoute: ProfileDidFollowersRoute,
485
+
ProfileDidFollowsRoute: ProfileDidFollowsRoute,
337
486
ProfileDidIndexRoute: ProfileDidIndexRoute,
487
+
ProfileDidFeedRkeyRoute: ProfileDidFeedRkeyRoute,
338
488
ProfileDidPostRkeyRoute: ProfileDidPostRkeyRouteWithChildren,
339
489
}
340
490
export const routeTree = rootRouteImport
+237
-121
src/routes/__root.tsx
+237
-121
src/routes/__root.tsx
···
12
12
useNavigate,
13
13
} from "@tanstack/react-router";
14
14
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
15
-
import { useState } from "react";
15
+
import { useAtom } from "jotai";
16
16
import * as React from "react";
17
+
import { toast as sonnerToast } from "sonner";
18
+
import { Toaster } from "sonner";
17
19
import { KeepAliveOutlet, KeepAliveProvider } from "tanstack-router-keepalive";
18
20
21
+
import { Composer } from "~/components/Composer";
19
22
import { DefaultCatchBoundary } from "~/components/DefaultCatchBoundary";
23
+
import { Import } from "~/components/Import";
20
24
import Login from "~/components/Login";
21
25
import { NotFound } from "~/components/NotFound";
26
+
import { FluentEmojiHighContrastGlowingStar } from "~/components/Star";
27
+
import { LikeMutationQueueProvider } from "~/providers/LikeMutationQueueProvider";
22
28
import { UnifiedAuthProvider, useAuth } from "~/providers/UnifiedAuthProvider";
29
+
import { composerAtom, hueAtom, useAtomCssVar } from "~/utils/atoms";
23
30
import { seo } from "~/utils/seo";
24
31
25
32
export const Route = createRootRouteWithContext<{
···
75
82
function RootComponent() {
76
83
return (
77
84
<UnifiedAuthProvider>
78
-
<RootDocument>
79
-
<KeepAliveProvider>
80
-
<KeepAliveOutlet />
81
-
</KeepAliveProvider>
82
-
</RootDocument>
85
+
<LikeMutationQueueProvider>
86
+
<RootDocument>
87
+
<KeepAliveProvider>
88
+
<AppToaster />
89
+
<KeepAliveOutlet />
90
+
</KeepAliveProvider>
91
+
</RootDocument>
92
+
</LikeMutationQueueProvider>
83
93
</UnifiedAuthProvider>
84
94
);
85
95
}
86
96
97
+
export function AppToaster() {
98
+
return (
99
+
<Toaster
100
+
position="bottom-center"
101
+
toastOptions={{
102
+
duration: 4000,
103
+
}}
104
+
/>
105
+
);
106
+
}
107
+
108
+
export function renderSnack({
109
+
title,
110
+
description,
111
+
button,
112
+
}: Omit<ToastProps, "id">) {
113
+
return sonnerToast.custom((id) => (
114
+
<Snack
115
+
id={id}
116
+
title={title}
117
+
description={description}
118
+
button={
119
+
button?.label
120
+
? {
121
+
label: button?.label,
122
+
onClick: () => {
123
+
button?.onClick?.();
124
+
},
125
+
}
126
+
: undefined
127
+
}
128
+
/>
129
+
));
130
+
}
131
+
132
+
function Snack(props: ToastProps) {
133
+
const { title, description, button, id } = props;
134
+
135
+
return (
136
+
<div
137
+
role="status"
138
+
aria-live="polite"
139
+
className="
140
+
w-full md:max-w-[520px]
141
+
flex items-center justify-between
142
+
rounded-md
143
+
px-4 py-3
144
+
shadow-sm
145
+
dark:bg-gray-300 dark:text-gray-900
146
+
bg-gray-700 text-gray-100
147
+
ring-1 dark:ring-gray-200 ring-gray-800
148
+
"
149
+
>
150
+
<div className="flex-1 min-w-0">
151
+
<p className="text-sm font-medium truncate">{title}</p>
152
+
{description ? (
153
+
<p className="mt-1 text-sm dark:text-gray-600 text-gray-300 truncate">
154
+
{description}
155
+
</p>
156
+
) : null}
157
+
</div>
158
+
159
+
{button ? (
160
+
<div className="ml-4 flex-shrink-0">
161
+
<button
162
+
className="
163
+
text-sm font-medium
164
+
px-3 py-1 rounded-md
165
+
bg-gray-200 text-gray-900
166
+
hover:bg-gray-300
167
+
dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700
168
+
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-300 dark:focus:ring-gray-700
169
+
"
170
+
onClick={() => {
171
+
button.onClick();
172
+
sonnerToast.dismiss(id);
173
+
}}
174
+
>
175
+
{button.label}
176
+
</button>
177
+
</div>
178
+
) : null}
179
+
<button className=" ml-4"
180
+
onClick={() => {
181
+
sonnerToast.dismiss(id);
182
+
}}
183
+
>
184
+
<IconMdiClose />
185
+
</button>
186
+
</div>
187
+
);
188
+
}
189
+
190
+
/* Types */
191
+
interface ToastProps {
192
+
id: string | number;
193
+
title: string;
194
+
description?: string;
195
+
button?: {
196
+
label: string;
197
+
onClick: () => void;
198
+
};
199
+
}
200
+
87
201
function RootDocument({ children }: { children: React.ReactNode }) {
202
+
useAtomCssVar(hueAtom, "--tw-gray-hue");
88
203
const location = useLocation();
89
204
const navigate = useNavigate();
90
205
const { agent } = useAuth();
···
95
210
agent &&
96
211
(location.pathname === `/profile/${agent?.did}` ||
97
212
location.pathname === `/profile/${encodeURIComponent(agent?.did ?? "")}`);
213
+
const isSettings = location.pathname.startsWith("/settings");
214
+
const isSearch = location.pathname.startsWith("/search");
215
+
const isFeeds = location.pathname.startsWith("/feeds");
216
+
const isModeration = location.pathname.startsWith("/moderation");
98
217
99
-
const [postOpen, setPostOpen] = useState(false);
100
-
const [postText, setPostText] = useState("");
101
-
const [posting, setPosting] = useState(false);
102
-
const [postSuccess, setPostSuccess] = useState(false);
103
-
const [postError, setPostError] = useState<string | null>(null);
218
+
const locationEnum:
219
+
| "feeds"
220
+
| "search"
221
+
| "settings"
222
+
| "notifications"
223
+
| "profile"
224
+
| "moderation"
225
+
| "home" = isFeeds
226
+
? "feeds"
227
+
: isSearch
228
+
? "search"
229
+
: isSettings
230
+
? "settings"
231
+
: isNotifications
232
+
? "notifications"
233
+
: isProfile
234
+
? "profile"
235
+
: isModeration
236
+
? "moderation"
237
+
: "home";
104
238
105
-
async function handlePost() {
106
-
if (!agent) return;
107
-
setPosting(true);
108
-
setPostError(null);
109
-
try {
110
-
await agent.com.atproto.repo.createRecord({
111
-
collection: "app.bsky.feed.post",
112
-
repo: agent.assertDid,
113
-
record: {
114
-
$type: "app.bsky.feed.post",
115
-
text: postText,
116
-
createdAt: new Date().toISOString(),
117
-
},
118
-
});
119
-
setPostSuccess(true);
120
-
setPostText("");
121
-
setTimeout(() => {
122
-
setPostSuccess(false);
123
-
setPostOpen(false);
124
-
}, 1500);
125
-
} catch (e: any) {
126
-
setPostError(e?.message || "Failed to post");
127
-
} finally {
128
-
setPosting(false);
129
-
}
130
-
}
239
+
const [, setComposerPost] = useAtom(composerAtom);
131
240
132
241
return (
133
242
<>
134
-
{postOpen && (
135
-
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
136
-
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-6 w-full max-w-md relative">
137
-
<button
138
-
className="absolute top-2 right-2 text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
139
-
onClick={() => !posting && setPostOpen(false)}
140
-
disabled={posting}
141
-
aria-label="Close"
142
-
>
143
-
ร
144
-
</button>
145
-
<h2 className="text-lg font-bold mb-2">Create Post</h2>
146
-
{postSuccess ? (
147
-
<div className="flex flex-col items-center justify-center py-8">
148
-
<span className="text-green-500 text-4xl mb-2">โ</span>
149
-
<span className="text-green-600">Posted!</span>
150
-
</div>
151
-
) : (
152
-
<>
153
-
<textarea
154
-
className="w-full border rounded p-2 mb-2 dark:bg-gray-800 dark:border-gray-700"
155
-
rows={4}
156
-
placeholder="What's on your mind?"
157
-
value={postText}
158
-
onChange={(e) => setPostText(e.target.value)}
159
-
disabled={posting}
160
-
autoFocus
161
-
/>
162
-
{postError && (
163
-
<div className="text-red-500 text-sm mb-2">{postError}</div>
164
-
)}
165
-
<button
166
-
className="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded disabled:opacity-50"
167
-
onClick={handlePost}
168
-
disabled={posting || !postText.trim()}
169
-
>
170
-
{posting ? "Posting..." : "Post"}
171
-
</button>
172
-
</>
173
-
)}
174
-
</div>
175
-
</div>
176
-
)}
243
+
<Composer />
177
244
178
245
<div className="min-h-screen flex justify-center bg-gray-50 dark:bg-gray-950">
179
246
<nav className="hidden lg:flex h-screen w-[250px] flex-col gap-0 p-4 dark:border-gray-800 sticky top-0 self-start">
180
247
<div className="flex items-center gap-3 mb-4">
181
-
<img src="/redstar.png" alt="Red Dwarf Logo" className="w-8 h-8" />
248
+
<FluentEmojiHighContrastGlowingStar
249
+
className="h-8 w-8"
250
+
style={{
251
+
color:
252
+
"oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))",
253
+
}}
254
+
/>
182
255
<span className="font-extrabold text-2xl tracking-tight text-gray-900 dark:text-gray-100">
183
256
Red Dwarf{" "}
184
257
{/* <span className="text-gray-500 dark:text-gray-400 text-sm">
···
191
264
<IconMaterialSymbolsHomeOutline className="w-6 h-6" />
192
265
}
193
266
ActiveIcon={<IconMaterialSymbolsHome className="w-6 h-6" />}
194
-
active={isHome}
267
+
active={locationEnum === "home"}
195
268
onClickCallbback={() =>
196
269
navigate({
197
270
to: "/",
···
202
275
/>
203
276
204
277
<MaterialNavItem
278
+
InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />}
279
+
ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />}
280
+
active={locationEnum === "search"}
281
+
onClickCallbback={() =>
282
+
navigate({
283
+
to: "/search",
284
+
//params: { did: agent.assertDid },
285
+
})
286
+
}
287
+
text="Explore"
288
+
/>
289
+
<MaterialNavItem
205
290
InactiveIcon={
206
291
<IconMaterialSymbolsNotificationsOutline className="w-6 h-6" />
207
292
}
208
293
ActiveIcon={
209
294
<IconMaterialSymbolsNotifications className="w-6 h-6" />
210
295
}
211
-
active={isNotifications}
296
+
active={locationEnum === "notifications"}
212
297
onClickCallbback={() =>
213
298
navigate({
214
299
to: "/notifications",
···
220
305
<MaterialNavItem
221
306
InactiveIcon={<IconMaterialSymbolsTag className="w-6 h-6" />}
222
307
ActiveIcon={<IconMaterialSymbolsTag className="w-6 h-6" />}
223
-
active={location.pathname.startsWith("/feeds")}
308
+
active={locationEnum === "feeds"}
224
309
onClickCallbback={() =>
225
310
navigate({
226
311
to: "/feeds",
···
230
315
text="Feeds"
231
316
/>
232
317
<MaterialNavItem
233
-
InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />}
234
-
ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />}
235
-
active={location.pathname.startsWith("/search")}
318
+
InactiveIcon={<IconMdiShieldOutline className="w-6 h-6" />}
319
+
ActiveIcon={<IconMdiShield className="w-6 h-6" />}
320
+
active={locationEnum === "moderation"}
236
321
onClickCallbback={() =>
237
322
navigate({
238
-
to: "/search",
323
+
to: "/moderation",
239
324
//params: { did: agent.assertDid },
240
325
})
241
326
}
242
-
text="Search"
327
+
text="Moderation"
243
328
/>
244
329
<MaterialNavItem
245
330
InactiveIcon={
···
248
333
ActiveIcon={
249
334
<IconMaterialSymbolsAccountCircle className="w-6 h-6" />
250
335
}
251
-
active={isProfile ?? false}
336
+
active={locationEnum === "profile"}
252
337
onClickCallbback={() => {
253
338
if (authed && agent && agent.assertDid) {
254
339
//window.location.href = `/profile/${agent.assertDid}`;
···
265
350
<IconMaterialSymbolsSettingsOutline className="w-6 h-6" />
266
351
}
267
352
ActiveIcon={<IconMaterialSymbolsSettings className="w-6 h-6" />}
268
-
active={location.pathname.startsWith("/settings")}
353
+
active={locationEnum === "settings"}
269
354
onClickCallbback={() =>
270
355
navigate({
271
356
to: "/settings",
···
279
364
InactiveIcon={<IconMdiPencilOutline className="w-6 h-6" />}
280
365
ActiveIcon={<IconMdiPencilOutline className="w-6 h-6" />}
281
366
//active={true}
282
-
onClickCallbback={() => setPostOpen(true)}
367
+
onClickCallbback={() => setComposerPost({ kind: "root" })}
283
368
text="Post"
284
369
/>
285
370
</div>
···
417
502
418
503
<nav className="hidden sm:flex items-center lg:hidden h-screen flex-col gap-2 p-4 dark:border-gray-800 sticky top-0 self-start">
419
504
<div className="flex items-center gap-3 mb-4">
420
-
<img src="/redstar.png" alt="Red Dwarf Logo" className="w-8 h-8" />
505
+
<FluentEmojiHighContrastGlowingStar
506
+
className="h-8 w-8"
507
+
style={{
508
+
color:
509
+
"oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))",
510
+
}}
511
+
/>
421
512
</div>
422
513
<MaterialNavItem
423
514
small
···
425
516
<IconMaterialSymbolsHomeOutline className="w-6 h-6" />
426
517
}
427
518
ActiveIcon={<IconMaterialSymbolsHome className="w-6 h-6" />}
428
-
active={isHome}
519
+
active={locationEnum === "home"}
429
520
onClickCallbback={() =>
430
521
navigate({
431
522
to: "/",
···
435
526
text="Home"
436
527
/>
437
528
529
+
<MaterialNavItem
530
+
small
531
+
InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />}
532
+
ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />}
533
+
active={locationEnum === "search"}
534
+
onClickCallbback={() =>
535
+
navigate({
536
+
to: "/search",
537
+
//params: { did: agent.assertDid },
538
+
})
539
+
}
540
+
text="Explore"
541
+
/>
438
542
<MaterialNavItem
439
543
small
440
544
InactiveIcon={
···
443
547
ActiveIcon={
444
548
<IconMaterialSymbolsNotifications className="w-6 h-6" />
445
549
}
446
-
active={isNotifications}
550
+
active={locationEnum === "notifications"}
447
551
onClickCallbback={() =>
448
552
navigate({
449
553
to: "/notifications",
···
456
560
small
457
561
InactiveIcon={<IconMaterialSymbolsTag className="w-6 h-6" />}
458
562
ActiveIcon={<IconMaterialSymbolsTag className="w-6 h-6" />}
459
-
active={location.pathname.startsWith("/feeds")}
563
+
active={locationEnum === "feeds"}
460
564
onClickCallbback={() =>
461
565
navigate({
462
566
to: "/feeds",
···
467
571
/>
468
572
<MaterialNavItem
469
573
small
470
-
InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />}
471
-
ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />}
472
-
active={location.pathname.startsWith("/search")}
574
+
InactiveIcon={<IconMdiShieldOutline className="w-6 h-6" />}
575
+
ActiveIcon={<IconMdiShield className="w-6 h-6" />}
576
+
active={locationEnum === "moderation"}
473
577
onClickCallbback={() =>
474
578
navigate({
475
-
to: "/search",
579
+
to: "/moderation",
476
580
//params: { did: agent.assertDid },
477
581
})
478
582
}
479
-
text="Search"
583
+
text="Moderation"
480
584
/>
481
585
<MaterialNavItem
482
586
small
···
486
590
ActiveIcon={
487
591
<IconMaterialSymbolsAccountCircle className="w-6 h-6" />
488
592
}
489
-
active={isProfile ?? false}
593
+
active={locationEnum === "profile"}
490
594
onClickCallbback={() => {
491
595
if (authed && agent && agent.assertDid) {
492
596
//window.location.href = `/profile/${agent.assertDid}`;
···
504
608
<IconMaterialSymbolsSettingsOutline className="w-6 h-6" />
505
609
}
506
610
ActiveIcon={<IconMaterialSymbolsSettings className="w-6 h-6" />}
507
-
active={location.pathname.startsWith("/settings")}
611
+
active={locationEnum === "settings"}
508
612
onClickCallbback={() =>
509
613
navigate({
510
614
to: "/settings",
···
519
623
InactiveIcon={<IconMdiPencilOutline className="w-6 h-6" />}
520
624
ActiveIcon={<IconMdiPencilOutline className="w-6 h-6" />}
521
625
//active={true}
522
-
onClickCallbback={() => setPostOpen(true)}
626
+
onClickCallbback={() => setComposerPost({ kind: "root" })}
523
627
text="Post"
524
628
/>
525
629
</div>
···
529
633
<button
530
634
className="lg:hidden fixed bottom-22 right-4 z-50 bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 rounded-2xl w-14 h-14 flex items-center justify-center transition-all"
531
635
style={{ boxShadow: "0 4px 24px 0 rgba(0,0,0,0.12)" }}
532
-
onClick={() => setPostOpen(true)}
636
+
onClick={() => setComposerPost({ kind: "root" })}
533
637
type="button"
534
638
aria-label="Create Post"
535
639
>
···
546
650
</main>
547
651
548
652
<aside className="hidden lg:flex h-screen w-[250px] sticky top-0 self-start flex-col">
653
+
<div className="px-4 pt-4">
654
+
<Import />
655
+
</div>
549
656
<Login />
550
657
551
658
<div className="flex-1"></div>
552
659
<p className="text-xs text-gray-400 dark:text-gray-500 text-justify mx-4 mb-4">
553
-
Red Dwarf is a bluesky client that uses Constellation and direct PDS
554
-
queries. Skylite would be a self-hosted bluesky "instance". Stay
555
-
tuned for the release of Skylite.
660
+
Red Dwarf is a Bluesky client that does not rely on any Bluesky API
661
+
App Servers. Instead, it uses Microcosm to fetch records directly
662
+
from each users' PDS (via Slingshot) and connect them using
663
+
backlinks (via Constellation)
556
664
</p>
557
665
</aside>
558
666
</div>
···
566
674
<IconMaterialSymbolsHomeOutline className="w-6 h-6" />
567
675
}
568
676
ActiveIcon={<IconMaterialSymbolsHome className="w-6 h-6" />}
569
-
active={isHome}
677
+
active={locationEnum === "home"}
570
678
onClickCallbback={() =>
571
679
navigate({
572
680
to: "/",
···
594
702
small
595
703
InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />}
596
704
ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />}
597
-
active={location.pathname.startsWith("/search")}
705
+
active={locationEnum === "search"}
598
706
onClickCallbback={() =>
599
707
navigate({
600
708
to: "/search",
601
709
//params: { did: agent.assertDid },
602
710
})
603
711
}
604
-
text="Search"
712
+
text="Explore"
605
713
/>
606
714
{/* <Link
607
715
to="/search"
···
626
734
ActiveIcon={
627
735
<IconMaterialSymbolsNotifications className="w-6 h-6" />
628
736
}
629
-
active={isNotifications}
737
+
active={locationEnum === "notifications"}
630
738
onClickCallbback={() =>
631
739
navigate({
632
740
to: "/notifications",
···
661
769
ActiveIcon={
662
770
<IconMaterialSymbolsAccountCircle className="w-6 h-6" />
663
771
}
664
-
active={isProfile ?? false}
772
+
active={locationEnum === "profile"}
665
773
onClickCallbback={() => {
666
774
if (authed && agent && agent.assertDid) {
667
775
//window.location.href = `/profile/${agent.assertDid}`;
···
699
807
<IconMaterialSymbolsSettingsOutline className="w-6 h-6" />
700
808
}
701
809
ActiveIcon={<IconMaterialSymbolsSettings className="w-6 h-6" />}
702
-
active={location.pathname.startsWith("/settings")}
810
+
active={locationEnum === "settings" || locationEnum === "feeds" || locationEnum === "moderation"}
703
811
onClickCallbback={() =>
704
812
navigate({
705
813
to: "/settings",
···
728
836
) : (
729
837
<div className="lg:hidden flex items-center fixed bottom-0 left-0 right-0 justify-between px-4 py-3 border-0 shadow border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 z-10">
730
838
<div className="flex items-center gap-2">
731
-
<img src="/redstar.png" alt="Red Dwarf Logo" className="w-6 h-6" />
839
+
<FluentEmojiHighContrastGlowingStar
840
+
className="h-6 w-6"
841
+
style={{
842
+
color:
843
+
"oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))",
844
+
}}
845
+
/>
732
846
<span className="font-bold text-lg text-gray-900 dark:text-gray-100">
733
847
Red Dwarf{" "}
734
848
{/* <span className="text-gray-500 dark:text-gray-400 text-sm">
···
748
862
);
749
863
}
750
864
751
-
function MaterialNavItem({
865
+
export function MaterialNavItem({
752
866
InactiveIcon,
753
867
ActiveIcon,
754
868
text,
···
841
955
<div className={`${!small && "mr-2"} ${active ? " " : " "}`}>
842
956
{active ? ActiveIcon : InactiveIcon}
843
957
</div>
844
-
{!small && (<span
845
-
className={`text-[17px] text-roboto ${active ? "font-medium" : ""}`}
846
-
>
847
-
{text}
848
-
</span>)}
958
+
{!small && (
959
+
<span
960
+
className={`text-[17px] text-roboto ${active ? "font-medium" : ""}`}
961
+
>
962
+
{text}
963
+
</span>
964
+
)}
849
965
</button>
850
966
);
851
967
}
+18
-1
src/routes/feeds.tsx
+18
-1
src/routes/feeds.tsx
···
1
1
import { createFileRoute } from "@tanstack/react-router";
2
2
3
+
import { Header } from "~/components/Header";
4
+
3
5
export const Route = createFileRoute("/feeds")({
4
6
component: Feeds,
5
7
});
6
8
7
9
export function Feeds() {
8
-
return <div className="p-6">Feeds page (coming soon)</div>;
10
+
return (
11
+
<div className="">
12
+
<Header
13
+
title={`Feeds`}
14
+
backButtonCallback={() => {
15
+
if (window.history.length > 1) {
16
+
window.history.back();
17
+
} else {
18
+
window.location.assign("/");
19
+
}
20
+
}}
21
+
bottomBorderDisabled={true}
22
+
/>
23
+
Feeds page (coming soon)
24
+
</div>
25
+
);
9
26
}
+84
-73
src/routes/index.tsx
+84
-73
src/routes/index.tsx
···
1
1
import { createFileRoute } from "@tanstack/react-router";
2
2
import { useAtom } from "jotai";
3
3
import * as React from "react";
4
-
import { useEffect, useLayoutEffect } from "react";
4
+
import { useLayoutEffect, useState } from "react";
5
5
6
6
import { Header } from "~/components/Header";
7
7
import { InfiniteCustomFeed } from "~/components/InfiniteCustomFeed";
8
8
import { useAuth } from "~/providers/UnifiedAuthProvider";
9
9
import {
10
-
agentAtom,
11
-
authedAtom,
12
10
feedScrollPositionsAtom,
13
11
isAtTopAtom,
12
+
quickAuthAtom,
14
13
selectedFeedUriAtom,
15
-
store,
16
14
} from "~/utils/atoms";
17
15
//import { usePersistentStore } from "~/providers/PersistentStoreProvider";
18
16
import {
···
107
105
} = useAuth();
108
106
const authed = !!agent?.did;
109
107
110
-
useEffect(() => {
111
-
if (agent?.did) {
112
-
store.set(authedAtom, true);
113
-
} else {
114
-
store.set(authedAtom, false);
115
-
}
116
-
}, [status, agent, authed]);
117
-
useEffect(() => {
118
-
if (agent) {
119
-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
120
-
// @ts-ignore is it just me or is the type really weird here it should be Agent not AtpAgent
121
-
store.set(agentAtom, agent);
122
-
} else {
123
-
store.set(agentAtom, null);
124
-
}
125
-
}, [status, agent, authed]);
108
+
// i dont remember why this is even here
109
+
// useEffect(() => {
110
+
// if (agent?.did) {
111
+
// store.set(authedAtom, true);
112
+
// } else {
113
+
// store.set(authedAtom, false);
114
+
// }
115
+
// }, [status, agent, authed]);
116
+
// useEffect(() => {
117
+
// if (agent) {
118
+
// // eslint-disable-next-line @typescript-eslint/ban-ts-comment
119
+
// // @ts-ignore is it just me or is the type really weird here it should be Agent not AtpAgent
120
+
// store.set(agentAtom, agent);
121
+
// } else {
122
+
// store.set(agentAtom, null);
123
+
// }
124
+
// }, [status, agent, authed]);
126
125
127
126
//const { get, set } = usePersistentStore();
128
127
// const [feed, setFeed] = React.useState<any[]>([]);
···
162
161
163
162
// const savedFeeds = savedFeedsPref?.items || [];
164
163
165
-
const identityresultmaybe = useQueryIdentity(agent?.did);
164
+
const [quickAuth, setQuickAuth] = useAtom(quickAuthAtom);
165
+
const isAuthRestoring = quickAuth ? status === "loading" : false;
166
+
167
+
const identityresultmaybe = useQueryIdentity(!isAuthRestoring ? agent?.did : undefined);
166
168
const identity = identityresultmaybe?.data;
167
169
168
170
const prefsresultmaybe = useQueryPreferences({
169
-
agent: agent ?? undefined,
170
-
pdsUrl: identity?.pds,
171
+
agent: !isAuthRestoring ? (agent ?? undefined) : undefined,
172
+
pdsUrl: !isAuthRestoring ? (identity?.pds) : undefined,
171
173
});
172
174
const prefs = prefsresultmaybe?.data;
173
175
···
178
180
return savedFeedsPref?.items || [];
179
181
}, [prefs]);
180
182
181
-
const [persistentSelectedFeed, setPersistentSelectedFeed] =
182
-
useAtom(selectedFeedUriAtom); // React.useState<string | null>(null);
183
-
const [unauthedSelectedFeed, setUnauthedSelectedFeed] = React.useState(
184
-
persistentSelectedFeed
185
-
); // React.useState<string | null>(null);
183
+
const [persistentSelectedFeed, setPersistentSelectedFeed] = useAtom(selectedFeedUriAtom);
184
+
const [unauthedSelectedFeed, setUnauthedSelectedFeed] = useState(persistentSelectedFeed);
186
185
const selectedFeed = agent?.did
187
186
? persistentSelectedFeed
188
187
: unauthedSelectedFeed;
···
306
305
}, [scrollPositions]);
307
306
308
307
useLayoutEffect(() => {
308
+
if (isAuthRestoring) return;
309
309
const savedPosition = scrollPositions[selectedFeed ?? "null"] ?? 0;
310
310
311
311
window.scrollTo({ top: savedPosition, behavior: "instant" });
312
312
// eslint-disable-next-line react-hooks/exhaustive-deps
313
-
}, [selectedFeed]);
313
+
}, [selectedFeed, isAuthRestoring]);
314
314
315
315
useLayoutEffect(() => {
316
-
if (!selectedFeed) return;
316
+
if (!selectedFeed || isAuthRestoring) return;
317
317
318
318
const handleScroll = () => {
319
319
scrollPositionsRef.current = {
···
328
328
329
329
setScrollPositions(scrollPositionsRef.current);
330
330
};
331
-
}, [selectedFeed, setScrollPositions]);
331
+
}, [isAuthRestoring, selectedFeed, setScrollPositions]);
332
332
333
-
const feedGengetrecordquery = useQueryArbitrary(selectedFeed ?? undefined);
334
-
const feedServiceDid = (feedGengetrecordquery?.data?.value as any)?.did;
333
+
const feedGengetrecordquery = useQueryArbitrary(!isAuthRestoring ? selectedFeed ?? undefined : undefined);
334
+
const feedServiceDid = !isAuthRestoring ? (feedGengetrecordquery?.data?.value as any)?.did as string | undefined : undefined;
335
335
336
336
// const {
337
337
// data: feedData,
···
347
347
348
348
// const feed = feedData?.feed || [];
349
349
350
-
const isReadyForAuthedFeed =
351
-
authed && agent && identity?.pds && feedServiceDid;
352
-
const isReadyForUnauthedFeed = !authed && selectedFeed;
350
+
const isReadyForAuthedFeed = !isAuthRestoring && authed && agent && identity?.pds && feedServiceDid;
351
+
const isReadyForUnauthedFeed = !isAuthRestoring && !authed && selectedFeed;
353
352
354
353
355
354
const [isAtTop] = useAtom(isAtTopAtom);
···
358
357
<div
359
358
className={`relative flex flex-col divide-y divide-gray-200 dark:divide-gray-800 ${hidden && "hidden"}`}
360
359
>
361
-
{savedFeeds.length > 0 ? (
360
+
{!isAuthRestoring && savedFeeds.length > 0 ? (
362
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`}>
363
-
{savedFeeds.map((item: any, idx: number) => {
364
-
const label = item.value.split("/").pop() || item.value;
365
-
const isActive = selectedFeed === item.value;
366
-
return (
367
-
<button
368
-
key={item.value || idx}
369
-
className={`px-3 py-1 rounded-full whitespace-nowrap font-medium transition-colors ${
370
-
isActive
371
-
? "text-gray-900 dark:text-gray-100 hover:bg-gray-300 dark:bg-gray-700 bg-gray-200 hover:dark:bg-gray-600"
372
-
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 hover:dark:bg-gray-800"
373
-
// ? "bg-gray-500 text-white"
374
-
// : item.pinned
375
-
// ? "bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200"
376
-
// : "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-200"
377
-
}`}
378
-
onClick={() => setSelectedFeed(item.value)}
379
-
title={item.value}
380
-
>
381
-
{label}
382
-
{item.pinned && (
383
-
<span
384
-
className={`ml-1 text-xs ${
385
-
isActive
386
-
? "text-gray-900 dark:text-gray-100"
387
-
: "text-gray-600 dark:text-gray-400"
388
-
}`}
389
-
>
390
-
โ
391
-
</span>
392
-
)}
393
-
</button>
394
-
);
395
-
})}
362
+
{savedFeeds.map((item: any, idx: number) => {return <FeedTabOnTop key={item} item={item} idx={idx} />})}
396
363
</div>
397
364
) : (
398
365
// <span className="text-xl font-bold ml-2">Home</span>
···
410
377
/>
411
378
))} */}
412
379
413
-
{authed && (!identity?.pds || !feedServiceDid) && (
380
+
{isAuthRestoring || authed && (!identity?.pds || !feedServiceDid) && (
414
381
<div className="p-4 text-center text-gray-500">
415
382
Preparing your feed...
416
383
</div>
417
384
)}
418
385
419
-
{isReadyForAuthedFeed || isReadyForUnauthedFeed ? (
386
+
{!isAuthRestoring && (isReadyForAuthedFeed || isReadyForUnauthedFeed) ? (
420
387
<InfiniteCustomFeed
388
+
key={selectedFeed!}
421
389
feedUri={selectedFeed!}
422
390
pdsUrl={identity?.pds}
423
391
feedServiceDid={feedServiceDid}
424
392
/>
425
393
) : (
426
394
<div className="p-4 text-center text-gray-500">
427
-
Select a feed to get started.
395
+
Loading.......
428
396
</div>
429
397
)}
430
398
{/* {false && restoringScrollPosition && (
···
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({
+269
src/routes/moderation.tsx
+269
src/routes/moderation.tsx
···
1
+
import * as ATPAPI from "@atproto/api";
2
+
import {
3
+
isAdultContentPref,
4
+
isBskyAppStatePref,
5
+
isContentLabelPref,
6
+
isFeedViewPref,
7
+
isLabelersPref,
8
+
isMutedWordsPref,
9
+
isSavedFeedsPref,
10
+
} from "@atproto/api/dist/client/types/app/bsky/actor/defs";
11
+
import { createFileRoute } from "@tanstack/react-router";
12
+
import { useAtom } from "jotai";
13
+
import { Switch } from "radix-ui";
14
+
15
+
import { Header } from "~/components/Header";
16
+
import { useAuth } from "~/providers/UnifiedAuthProvider";
17
+
import { quickAuthAtom } from "~/utils/atoms";
18
+
import { useQueryIdentity, useQueryPreferences } from "~/utils/useQuery";
19
+
20
+
import { renderSnack } from "./__root";
21
+
import { NotificationItem } from "./notifications";
22
+
import { SettingHeading } from "./settings";
23
+
24
+
export const Route = createFileRoute("/moderation")({
25
+
component: RouteComponent,
26
+
});
27
+
28
+
function RouteComponent() {
29
+
const { agent } = useAuth();
30
+
31
+
const [quickAuth, setQuickAuth] = useAtom(quickAuthAtom);
32
+
const isAuthRestoring = quickAuth ? status === "loading" : false;
33
+
34
+
const identityresultmaybe = useQueryIdentity(
35
+
!isAuthRestoring ? agent?.did : undefined
36
+
);
37
+
const identity = identityresultmaybe?.data;
38
+
39
+
const prefsresultmaybe = useQueryPreferences({
40
+
agent: !isAuthRestoring ? (agent ?? undefined) : undefined,
41
+
pdsUrl: !isAuthRestoring ? identity?.pds : undefined,
42
+
});
43
+
const rawprefs = prefsresultmaybe?.data?.preferences as
44
+
| ATPAPI.AppBskyActorGetPreferences.OutputSchema["preferences"]
45
+
| undefined;
46
+
47
+
//console.log(JSON.stringify(prefs, null, 2))
48
+
49
+
const parsedPref = parsePreferences(rawprefs);
50
+
51
+
return (
52
+
<div>
53
+
<Header
54
+
title={`Moderation`}
55
+
backButtonCallback={() => {
56
+
if (window.history.length > 1) {
57
+
window.history.back();
58
+
} else {
59
+
window.location.assign("/");
60
+
}
61
+
}}
62
+
bottomBorderDisabled={true}
63
+
/>
64
+
{/* <SettingHeading title="Moderation Tools" />
65
+
<p>
66
+
todo: add all these:
67
+
<br />
68
+
- Interaction settings
69
+
<br />
70
+
- Muted words & tags
71
+
<br />
72
+
- Moderation lists
73
+
<br />
74
+
- Muted accounts
75
+
<br />
76
+
- Blocked accounts
77
+
<br />
78
+
- Verification settings
79
+
<br />
80
+
</p> */}
81
+
<SettingHeading title="Content Filters" />
82
+
<div>
83
+
<div className="flex items-center gap-4 px-4 py-2 border-b">
84
+
<label
85
+
htmlFor={`switch-${"hardcoded"}`}
86
+
className="flex flex-row flex-1"
87
+
>
88
+
<div className="flex flex-col">
89
+
<span className="text-md">{"Adult Content"}</span>
90
+
<span className="text-sm text-gray-500 dark:text-gray-400">
91
+
{"Enable adult content"}
92
+
</span>
93
+
</div>
94
+
</label>
95
+
96
+
<Switch.Root
97
+
id={`switch-${"hardcoded"}`}
98
+
checked={parsedPref?.adultContentEnabled}
99
+
onCheckedChange={(v) => {
100
+
renderSnack({
101
+
title: "Sorry... Modifying preferences is not implemented yet",
102
+
description: "You can use another app to change preferences",
103
+
//button: { label: 'Try Again', onClick: () => console.log('whatever') },
104
+
});
105
+
}}
106
+
className="m3switch root"
107
+
>
108
+
<Switch.Thumb className="m3switch thumb " />
109
+
</Switch.Root>
110
+
</div>
111
+
<div className="">
112
+
{Object.entries(parsedPref?.contentLabelPrefs ?? {}).map(
113
+
([label, visibility]) => (
114
+
<div
115
+
key={label}
116
+
className="flex justify-between border-b py-2 px-4"
117
+
>
118
+
<label
119
+
htmlFor={`switch-${"hardcoded"}`}
120
+
className="flex flex-row flex-1"
121
+
>
122
+
<div className="flex flex-col">
123
+
<span className="text-md">{label}</span>
124
+
<span className="text-sm text-gray-500 dark:text-gray-400">
125
+
{"uknown labeler"}
126
+
</span>
127
+
</div>
128
+
</label>
129
+
{/* <span className="text-md text-gray-500 dark:text-gray-400">
130
+
{visibility}
131
+
</span> */}
132
+
<TripleToggle
133
+
value={visibility as "ignore" | "warn" | "hide"}
134
+
/>
135
+
</div>
136
+
)
137
+
)}
138
+
</div>
139
+
</div>
140
+
<SettingHeading title="Advanced" />
141
+
{parsedPref?.labelers.map((labeler) => {
142
+
return (
143
+
<NotificationItem
144
+
key={labeler}
145
+
notification={labeler}
146
+
labeler={true}
147
+
/>
148
+
);
149
+
})}
150
+
</div>
151
+
);
152
+
}
153
+
154
+
export function TripleToggle({
155
+
value,
156
+
onChange,
157
+
}: {
158
+
value: "ignore" | "warn" | "hide";
159
+
onChange?: (newValue: "ignore" | "warn" | "hide") => void;
160
+
}) {
161
+
const options: Array<"ignore" | "warn" | "hide"> = ["ignore", "warn", "hide"];
162
+
return (
163
+
<div className="flex rounded-full bg-gray-200 dark:bg-gray-800 p-1 text-sm">
164
+
{options.map((opt) => {
165
+
const isActive = opt === value;
166
+
return (
167
+
<button
168
+
key={opt}
169
+
onClick={() => {
170
+
renderSnack({
171
+
title: "Sorry... Modifying preferences is not implemented yet",
172
+
description: "You can use another app to change preferences",
173
+
//button: { label: 'Try Again', onClick: () => console.log('whatever') },
174
+
});
175
+
onChange?.(opt);
176
+
}}
177
+
className={`flex-1 px-3 py-1.5 rounded-full transition-colors ${
178
+
isActive
179
+
? "bg-gray-400 dark:bg-gray-600 text-white"
180
+
: "text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-700"
181
+
}`}
182
+
>
183
+
{" "}
184
+
{opt.charAt(0).toUpperCase() + opt.slice(1)}
185
+
</button>
186
+
);
187
+
})}
188
+
</div>
189
+
);
190
+
}
191
+
192
+
type PrefItem =
193
+
ATPAPI.AppBskyActorGetPreferences.OutputSchema["preferences"][number];
194
+
195
+
export interface NormalizedPreferences {
196
+
contentLabelPrefs: Record<string, string>;
197
+
mutedWords: string[];
198
+
feedViewPrefs: Record<string, any>;
199
+
labelers: string[];
200
+
adultContentEnabled: boolean;
201
+
savedFeeds: {
202
+
pinned: string[];
203
+
saved: string[];
204
+
};
205
+
nuxs: string[];
206
+
}
207
+
208
+
export function parsePreferences(
209
+
prefs?: PrefItem[]
210
+
): NormalizedPreferences | undefined {
211
+
if (!prefs) return undefined;
212
+
const normalized: NormalizedPreferences = {
213
+
contentLabelPrefs: {},
214
+
mutedWords: [],
215
+
feedViewPrefs: {},
216
+
labelers: [],
217
+
adultContentEnabled: false,
218
+
savedFeeds: { pinned: [], saved: [] },
219
+
nuxs: [],
220
+
};
221
+
222
+
for (const pref of prefs) {
223
+
switch (pref.$type) {
224
+
case "app.bsky.actor.defs#contentLabelPref":
225
+
if (!isContentLabelPref(pref)) break;
226
+
normalized.contentLabelPrefs[pref.label] = pref.visibility;
227
+
break;
228
+
229
+
case "app.bsky.actor.defs#mutedWordsPref":
230
+
if (!isMutedWordsPref(pref)) break;
231
+
for (const item of pref.items ?? []) {
232
+
normalized.mutedWords.push(item.value);
233
+
}
234
+
break;
235
+
236
+
case "app.bsky.actor.defs#feedViewPref":
237
+
if (!isFeedViewPref(pref)) break;
238
+
normalized.feedViewPrefs[pref.feed] = pref;
239
+
break;
240
+
241
+
case "app.bsky.actor.defs#labelersPref":
242
+
if (!isLabelersPref(pref)) break;
243
+
normalized.labelers.push(...(pref.labelers?.map((l) => l.did) ?? []));
244
+
break;
245
+
246
+
case "app.bsky.actor.defs#adultContentPref":
247
+
if (!isAdultContentPref(pref)) break;
248
+
normalized.adultContentEnabled = !!pref.enabled;
249
+
break;
250
+
251
+
case "app.bsky.actor.defs#savedFeedsPref":
252
+
if (!isSavedFeedsPref(pref)) break;
253
+
normalized.savedFeeds.pinned.push(...(pref.pinned ?? []));
254
+
normalized.savedFeeds.saved.push(...(pref.saved ?? []));
255
+
break;
256
+
257
+
case "app.bsky.actor.defs#bskyAppStatePref":
258
+
if (!isBskyAppStatePref(pref)) break;
259
+
normalized.nuxs.push(...(pref.nuxs?.map((n) => n.id) ?? []));
260
+
break;
261
+
262
+
default:
263
+
// unknown pref type โ just ignore for now
264
+
break;
265
+
}
266
+
}
267
+
268
+
return normalized;
269
+
}
+644
-152
src/routes/notifications.tsx
+644
-152
src/routes/notifications.tsx
···
1
-
import { createFileRoute } from "@tanstack/react-router";
2
-
import React, { useEffect, useRef,useState } from "react";
1
+
import { AtUri } from "@atproto/api";
2
+
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
3
+
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
4
+
import { useAtom } from "jotai";
5
+
import * as React from "react";
3
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";
4
19
import { useAuth } from "~/providers/UnifiedAuthProvider";
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";
5
33
6
-
const HANDLE_DID_CACHE_TIMEOUT = 60 * 60 * 1000; // 1 hour
34
+
import { FollowButton, Mutual } from "./profile.$did";
35
+
36
+
export function NotificationsComponent() {
37
+
return (
38
+
<div className="">
39
+
<Header
40
+
title={`Notifications`}
41
+
backButtonCallback={() => {
42
+
if (window.history.length > 1) {
43
+
window.history.back();
44
+
} else {
45
+
window.location.assign("/");
46
+
}
47
+
}}
48
+
bottomBorderDisabled={true}
49
+
/>
50
+
<NotificationsTabs />
51
+
</div>
52
+
);
53
+
}
7
54
8
55
export const Route = createFileRoute("/notifications")({
9
56
component: NotificationsComponent,
10
57
});
11
58
12
-
function NotificationsComponent() {
13
-
// /*mass comment*/ console.log("NotificationsComponent render");
14
-
const { agent, status } = useAuth();
15
-
const authed = !!agent?.did;
16
-
const authLoading = status === "loading";
17
-
const [did, setDid] = useState<string | null>(null);
18
-
const [resolving, setResolving] = useState(false);
19
-
const [error, setError] = useState<string | null>(null);
20
-
const [responses, setResponses] = useState<any[]>([null, null, null]);
21
-
const [loading, setLoading] = useState(false);
22
-
const inputRef = useRef<HTMLInputElement>(null);
59
+
export default function NotificationsTabs() {
60
+
const [bitesEnabled] = useAtom(enableBitesAtom);
61
+
return (
62
+
<ReusableTabRoute
63
+
route={`Notifications`}
64
+
tabs={{
65
+
Mentions: <MentionsTab />,
66
+
Follows: <FollowsTab />,
67
+
"Post Interactions": <PostInteractionsTab />,
68
+
...bitesEnabled ? {
69
+
Bites: <BitesTab />,
70
+
} : {}
71
+
}}
72
+
/>
73
+
);
74
+
}
75
+
76
+
function MentionsTab() {
77
+
const { agent } = useAuth();
78
+
const [constellationurl] = useAtom(constellationURLAtom);
79
+
const infinitequeryresults = useInfiniteQuery({
80
+
...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(
81
+
{
82
+
constellation: constellationurl,
83
+
method: "/links",
84
+
target: agent?.did,
85
+
collection: "app.bsky.feed.post",
86
+
path: ".facets[app.bsky.richtext.facet].features[app.bsky.richtext.facet#mention].did",
87
+
}
88
+
),
89
+
enabled: !!agent?.did,
90
+
});
91
+
92
+
const {
93
+
data: infiniteMentionsData,
94
+
fetchNextPage,
95
+
hasNextPage,
96
+
isFetchingNextPage,
97
+
isLoading,
98
+
isError,
99
+
error,
100
+
} = infinitequeryresults;
101
+
102
+
const mentionsAturis = React.useMemo(() => {
103
+
// Get all replies from the standard infinite query
104
+
return (
105
+
infiniteMentionsData?.pages.flatMap(
106
+
(page) =>
107
+
page?.linking_records.map(
108
+
(r) => `at://${r.did}/${r.collection}/${r.rkey}`
109
+
) ?? []
110
+
) ?? []
111
+
);
112
+
}, [infiniteMentionsData]);
23
113
24
-
useEffect(() => {
25
-
if (authLoading) return;
26
-
if (authed && agent && agent.assertDid) {
27
-
setDid(agent.assertDid);
28
-
}
29
-
}, [authed, agent, authLoading]);
114
+
useReusableTabScrollRestore("Notifications");
30
115
31
-
async function handleSubmit() {
32
-
// /*mass comment*/ console.log("handleSubmit called");
33
-
setError(null);
34
-
setResponses([null, null, null]);
35
-
const value = inputRef.current?.value?.trim() || "";
36
-
if (!value) return;
37
-
if (value.startsWith("did:")) {
38
-
setDid(value);
39
-
setError(null);
40
-
return;
41
-
}
42
-
setResolving(true);
43
-
const cacheKey = `handleDid:${value}`;
44
-
const now = Date.now();
45
-
const cached = undefined // await get(cacheKey);
46
-
// if (
47
-
// cached &&
48
-
// cached.value &&
49
-
// cached.time &&
50
-
// now - cached.time < HANDLE_DID_CACHE_TIMEOUT
51
-
// ) {
52
-
// try {
53
-
// const data = JSON.parse(cached.value);
54
-
// setDid(data.did);
55
-
// setResolving(false);
56
-
// return;
57
-
// } catch {}
58
-
// }
59
-
try {
60
-
const url = `https://free-fly-24.deno.dev/?handle=${encodeURIComponent(value)}`;
61
-
const res = await fetch(url);
62
-
if (!res.ok) throw new Error("Failed to resolve handle");
63
-
const data = await res.json();
64
-
//set(cacheKey, JSON.stringify(data));
65
-
setDid(data.did);
66
-
} catch (e: any) {
67
-
setError("Failed to resolve handle: " + (e?.message || e));
68
-
} finally {
69
-
setResolving(false);
70
-
}
71
-
}
116
+
if (isLoading) return <LoadingState text="Loading mentions..." />;
117
+
if (isError) return <ErrorState error={error} />;
118
+
119
+
if (!mentionsAturis?.length) return <EmptyState text="No mentions yet." />;
120
+
121
+
return (
122
+
<>
123
+
{mentionsAturis.map((m) => (
124
+
<UniversalPostRendererATURILoader key={m} atUri={m} />
125
+
))}
72
126
73
-
useEffect(() => {
74
-
if (!did) return;
75
-
setLoading(true);
76
-
setError(null);
77
-
const urls = [
78
-
`https://constellation.microcosm.blue/links?target=${encodeURIComponent(did)}&collection=app.bsky.feed.post&path=.facets[app.bsky.richtext.facet].features[app.bsky.richtext.facet%23mention].did`,
79
-
`https://constellation.microcosm.blue/links?target=${encodeURIComponent(did)}&collection=app.bsky.feed.post&path=.facets[].features[app.bsky.richtext.facet%23mention].did`,
80
-
`https://constellation.microcosm.blue/links?target=${encodeURIComponent(did)}&collection=app.bsky.graph.follow&path=.subject`,
81
-
];
82
-
let ignore = false;
83
-
Promise.all(
84
-
urls.map(async (url) => {
85
-
try {
86
-
const r = await fetch(url);
87
-
if (!r.ok) throw new Error("Failed to fetch");
88
-
const text = await r.text();
89
-
if (!text) return null;
90
-
try {
91
-
return JSON.parse(text);
92
-
} catch {
93
-
return null;
127
+
{hasNextPage && (
128
+
<button
129
+
onClick={() => fetchNextPage()}
130
+
disabled={isFetchingNextPage}
131
+
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"
132
+
>
133
+
{isFetchingNextPage ? "Loading..." : "Load More"}
134
+
</button>
135
+
)}
136
+
</>
137
+
);
138
+
}
139
+
140
+
export function FollowsTab({did}:{did?:string}) {
141
+
const { agent } = useAuth();
142
+
const userdidunsafe = did ?? agent?.did;
143
+
const { data: identity} = useQueryIdentity(userdidunsafe);
144
+
const userdid = identity?.did;
145
+
146
+
const [constellationurl] = useAtom(constellationURLAtom);
147
+
const infinitequeryresults = useInfiniteQuery({
148
+
...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(
149
+
{
150
+
constellation: constellationurl,
151
+
method: "/links",
152
+
target: userdid,
153
+
collection: "app.bsky.graph.follow",
154
+
path: ".subject",
155
+
}
156
+
),
157
+
enabled: !!userdid,
158
+
});
159
+
160
+
const {
161
+
data: infiniteFollowsData,
162
+
fetchNextPage,
163
+
hasNextPage,
164
+
isFetchingNextPage,
165
+
isLoading,
166
+
isError,
167
+
error,
168
+
} = infinitequeryresults;
169
+
170
+
const followsAturis = React.useMemo(() => {
171
+
// Get all replies from the standard infinite query
172
+
return (
173
+
infiniteFollowsData?.pages.flatMap(
174
+
(page) =>
175
+
page?.linking_records.map(
176
+
(r) => `at://${r.did}/${r.collection}/${r.rkey}`
177
+
) ?? []
178
+
) ?? []
179
+
);
180
+
}, [infiniteFollowsData]);
181
+
182
+
useReusableTabScrollRestore("Notifications");
183
+
184
+
if (isLoading) return <LoadingState text="Loading follows..." />;
185
+
if (isError) return <ErrorState error={error} />;
186
+
187
+
if (!followsAturis?.length) return <EmptyState text="No follows yet." />;
188
+
189
+
return (
190
+
<>
191
+
{followsAturis.map((m) => (
192
+
<NotificationItem key={m} notification={m} />
193
+
))}
194
+
195
+
{hasNextPage && (
196
+
<button
197
+
onClick={() => fetchNextPage()}
198
+
disabled={isFetchingNextPage}
199
+
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"
200
+
>
201
+
{isFetchingNextPage ? "Loading..." : "Load More"}
202
+
</button>
203
+
)}
204
+
</>
205
+
);
206
+
}
207
+
208
+
209
+
export function BitesTab({did}:{did?:string}) {
210
+
const { agent } = useAuth();
211
+
const userdidunsafe = did ?? agent?.did;
212
+
const { data: identity} = useQueryIdentity(userdidunsafe);
213
+
const userdid = identity?.did;
214
+
215
+
const [constellationurl] = useAtom(constellationURLAtom);
216
+
const infinitequeryresults = useInfiniteQuery({
217
+
...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(
218
+
{
219
+
constellation: constellationurl,
220
+
method: "/links",
221
+
target: "at://"+userdid,
222
+
collection: "net.wafrn.feed.bite",
223
+
path: ".subject",
224
+
staleMult: 0 // safe fun
225
+
}
226
+
),
227
+
enabled: !!userdid,
228
+
});
229
+
230
+
const {
231
+
data: infiniteFollowsData,
232
+
fetchNextPage,
233
+
hasNextPage,
234
+
isFetchingNextPage,
235
+
isLoading,
236
+
isError,
237
+
error,
238
+
} = infinitequeryresults;
239
+
240
+
const followsAturis = React.useMemo(() => {
241
+
// Get all replies from the standard infinite query
242
+
return (
243
+
infiniteFollowsData?.pages.flatMap(
244
+
(page) =>
245
+
page?.linking_records.map(
246
+
(r) => `at://${r.did}/${r.collection}/${r.rkey}`
247
+
) ?? []
248
+
) ?? []
249
+
);
250
+
}, [infiniteFollowsData]);
251
+
252
+
useReusableTabScrollRestore("Notifications");
253
+
254
+
if (isLoading) return <LoadingState text="Loading bites..." />;
255
+
if (isError) return <ErrorState error={error} />;
256
+
257
+
if (!followsAturis?.length) return <EmptyState text="No bites yet." />;
258
+
259
+
return (
260
+
<>
261
+
{followsAturis.map((m) => (
262
+
<NotificationItem key={m} notification={m} />
263
+
))}
264
+
265
+
{hasNextPage && (
266
+
<button
267
+
onClick={() => fetchNextPage()}
268
+
disabled={isFetchingNextPage}
269
+
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"
270
+
>
271
+
{isFetchingNextPage ? "Loading..." : "Load More"}
272
+
</button>
273
+
)}
274
+
</>
275
+
);
276
+
}
277
+
278
+
function PostInteractionsTab() {
279
+
const { agent } = useAuth();
280
+
const { data: identity } = useQueryIdentity(agent?.did);
281
+
const queryClient = useQueryClient();
282
+
const {
283
+
data: postsData,
284
+
fetchNextPage,
285
+
hasNextPage,
286
+
isFetchingNextPage,
287
+
isLoading: arePostsLoading,
288
+
} = useInfiniteQueryAuthorFeed(agent?.did, identity?.pds);
289
+
290
+
React.useEffect(() => {
291
+
if (postsData) {
292
+
postsData.pages.forEach((page) => {
293
+
page.records.forEach((record) => {
294
+
if (!queryClient.getQueryData(["post", record.uri])) {
295
+
queryClient.setQueryData(["post", record.uri], record);
94
296
}
95
-
} catch (e: any) {
96
-
return { error: e?.message || String(e) };
97
-
}
98
-
})
99
-
)
100
-
.then((results) => {
101
-
if (!ignore) setResponses(results);
102
-
})
103
-
.catch((e) => {
104
-
if (!ignore)
105
-
setError("Failed to fetch notifications: " + (e?.message || e));
106
-
})
107
-
.finally(() => {
108
-
if (!ignore) setLoading(false);
297
+
});
109
298
});
110
-
return () => {
111
-
ignore = true;
112
-
};
113
-
}, [did]);
299
+
}
300
+
}, [postsData, queryClient]);
301
+
302
+
const posts = React.useMemo(
303
+
() => postsData?.pages.flatMap((page) => page.records) ?? [],
304
+
[postsData]
305
+
);
306
+
307
+
useReusableTabScrollRestore("Notifications");
308
+
309
+
const [filters] = useAtom(postInteractionsFiltersAtom);
310
+
const empty = (!filters.likes && !filters.quotes && !filters.replies && !filters.reposts);
114
311
115
312
return (
116
-
<div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800">
117
-
<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">
118
-
<span className="text-xl font-bold ml-2">Notifications</span>
119
-
{!authed && (
120
-
<div className="flex items-center gap-2">
121
-
<input
122
-
type="text"
123
-
placeholder="Enter handle or DID"
124
-
ref={inputRef}
125
-
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"
126
-
style={{ minWidth: 220 }}
127
-
disabled={resolving}
128
-
/>
129
-
<button
130
-
type="button"
131
-
className="px-3 py-1 rounded bg-blue-600 text-white font-semibold disabled:opacity-50"
132
-
disabled={resolving}
133
-
onClick={handleSubmit}
134
-
>
135
-
{resolving ? "Resolving..." : "Submit"}
136
-
</button>
137
-
</div>
138
-
)}
313
+
<>
314
+
<PostInteractionsFilterChipBar />
315
+
{!empty && posts.map((m) => (
316
+
<PostInteractionsItem key={m.uri} uri={m.uri} />
317
+
))}
318
+
319
+
{hasNextPage && (
320
+
<button
321
+
onClick={() => fetchNextPage()}
322
+
disabled={isFetchingNextPage}
323
+
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"
324
+
>
325
+
{isFetchingNextPage ? "Loading..." : "Load More"}
326
+
</button>
327
+
)}
328
+
</>
329
+
);
330
+
}
331
+
332
+
function PostInteractionsFilterChipBar() {
333
+
const [filters, setFilters] = useAtom(postInteractionsFiltersAtom);
334
+
// const empty = (!filters.likes && !filters.quotes && !filters.replies && !filters.reposts);
335
+
336
+
// useEffect(() => {
337
+
// if (empty) {
338
+
// setFilters((prev) => ({
339
+
// ...prev,
340
+
// likes: true,
341
+
// }));
342
+
// }
343
+
// }, [
344
+
// empty,
345
+
// setFilters,
346
+
// ]);
347
+
348
+
const toggle = (key: keyof typeof filters) => {
349
+
setFilters((prev) => ({
350
+
...prev,
351
+
[key]: !prev[key],
352
+
}));
353
+
};
354
+
355
+
return (
356
+
<div className="flex flex-row flex-wrap gap-2 px-4 pt-4">
357
+
<Chip
358
+
state={filters.likes}
359
+
text="Likes"
360
+
onClick={() => toggle("likes")}
361
+
/>
362
+
<Chip
363
+
state={filters.reposts}
364
+
text="Reposts"
365
+
onClick={() => toggle("reposts")}
366
+
/>
367
+
<Chip
368
+
state={filters.replies}
369
+
text="Replies"
370
+
onClick={() => toggle("replies")}
371
+
/>
372
+
<Chip
373
+
state={filters.quotes}
374
+
text="Quotes"
375
+
onClick={() => toggle("quotes")}
376
+
/>
377
+
<Chip
378
+
state={filters.showAll}
379
+
text="Show All Metrics"
380
+
onClick={() => toggle("showAll")}
381
+
/>
382
+
</div>
383
+
);
384
+
}
385
+
386
+
export function Chip({
387
+
state,
388
+
text,
389
+
onClick,
390
+
}: {
391
+
state: boolean;
392
+
text: string;
393
+
onClick: React.MouseEventHandler<HTMLButtonElement>;
394
+
}) {
395
+
return (
396
+
<button
397
+
onClick={onClick}
398
+
className={`relative inline-flex items-center px-3 py-1.5 rounded-lg text-sm font-medium transition-all
399
+
${
400
+
state
401
+
? "bg-primary/20 text-primary bg-gray-200 dark:bg-gray-800 border border-transparent"
402
+
: "bg-surface-container-low text-on-surface-variant border border-outline"
403
+
}
404
+
hover:bg-primary/30 active:scale-[0.97]
405
+
dark:border-outline-variant
406
+
`}
407
+
>
408
+
{state && (
409
+
<IconMdiCheck
410
+
className="mr-1.5 inline-block w-4 h-4 rounded-full bg-primary"
411
+
aria-hidden
412
+
/>
413
+
)}
414
+
{text}
415
+
</button>
416
+
);
417
+
}
418
+
419
+
function PostInteractionsItem({ uri }: { uri: string }) {
420
+
const [filters] = useAtom(postInteractionsFiltersAtom);
421
+
const { data: links } = useQueryConstellation({
422
+
method: "/links/all",
423
+
target: uri,
424
+
});
425
+
426
+
const likes =
427
+
links?.links?.["app.bsky.feed.like"]?.[".subject.uri"]?.records || 0;
428
+
const replies =
429
+
links?.links?.["app.bsky.feed.post"]?.[".reply.parent.uri"]?.records || 0;
430
+
const reposts =
431
+
links?.links?.["app.bsky.feed.repost"]?.[".subject.uri"]?.records || 0;
432
+
const quotes1 =
433
+
links?.links?.["app.bsky.feed.post"]?.[".embed.record.uri"]?.records || 0;
434
+
const quotes2 =
435
+
links?.links?.["app.bsky.feed.post"]?.[".embed.record.record.uri"]
436
+
?.records || 0;
437
+
const quotes = quotes1 + quotes2;
438
+
439
+
const all = likes + replies + reposts + quotes;
440
+
441
+
//const failLikes = filters.likes && likes < 1;
442
+
//const failReposts = filters.reposts && reposts < 1;
443
+
//const failReplies = filters.replies && replies < 1;
444
+
//const failQuotes = filters.quotes && quotes < 1;
445
+
446
+
const showLikes = filters.showAll || filters.likes
447
+
const showReposts = filters.showAll || filters.reposts
448
+
const showReplies = filters.showAll || filters.replies
449
+
const showQuotes = filters.showAll || filters.quotes
450
+
451
+
//const showNone = !showLikes && !showReposts && !showReplies && !showQuotes;
452
+
453
+
//const fail = failLikes || failReposts || failReplies || failQuotes || showNone;
454
+
455
+
const matchesLikes = filters.likes && likes > 0;
456
+
const matchesReposts = filters.reposts && reposts > 0;
457
+
const matchesReplies = filters.replies && replies > 0;
458
+
const matchesQuotes = filters.quotes && quotes > 0;
459
+
460
+
const matchesAnything =
461
+
// filters.showAll ||
462
+
matchesLikes ||
463
+
matchesReposts ||
464
+
matchesReplies ||
465
+
matchesQuotes;
466
+
467
+
if (!matchesAnything) return null;
468
+
469
+
//if (fail) return;
470
+
471
+
return (
472
+
<div className="flex flex-col">
473
+
{/* <span>fail likes {failLikes ? "true" : "false"}</span>
474
+
<span>fail repost {failReposts ? "true" : "false"}</span>
475
+
<span>fail reply {failReplies ? "true" : "false"}</span>
476
+
<span>fail qupte {failQuotes ? "true" : "false"}</span> */}
477
+
<div className="border rounded-xl mx-4 mt-4 overflow-hidden">
478
+
<UniversalPostRendererATURILoader
479
+
isQuote
480
+
key={uri}
481
+
atUri={uri}
482
+
nopics={true}
483
+
concise={true}
484
+
/>
485
+
<div className="flex flex-col divide-x">
486
+
{showLikes &&(<InteractionsButton
487
+
type={"like"}
488
+
uri={uri}
489
+
count={likes}
490
+
/>)}
491
+
{showReposts && (<InteractionsButton
492
+
type={"repost"}
493
+
uri={uri}
494
+
count={reposts}
495
+
/>)}
496
+
{showReplies && (<InteractionsButton
497
+
type={"reply"}
498
+
uri={uri}
499
+
count={replies}
500
+
/>)}
501
+
{showQuotes && (<InteractionsButton
502
+
type={"quote"}
503
+
uri={uri}
504
+
count={quotes}
505
+
/>)}
506
+
{!all && (
507
+
<div className="text-center text-gray-500 dark:text-gray-400 pb-3 pt-2 border-t">
508
+
No interactions yet.
509
+
</div>
510
+
)}
511
+
</div>
139
512
</div>
140
-
{error && <div className="p-4 text-red-500">{error}</div>}
141
-
{loading && (
142
-
<div className="p-4 text-gray-500">Loading notifications...</div>
513
+
</div>
514
+
);
515
+
}
516
+
517
+
function InteractionsButton({
518
+
type,
519
+
uri,
520
+
count,
521
+
}: {
522
+
type: "reply" | "repost" | "like" | "quote";
523
+
uri: string;
524
+
count: number;
525
+
}) {
526
+
if (!count) return <></>;
527
+
const aturi = new AtUri(uri);
528
+
return (
529
+
<Link
530
+
to={
531
+
`/profile/$did/post/$rkey` +
532
+
(type === "like"
533
+
? "/liked-by"
534
+
: type === "repost"
535
+
? "/reposted-by"
536
+
: type === "quote"
537
+
? "/quotes"
538
+
: "")
539
+
}
540
+
params={{
541
+
did: aturi.host,
542
+
rkey: aturi.rkey,
543
+
}}
544
+
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"
545
+
>
546
+
{type === "like" ? (
547
+
<MdiCardsHeartOutline height={22} width={22} />
548
+
) : type === "repost" ? (
549
+
<MdiRepeat height={22} width={22} />
550
+
) : type === "reply" ? (
551
+
<MdiCommentOutline height={22} width={22} />
552
+
) : type === "quote" ? (
553
+
<IconMdiMessageReplyTextOutline
554
+
height={22}
555
+
width={22}
556
+
className=" text-gray-400"
557
+
/>
558
+
) : (
559
+
<></>
560
+
)}
561
+
{type === "like"
562
+
? "likes"
563
+
: type === "reply"
564
+
? "replies"
565
+
: type === "quote"
566
+
? "quotes"
567
+
: type === "repost"
568
+
? "reposts"
569
+
: ""}
570
+
<div className="flex-1" /> {count}
571
+
</Link>
572
+
);
573
+
}
574
+
575
+
export function NotificationItem({ notification, labeler }: { notification: string, labeler?: boolean }) {
576
+
const aturi = new AtUri(notification);
577
+
const bite = aturi.collection === "net.wafrn.feed.bite";
578
+
const navigate = useNavigate();
579
+
const { data: identity } = useQueryIdentity(aturi.host);
580
+
const resolvedDid = identity?.did;
581
+
const profileUri = resolvedDid
582
+
? `at://${resolvedDid}/app.bsky.actor.profile/self`
583
+
: undefined;
584
+
const { data: profileRecord } = useQueryProfile(profileUri);
585
+
const profile = profileRecord?.value;
586
+
587
+
const [imgcdn] = useAtom(imgCDNAtom);
588
+
589
+
function getAvatarUrl(p: typeof profile) {
590
+
const link = p?.avatar?.ref?.["$link"];
591
+
if (!link || !resolvedDid) return null;
592
+
return `https://${imgcdn}/img/avatar/plain/${resolvedDid}/${link}@jpeg`;
593
+
}
594
+
595
+
const avatar = getAvatarUrl(profile);
596
+
597
+
return (
598
+
<div
599
+
className="flex items-center p-4 cursor-pointer gap-3 justify-around border-b flex-row"
600
+
onClick={() =>
601
+
aturi &&
602
+
navigate({
603
+
to: "/profile/$did",
604
+
params: { did: aturi.host },
605
+
})
606
+
}
607
+
>
608
+
{/* <div>
609
+
{aturi.collection === "app.bsky.graph.follow" ? (
610
+
<IconMdiAccountPlus />
611
+
) : aturi.collection === "app.bsky.feed.like" ? (
612
+
<MdiCardsHeart />
613
+
) : (
614
+
<></>
615
+
)}
616
+
</div> */}
617
+
{profile ? (
618
+
<img
619
+
src={avatar || defaultpfp}
620
+
alt={identity?.handle}
621
+
className={`w-10 h-10 ${labeler ? "rounded-md" : "rounded-full"}`}
622
+
/>
623
+
) : (
624
+
<div className="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-700" />
143
625
)}
144
-
{!loading &&
145
-
!error &&
146
-
responses.map((resp, i) => (
147
-
<div key={i} className="p-4">
148
-
<div className="font-bold mb-2">Query {i + 1}</div>
149
-
{!resp ||
150
-
(typeof resp === "object" && Object.keys(resp).length === 0) ||
151
-
(Array.isArray(resp) && resp.length === 0) ? (
152
-
<div className="text-gray-500">No notifications found.</div>
153
-
) : (
154
-
<pre
155
-
style={{
156
-
background: "#222",
157
-
color: "#eee",
158
-
borderRadius: 8,
159
-
padding: 12,
160
-
fontSize: 13,
161
-
overflowX: "auto",
162
-
}}
163
-
>
164
-
{JSON.stringify(resp, null, 2)}
165
-
</pre>
166
-
)}
167
-
</div>
168
-
))}
169
-
{/* <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> */}
626
+
<div className="flex flex-col min-w-0">
627
+
<div className="flex flex-row gap-2 overflow-hidden text-ellipsis whitespace-nowrap min-w-0">
628
+
<span className="font-medium text-gray-900 dark:text-gray-100 truncate">
629
+
{profile?.displayName || identity?.handle || "Someone"}
630
+
</span>
631
+
<span className="text-gray-700 dark:text-gray-400 truncate">
632
+
@{identity?.handle}
633
+
</span>
634
+
</div>
635
+
<div className="flex flex-row gap-2">
636
+
{identity?.did && <Mutual targetdidorhandle={identity?.did} />}
637
+
{/* <span className="text-sm text-gray-600 dark:text-gray-400">
638
+
followed you
639
+
</span> */}
640
+
</div>
641
+
</div>
642
+
<div className="flex-1" />
643
+
{identity?.did && <FollowButton targetdidorhandle={identity?.did} />}
170
644
</div>
171
645
);
172
646
}
647
+
648
+
export const EmptyState = ({ text }: { text: string }) => (
649
+
<div className="py-10 text-center text-gray-500 dark:text-gray-400">
650
+
{text}
651
+
</div>
652
+
);
653
+
654
+
export const LoadingState = ({ text }: { text: string }) => (
655
+
<div className="py-10 text-center text-gray-500 dark:text-gray-400 italic">
656
+
{text}
657
+
</div>
658
+
);
659
+
660
+
export const ErrorState = ({ error }: { error: unknown }) => (
661
+
<div className="py-10 text-center text-red-600 dark:text-red-400">
662
+
Error: {(error as Error)?.message || "Something went wrong."}
663
+
</div>
664
+
);
+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
+
}
+1005
-125
src/routes/profile.$did/index.tsx
+1005
-125
src/routes/profile.$did/index.tsx
···
1
+
import { Agent, RichText } from "@atproto/api";
2
+
import * as ATPAPI from "@atproto/api";
3
+
import { TID } from "@atproto/common-web";
1
4
import { useQueryClient } from "@tanstack/react-query";
2
-
import { createFileRoute } from "@tanstack/react-router";
3
-
import React from "react";
5
+
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
6
+
import { useAtom } from "jotai";
7
+
import React, { type ReactNode, useEffect, useState } from "react";
4
8
9
+
import defaultpfp from "~/../public/favicon.png";
5
10
import { Header } from "~/components/Header";
6
-
import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer";
11
+
import {
12
+
ReusableTabRoute,
13
+
useReusableTabScrollRestore,
14
+
} from "~/components/ReusableTabRoute";
15
+
import {
16
+
renderTextWithFacets,
17
+
UniversalPostRendererATURILoader,
18
+
} from "~/components/UniversalPostRenderer";
7
19
import { useAuth } from "~/providers/UnifiedAuthProvider";
8
-
import { toggleFollow, useGetFollowState } from "~/utils/followState";
20
+
import { enableBitesAtom, imgCDNAtom, profileChipsAtom } from "~/utils/atoms";
21
+
import {
22
+
toggleFollow,
23
+
useGetFollowState,
24
+
useGetOneToOneState,
25
+
} from "~/utils/followState";
26
+
import { useFastSetLikesFromFeed } from "~/utils/likeMutationQueue";
9
27
import {
10
28
useInfiniteQueryAuthorFeed,
29
+
useQueryArbitrary,
30
+
useQueryConstellation,
31
+
useQueryConstellationLinksCountDistinctDids,
11
32
useQueryIdentity,
12
33
useQueryProfile,
13
34
} from "~/utils/useQuery";
35
+
import IconMdiShieldOutline from "~icons/mdi/shield-outline.jsx";
36
+
37
+
import { renderSnack } from "../__root";
38
+
import { Chip } from "../notifications";
14
39
15
40
export const Route = createFileRoute("/profile/$did/")({
16
41
component: ProfileComponent,
···
19
44
function ProfileComponent() {
20
45
// booo bad this is not always the did it might be a handle, use identity.did instead
21
46
const { did } = Route.useParams();
47
+
const { agent } = useAuth();
48
+
const navigate = useNavigate();
22
49
const queryClient = useQueryClient();
23
-
const { agent } = useAuth();
24
50
const {
25
51
data: identity,
26
52
isLoading: isIdentityLoading,
27
53
error: identityError,
28
54
} = useQueryIdentity(did);
29
55
30
-
const followRecords = useGetFollowState({
31
-
target: identity?.did || did,
32
-
user: agent?.did,
33
-
});
56
+
// i was gonna check the did doc but useQueryIdentity doesnt return that info (slingshot minidoc)
57
+
// so instead we should query the labeler profile
58
+
59
+
const { data: labelerProfile } = useQueryArbitrary(
60
+
identity?.did
61
+
? `at://${identity?.did}/app.bsky.labeler.service/self`
62
+
: undefined
63
+
);
64
+
65
+
const isLabeler = !!labelerProfile?.cid;
66
+
const labelerRecord = isLabeler
67
+
? (labelerProfile?.value as ATPAPI.AppBskyLabelerService.Record)
68
+
: undefined;
34
69
35
70
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
36
71
const resolvedHandle = did.startsWith("did:") ? identity?.handle : did;
···
42
77
const { data: profileRecord } = useQueryProfile(profileUri);
43
78
const profile = profileRecord?.value;
44
79
45
-
const {
46
-
data: postsData,
47
-
fetchNextPage,
48
-
hasNextPage,
49
-
isFetchingNextPage,
50
-
isLoading: arePostsLoading,
51
-
} = useInfiniteQueryAuthorFeed(resolvedDid, pdsUrl);
52
-
53
-
React.useEffect(() => {
54
-
if (postsData) {
55
-
postsData.pages.forEach((page) => {
56
-
page.records.forEach((record) => {
57
-
if (!queryClient.getQueryData(["post", record.uri])) {
58
-
queryClient.setQueryData(["post", record.uri], record);
59
-
}
60
-
});
61
-
});
62
-
}
63
-
}, [postsData, queryClient]);
64
-
65
-
const posts = React.useMemo(
66
-
() => postsData?.pages.flatMap((page) => page.records) ?? [],
67
-
[postsData]
68
-
);
80
+
const [imgcdn] = useAtom(imgCDNAtom);
69
81
70
82
function getAvatarUrl(p: typeof profile) {
71
83
const link = p?.avatar?.ref?.["$link"];
72
84
if (!link || !resolvedDid) return null;
73
-
return `https://cdn.bsky.app/img/avatar/plain/${resolvedDid}/${link}@jpeg`;
85
+
return `https://${imgcdn}/img/avatar/plain/${resolvedDid}/${link}@jpeg`;
74
86
}
75
87
function getBannerUrl(p: typeof profile) {
76
88
const link = p?.banner?.ref?.["$link"];
77
89
if (!link || !resolvedDid) return null;
78
-
return `https://cdn.bsky.app/img/banner/plain/${resolvedDid}/${link}@jpeg`;
90
+
return `https://${imgcdn}/img/banner/plain/${resolvedDid}/${link}@jpeg`;
79
91
}
80
92
81
93
const displayName =
···
83
95
const handle = resolvedHandle ? `@${resolvedHandle}` : resolvedDid || did;
84
96
const description = profile?.description || "";
85
97
86
-
if (isIdentityLoading) {
87
-
return (
88
-
<div className="p-4 text-center text-gray-500">Resolving profile...</div>
89
-
);
90
-
}
98
+
const isReady = !!resolvedDid && !isIdentityLoading && !!profileRecord;
91
99
92
-
if (identityError) {
93
-
return (
94
-
<div className="p-4 text-center text-red-500">
95
-
Error: {identityError.message}
96
-
</div>
97
-
);
98
-
}
100
+
const resultwhateversure = useQueryConstellationLinksCountDistinctDids(
101
+
resolvedDid
102
+
? {
103
+
method: "/links/count/distinct-dids",
104
+
collection: "app.bsky.graph.follow",
105
+
target: resolvedDid,
106
+
path: ".subject",
107
+
}
108
+
: undefined
109
+
);
99
110
100
-
if (!resolvedDid) {
101
-
return (
102
-
<div className="p-4 text-center text-gray-500">Profile not found.</div>
103
-
);
104
-
}
111
+
const followercount = resultwhateversure?.data?.total;
105
112
106
113
return (
107
-
<>
114
+
<div className="">
108
115
<Header
109
116
title={`Profile`}
110
117
backButtonCallback={() => {
···
114
121
window.location.assign("/");
115
122
}
116
123
}}
124
+
bottomBorderDisabled={true}
117
125
/>
118
126
{/* <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">
119
127
<Link
···
148
156
149
157
{/* Avatar (PFP) */}
150
158
<div className="absolute left-[16px] top-[100px] ">
151
-
<img
152
-
src={getAvatarUrl(profile) || "/favicon.png"}
153
-
alt="avatar"
154
-
className="w-28 h-28 rounded-full object-cover border-4 border-white dark:border-gray-950 bg-gray-300 dark:bg-gray-700"
155
-
/>
159
+
{!getAvatarUrl(profile) && isLabeler ? (
160
+
<div
161
+
className={`w-28 h-28 ${isLabeler ? "rounded-md" : "rounded-full"} items-center justify-center flex object-cover border-4 border-white dark:border-gray-950 bg-gray-300 dark:bg-gray-700`}
162
+
>
163
+
<IconMdiShieldOutline className="w-20 h-20" />
164
+
</div>
165
+
) : (
166
+
<img
167
+
src={getAvatarUrl(profile) || "/favicon.png"}
168
+
alt="avatar"
169
+
className={`w-28 h-28 ${isLabeler ? "rounded-md" : "rounded-full"} object-cover border-4 border-white dark:border-gray-950 bg-gray-300 dark:bg-gray-700`}
170
+
/>
171
+
)}
156
172
</div>
157
173
158
174
<div className="absolute right-[16px] top-[170px] flex flex-row gap-2.5">
175
+
<BiteButton targetdidorhandle={did} />
159
176
{/*
160
177
todo: full follow and unfollow backfill (along with partial likes backfill,
161
178
just enough for it to be useful)
162
179
also delay the backfill to be on demand because it would be pretty intense
163
180
also save it persistently
164
181
*/}
165
-
{identity?.did !== agent?.did ? (
166
-
<>
167
-
{!(followRecords?.length && followRecords?.length > 0) ? (
168
-
<button
169
-
onClick={() =>
170
-
toggleFollow({
171
-
agent: agent || undefined,
172
-
targetDid: identity?.did,
173
-
followRecords: followRecords,
174
-
queryClient: queryClient,
175
-
})
176
-
}
177
-
className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]"
178
-
>
179
-
Follow
180
-
</button>
181
-
) : (
182
-
<button
183
-
onClick={() =>
184
-
toggleFollow({
185
-
agent: agent || undefined,
186
-
targetDid: identity?.did,
187
-
followRecords: followRecords,
188
-
queryClient: queryClient,
189
-
})
190
-
}
191
-
className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]"
192
-
>
193
-
Unfollow
194
-
</button>
195
-
)}
196
-
</>
197
-
) : (
198
-
<button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]">
199
-
Edit Profile
200
-
</button>
201
-
)}
202
-
<button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]">
182
+
<FollowButton targetdidorhandle={did} />
183
+
<button
184
+
className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]"
185
+
onClick={(e) => {
186
+
renderSnack({
187
+
title: "Not Implemented Yet",
188
+
description: "Sorry...",
189
+
//button: { label: 'Try Again', onClick: () => console.log('whatever') },
190
+
});
191
+
}}
192
+
>
203
193
... {/* todo: icon */}
204
194
</button>
205
195
</div>
···
207
197
{/* Info Card */}
208
198
<div className="mt-16 pb-2 px-4 text-gray-900 dark:text-gray-100">
209
199
<div className="font-bold text-2xl">{displayName}</div>
210
-
<div className="text-gray-500 dark:text-gray-400 text-base mb-3">
200
+
<div className="text-gray-500 dark:text-gray-400 text-base mb-3 flex flex-row gap-1">
201
+
<Mutual targetdidorhandle={did} />
211
202
{handle}
212
203
</div>
204
+
<div className="flex flex-row gap-2 text-md text-gray-500 dark:text-gray-400 mb-2">
205
+
<Link to="/profile/$did/followers" params={{ did: did }}>
206
+
{followercount && (
207
+
<span className="mr-1 text-gray-900 dark:text-gray-200 font-medium">
208
+
{followercount}
209
+
</span>
210
+
)}
211
+
Followers
212
+
</Link>
213
+
-
214
+
<Link to="/profile/$did/follows" params={{ did: did }}>
215
+
Follows
216
+
</Link>
217
+
</div>
213
218
{description && (
214
219
<div className="text-base leading-relaxed text-gray-800 dark:text-gray-300 mb-5 whitespace-pre-wrap break-words text-[15px]">
215
-
{description}
220
+
{/* {description} */}
221
+
<RichTextRenderer key={did} description={description} />
216
222
</div>
217
223
)}
218
224
</div>
219
225
</div>
220
226
221
-
{/* Posts Section */}
222
-
<div className="max-w-2xl mx-auto">
223
-
<div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
224
-
Posts
227
+
{/* this should not be rendered until its ready (the top profile layout is stable) */}
228
+
{isReady ? (
229
+
<ReusableTabRoute
230
+
route={`Profile` + did}
231
+
tabs={{
232
+
...(isLabeler
233
+
? {
234
+
Labels: <LabelsTab did={did} labelerRecord={labelerRecord} />,
235
+
}
236
+
: {}),
237
+
...{
238
+
Posts: <PostsTab did={did} />,
239
+
Reposts: <RepostsTab did={did} />,
240
+
Feeds: <FeedsTab did={did} />,
241
+
Lists: <ListsTab did={did} />,
242
+
},
243
+
...(identity?.did === agent?.did
244
+
? { Likes: <SelfLikesTab did={did} /> }
245
+
: {}),
246
+
}}
247
+
/>
248
+
) : isIdentityLoading ? (
249
+
<div className="p-4 text-center text-gray-500">
250
+
Resolving profile...
251
+
</div>
252
+
) : identityError ? (
253
+
<div className="p-4 text-center text-red-500">
254
+
Error: {identityError.message}
255
+
</div>
256
+
) : !resolvedDid ? (
257
+
<div className="p-4 text-center text-gray-500">Profile not found.</div>
258
+
) : (
259
+
<div className="p-4 text-center text-gray-500">
260
+
Loading profile content...
225
261
</div>
226
-
<div>
227
-
{posts.map((post) => (
262
+
)}
263
+
</div>
264
+
);
265
+
}
266
+
267
+
export type ProfilePostsFilter = {
268
+
posts: boolean;
269
+
replies: boolean;
270
+
mediaOnly: boolean;
271
+
};
272
+
export const defaultProfilePostsFilter: ProfilePostsFilter = {
273
+
posts: true,
274
+
replies: true,
275
+
mediaOnly: false,
276
+
};
277
+
278
+
function ProfilePostsFilterChipBar({
279
+
filters,
280
+
toggle,
281
+
}: {
282
+
filters: ProfilePostsFilter | null;
283
+
toggle: (key: keyof ProfilePostsFilter) => void;
284
+
}) {
285
+
const empty = !filters?.replies && !filters?.posts;
286
+
const almostEmpty = !filters?.replies && filters?.posts;
287
+
288
+
useEffect(() => {
289
+
if (empty) {
290
+
toggle("posts");
291
+
}
292
+
}, [empty, toggle]);
293
+
294
+
return (
295
+
<div className="flex flex-row flex-wrap gap-2 px-4 pt-4">
296
+
<Chip
297
+
state={filters?.posts ?? true}
298
+
text="Posts"
299
+
onClick={() => (almostEmpty ? null : toggle("posts"))}
300
+
/>
301
+
<Chip
302
+
state={filters?.replies ?? true}
303
+
text="Replies"
304
+
onClick={() => toggle("replies")}
305
+
/>
306
+
<Chip
307
+
state={filters?.mediaOnly ?? false}
308
+
text="Media Only"
309
+
onClick={() => toggle("mediaOnly")}
310
+
/>
311
+
</div>
312
+
);
313
+
}
314
+
315
+
function PostsTab({ did }: { did: string }) {
316
+
// todo: this needs to be a (non-persisted is fine) atom to survive navigation
317
+
const [filterses, setFilterses] = useAtom(profileChipsAtom);
318
+
const filters = filterses?.[did];
319
+
const setFilters = (obj: ProfilePostsFilter) => {
320
+
setFilterses((prev) => {
321
+
return {
322
+
...prev,
323
+
[did]: obj,
324
+
};
325
+
});
326
+
};
327
+
useEffect(() => {
328
+
if (!filters) {
329
+
setFilters(defaultProfilePostsFilter);
330
+
}
331
+
});
332
+
useReusableTabScrollRestore(`Profile` + did);
333
+
const queryClient = useQueryClient();
334
+
const {
335
+
data: identity,
336
+
isLoading: isIdentityLoading,
337
+
error: identityError,
338
+
} = useQueryIdentity(did);
339
+
340
+
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
341
+
342
+
const {
343
+
data: postsData,
344
+
fetchNextPage,
345
+
hasNextPage,
346
+
isFetchingNextPage,
347
+
isLoading: arePostsLoading,
348
+
} = useInfiniteQueryAuthorFeed(resolvedDid, identity?.pds);
349
+
350
+
React.useEffect(() => {
351
+
if (postsData) {
352
+
postsData.pages.forEach((page) => {
353
+
page.records.forEach((record) => {
354
+
if (!queryClient.getQueryData(["post", record.uri])) {
355
+
queryClient.setQueryData(["post", record.uri], record);
356
+
}
357
+
});
358
+
});
359
+
}
360
+
}, [postsData, queryClient]);
361
+
362
+
const posts = React.useMemo(
363
+
() => postsData?.pages.flatMap((page) => page.records) ?? [],
364
+
[postsData]
365
+
);
366
+
367
+
const toggle = (key: keyof ProfilePostsFilter) => {
368
+
setFilterses((prev) => {
369
+
const existing = prev[did] ?? {
370
+
posts: false,
371
+
replies: false,
372
+
mediaOnly: false,
373
+
}; // default
374
+
375
+
return {
376
+
...prev,
377
+
[did]: {
378
+
...existing,
379
+
[key]: !existing[key], // safely negate
380
+
},
381
+
};
382
+
});
383
+
};
384
+
385
+
return (
386
+
<>
387
+
{/* <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
388
+
Posts
389
+
</div> */}
390
+
<ProfilePostsFilterChipBar filters={filters} toggle={toggle} />
391
+
<div>
392
+
{posts.map((post) => (
393
+
<UniversalPostRendererATURILoader
394
+
key={post.uri}
395
+
atUri={post.uri}
396
+
feedviewpost={true}
397
+
filterNoReplies={!filters?.replies}
398
+
filterMustHaveMedia={filters?.mediaOnly}
399
+
filterMustBeReply={!filters?.posts}
400
+
/>
401
+
))}
402
+
</div>
403
+
404
+
{/* Loading and "Load More" states */}
405
+
{arePostsLoading && posts.length === 0 && (
406
+
<div className="p-4 text-center text-gray-500">Loading posts...</div>
407
+
)}
408
+
{isFetchingNextPage && (
409
+
<div className="p-4 text-center text-gray-500">Loading more...</div>
410
+
)}
411
+
{hasNextPage && !isFetchingNextPage && (
412
+
<button
413
+
onClick={() => fetchNextPage()}
414
+
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"
415
+
>
416
+
Load More Posts
417
+
</button>
418
+
)}
419
+
{posts.length === 0 && !arePostsLoading && (
420
+
<div className="p-4 text-center text-gray-500">No posts found.</div>
421
+
)}
422
+
</>
423
+
);
424
+
}
425
+
426
+
function RepostsTab({ did }: { did: string }) {
427
+
useReusableTabScrollRestore(`Profile` + did);
428
+
const {
429
+
data: identity,
430
+
isLoading: isIdentityLoading,
431
+
error: identityError,
432
+
} = useQueryIdentity(did);
433
+
434
+
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
435
+
436
+
const {
437
+
data: repostsData,
438
+
fetchNextPage,
439
+
hasNextPage,
440
+
isFetchingNextPage,
441
+
isLoading: arePostsLoading,
442
+
} = useInfiniteQueryAuthorFeed(
443
+
resolvedDid,
444
+
identity?.pds,
445
+
"app.bsky.feed.repost"
446
+
);
447
+
448
+
const reposts = React.useMemo(
449
+
() => repostsData?.pages.flatMap((page) => page.records) ?? [],
450
+
[repostsData]
451
+
);
452
+
453
+
return (
454
+
<>
455
+
<div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
456
+
Reposts
457
+
</div>
458
+
<div>
459
+
{reposts.map((repost) => {
460
+
if (
461
+
!repost ||
462
+
!repost?.value ||
463
+
!repost?.value?.subject ||
464
+
// @ts-expect-error blehhhhh
465
+
!repost?.value?.subject?.uri
466
+
)
467
+
return;
468
+
const repostRecord =
469
+
repost.value as unknown as ATPAPI.AppBskyFeedRepost.Record;
470
+
return (
228
471
<UniversalPostRendererATURILoader
229
-
key={post.uri}
230
-
atUri={post.uri}
472
+
key={repostRecord.subject.uri}
473
+
atUri={repostRecord.subject.uri}
231
474
feedviewpost={true}
475
+
repostedby={repost.uri}
232
476
/>
233
-
))}
234
-
</div>
477
+
);
478
+
})}
479
+
</div>
480
+
481
+
{/* Loading and "Load More" states */}
482
+
{arePostsLoading && reposts.length === 0 && (
483
+
<div className="p-4 text-center text-gray-500">Loading posts...</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 Posts
494
+
</button>
495
+
)}
496
+
{reposts.length === 0 && !arePostsLoading && (
497
+
<div className="p-4 text-center text-gray-500">No posts found.</div>
498
+
)}
499
+
</>
500
+
);
501
+
}
502
+
503
+
function FeedsTab({ did }: { did: string }) {
504
+
useReusableTabScrollRestore(`Profile` + did);
505
+
const {
506
+
data: identity,
507
+
isLoading: isIdentityLoading,
508
+
error: identityError,
509
+
} = useQueryIdentity(did);
510
+
511
+
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
512
+
513
+
const {
514
+
data: feedsData,
515
+
fetchNextPage,
516
+
hasNextPage,
517
+
isFetchingNextPage,
518
+
isLoading: arePostsLoading,
519
+
} = useInfiniteQueryAuthorFeed(
520
+
resolvedDid,
521
+
identity?.pds,
522
+
"app.bsky.feed.generator"
523
+
);
235
524
236
-
{/* Loading and "Load More" states */}
237
-
{arePostsLoading && posts.length === 0 && (
238
-
<div className="p-4 text-center text-gray-500">Loading posts...</div>
239
-
)}
240
-
{isFetchingNextPage && (
241
-
<div className="p-4 text-center text-gray-500">Loading more...</div>
242
-
)}
243
-
{hasNextPage && !isFetchingNextPage && (
244
-
<button
245
-
onClick={() => fetchNextPage()}
246
-
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"
525
+
const feeds = React.useMemo(
526
+
() => feedsData?.pages.flatMap((page) => page.records) ?? [],
527
+
[feedsData]
528
+
);
529
+
530
+
return (
531
+
<>
532
+
<div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
533
+
Feeds
534
+
</div>
535
+
<div>
536
+
{feeds.map((feed) => {
537
+
if (!feed || !feed?.value) return;
538
+
const feedGenRecord =
539
+
feed.value as unknown as ATPAPI.AppBskyFeedGenerator.Record;
540
+
return <FeedItemRender feed={feed as any} key={feed.uri} />;
541
+
})}
542
+
</div>
543
+
544
+
{/* Loading and "Load More" states */}
545
+
{arePostsLoading && feeds.length === 0 && (
546
+
<div className="p-4 text-center text-gray-500">Loading feeds...</div>
547
+
)}
548
+
{isFetchingNextPage && (
549
+
<div className="p-4 text-center text-gray-500">Loading more...</div>
550
+
)}
551
+
{hasNextPage && !isFetchingNextPage && (
552
+
<button
553
+
onClick={() => fetchNextPage()}
554
+
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"
555
+
>
556
+
Load More Feeds
557
+
</button>
558
+
)}
559
+
{feeds.length === 0 && !arePostsLoading && (
560
+
<div className="p-4 text-center text-gray-500">No feeds found.</div>
561
+
)}
562
+
</>
563
+
);
564
+
}
565
+
566
+
function LabelsTab({
567
+
did,
568
+
labelerRecord,
569
+
}: {
570
+
did: string;
571
+
labelerRecord?: ATPAPI.AppBskyLabelerService.Record;
572
+
}) {
573
+
useReusableTabScrollRestore(`Profile` + did);
574
+
const { agent } = useAuth();
575
+
// const {
576
+
// data: identity,
577
+
// isLoading: isIdentityLoading,
578
+
// error: identityError,
579
+
// } = useQueryIdentity(did);
580
+
581
+
// const resolvedDid = did.startsWith("did:") ? did : identity?.did;
582
+
583
+
const labelMap = new Map(
584
+
labelerRecord?.policies?.labelValueDefinitions?.map((def) => {
585
+
const locale = def.locales.find((l) => l.lang === "en") ?? def.locales[0];
586
+
return [
587
+
def.identifier,
588
+
{
589
+
name: locale?.name,
590
+
description: locale?.description,
591
+
blur: def.blurs,
592
+
severity: def.severity,
593
+
adultOnly: def.adultOnly,
594
+
defaultSetting: def.defaultSetting,
595
+
},
596
+
];
597
+
})
598
+
);
599
+
600
+
return (
601
+
<>
602
+
<div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
603
+
Labels
604
+
</div>
605
+
<div>
606
+
{[...labelMap.entries()].map(([key, item]) => (
607
+
<div
608
+
key={key}
609
+
className="border-gray-300 dark:border-gray-700 border-b px-4 py-4"
247
610
>
248
-
Load More Posts
249
-
</button>
250
-
)}
251
-
{posts.length === 0 && !arePostsLoading && (
252
-
<div className="p-4 text-center text-gray-500">No posts found.</div>
253
-
)}
611
+
<div className="font-semibold text-lg">{item.name}</div>
612
+
<div className="text-sm text-gray-500 dark:text-gray-400">
613
+
{item.description}
614
+
</div>
615
+
<div className="mt-1 text-xs text-gray-400">
616
+
{item.blur && <span>Blur: {item.blur} </span>}
617
+
{item.severity && <span>โข Severity: {item.severity} </span>}
618
+
{item.adultOnly && <span>โข 18+ only</span>}
619
+
</div>
620
+
</div>
621
+
))}
622
+
</div>
623
+
624
+
{/* Loading and "Load More" states */}
625
+
{!labelerRecord && (
626
+
<div className="p-4 text-center text-gray-500">Loading labels...</div>
627
+
)}
628
+
{/* {!labelerRecord && (
629
+
<div className="p-4 text-center text-gray-500">Loading more...</div>
630
+
)} */}
631
+
{/* {hasNextPage && !isFetchingNextPage && (
632
+
<button
633
+
onClick={() => fetchNextPage()}
634
+
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"
635
+
>
636
+
Load More Feeds
637
+
</button>
638
+
)}
639
+
{feeds.length === 0 && !arePostsLoading && (
640
+
<div className="p-4 text-center text-gray-500">No feeds found.</div>
641
+
)} */}
642
+
</>
643
+
);
644
+
}
645
+
646
+
export function FeedItemRenderAturiLoader({
647
+
aturi,
648
+
listmode,
649
+
disableBottomBorder,
650
+
disablePropagation,
651
+
}: {
652
+
aturi: string;
653
+
listmode?: boolean;
654
+
disableBottomBorder?: boolean;
655
+
disablePropagation?: boolean;
656
+
}) {
657
+
const { data: record } = useQueryArbitrary(aturi);
658
+
659
+
if (!record) return;
660
+
return (
661
+
<FeedItemRender
662
+
listmode={listmode}
663
+
feed={record}
664
+
disableBottomBorder={disableBottomBorder}
665
+
disablePropagation={disablePropagation}
666
+
/>
667
+
);
668
+
}
669
+
670
+
export function FeedItemRender({
671
+
feed,
672
+
listmode,
673
+
disableBottomBorder,
674
+
disablePropagation,
675
+
}: {
676
+
feed: { uri: string; cid: string; value: any };
677
+
listmode?: boolean;
678
+
disableBottomBorder?: boolean;
679
+
disablePropagation?: boolean;
680
+
}) {
681
+
const name = listmode
682
+
? (feed.value?.name as string)
683
+
: (feed.value?.displayName as string);
684
+
const aturi = new ATPAPI.AtUri(feed.uri);
685
+
const { data: identity } = useQueryIdentity(aturi.host);
686
+
const resolvedDid = identity?.did;
687
+
const [imgcdn] = useAtom(imgCDNAtom);
688
+
689
+
function getAvatarThumbnailUrl(f: typeof feed) {
690
+
const link = f?.value.avatar?.ref?.["$link"];
691
+
if (!link || !resolvedDid) return null;
692
+
return `https://${imgcdn}/img/avatar/plain/${resolvedDid}/${link}@jpeg`;
693
+
}
694
+
695
+
const { data: likes } = useQueryConstellation(
696
+
// @ts-expect-error overloads sucks
697
+
!listmode
698
+
? {
699
+
target: feed.uri,
700
+
method: "/links/count",
701
+
collection: "app.bsky.feed.like",
702
+
path: ".subject.uri",
703
+
}
704
+
: undefined
705
+
);
706
+
707
+
return (
708
+
<Link
709
+
className={`px-4 py-4 ${!disableBottomBorder && "border-b"} flex flex-col gap-1`}
710
+
to="/profile/$did/feed/$rkey"
711
+
params={{ did: aturi.host, rkey: aturi.rkey }}
712
+
onClick={(e) => {
713
+
e.stopPropagation();
714
+
}}
715
+
>
716
+
<div className="flex flex-row gap-3">
717
+
<div className="min-w-10 min-h-10">
718
+
<img
719
+
src={getAvatarThumbnailUrl(feed) || defaultpfp}
720
+
className="h-10 w-10 rounded border"
721
+
/>
722
+
</div>
723
+
<div className="flex flex-col">
724
+
<span className="">{name}</span>
725
+
<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">
726
+
{feed.value.did || aturi.rkey}
727
+
</span>
728
+
</div>
729
+
<div className="flex-1" />
730
+
{/* <div className="button bg-red-500 rounded-full min-w-[60px]" /> */}
731
+
</div>
732
+
<span className=" text-sm">{feed.value?.description}</span>
733
+
{!listmode && (
734
+
<span className=" text-sm dark:text-gray-400 text-gray-500">
735
+
Liked by {((likes as unknown as any)?.total as number) || 0} users
736
+
</span>
737
+
)}
738
+
</Link>
739
+
);
740
+
}
741
+
742
+
function ListsTab({ did }: { did: string }) {
743
+
useReusableTabScrollRestore(`Profile` + did);
744
+
const {
745
+
data: identity,
746
+
isLoading: isIdentityLoading,
747
+
error: identityError,
748
+
} = useQueryIdentity(did);
749
+
750
+
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
751
+
752
+
const {
753
+
data: feedsData,
754
+
fetchNextPage,
755
+
hasNextPage,
756
+
isFetchingNextPage,
757
+
isLoading: arePostsLoading,
758
+
} = useInfiniteQueryAuthorFeed(
759
+
resolvedDid,
760
+
identity?.pds,
761
+
"app.bsky.graph.list"
762
+
);
763
+
764
+
const feeds = React.useMemo(
765
+
() => feedsData?.pages.flatMap((page) => page.records) ?? [],
766
+
[feedsData]
767
+
);
768
+
769
+
return (
770
+
<>
771
+
<div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
772
+
Feeds
773
+
</div>
774
+
<div>
775
+
{feeds.map((feed) => {
776
+
if (!feed || !feed?.value) return;
777
+
const feedGenRecord =
778
+
feed.value as unknown as ATPAPI.AppBskyFeedGenerator.Record;
779
+
return (
780
+
<FeedItemRender listmode={true} feed={feed as any} key={feed.uri} />
781
+
);
782
+
})}
783
+
</div>
784
+
785
+
{/* Loading and "Load More" states */}
786
+
{arePostsLoading && feeds.length === 0 && (
787
+
<div className="p-4 text-center text-gray-500">Loading lists...</div>
788
+
)}
789
+
{isFetchingNextPage && (
790
+
<div className="p-4 text-center text-gray-500">Loading more...</div>
791
+
)}
792
+
{hasNextPage && !isFetchingNextPage && (
793
+
<button
794
+
onClick={() => fetchNextPage()}
795
+
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"
796
+
>
797
+
Load More Lists
798
+
</button>
799
+
)}
800
+
{feeds.length === 0 && !arePostsLoading && (
801
+
<div className="p-4 text-center text-gray-500">No lists found.</div>
802
+
)}
803
+
</>
804
+
);
805
+
}
806
+
807
+
function SelfLikesTab({ did }: { did: string }) {
808
+
useReusableTabScrollRestore(`Profile` + did);
809
+
const {
810
+
data: identity,
811
+
isLoading: isIdentityLoading,
812
+
error: identityError,
813
+
} = useQueryIdentity(did);
814
+
815
+
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
816
+
817
+
const {
818
+
data: likesData,
819
+
fetchNextPage,
820
+
hasNextPage,
821
+
isFetchingNextPage,
822
+
isLoading: arePostsLoading,
823
+
} = useInfiniteQueryAuthorFeed(
824
+
resolvedDid,
825
+
identity?.pds,
826
+
"app.bsky.feed.like"
827
+
);
828
+
829
+
const likes = React.useMemo(
830
+
() => likesData?.pages.flatMap((page) => page.records) ?? [],
831
+
[likesData]
832
+
);
833
+
834
+
const { setFastState } = useFastSetLikesFromFeed();
835
+
const seededRef = React.useRef(new Set<string>());
836
+
837
+
useEffect(() => {
838
+
for (const like of likes) {
839
+
if (!seededRef.current.has(like.uri)) {
840
+
seededRef.current.add(like.uri);
841
+
const record = like.value as unknown as ATPAPI.AppBskyFeedLike.Record;
842
+
setFastState(record.subject.uri, {
843
+
target: record.subject.uri,
844
+
uri: like.uri,
845
+
cid: like.cid,
846
+
});
847
+
}
848
+
}
849
+
}, [likes, setFastState]);
850
+
851
+
return (
852
+
<>
853
+
<div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
854
+
Likes
855
+
</div>
856
+
<div>
857
+
{likes.map((like) => {
858
+
if (
859
+
!like ||
860
+
!like?.value ||
861
+
!like?.value?.subject ||
862
+
// @ts-expect-error blehhhhh
863
+
!like?.value?.subject?.uri
864
+
)
865
+
return;
866
+
const likeRecord =
867
+
like.value as unknown as ATPAPI.AppBskyFeedLike.Record;
868
+
return (
869
+
<UniversalPostRendererATURILoader
870
+
key={likeRecord.subject.uri}
871
+
atUri={likeRecord.subject.uri}
872
+
feedviewpost={true}
873
+
/>
874
+
);
875
+
})}
254
876
</div>
877
+
878
+
{/* Loading and "Load More" states */}
879
+
{arePostsLoading && likes.length === 0 && (
880
+
<div className="p-4 text-center text-gray-500">Loading likes...</div>
881
+
)}
882
+
{isFetchingNextPage && (
883
+
<div className="p-4 text-center text-gray-500">Loading more...</div>
884
+
)}
885
+
{hasNextPage && !isFetchingNextPage && (
886
+
<button
887
+
onClick={() => fetchNextPage()}
888
+
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"
889
+
>
890
+
Load More Likes
891
+
</button>
892
+
)}
893
+
{likes.length === 0 && !arePostsLoading && (
894
+
<div className="p-4 text-center text-gray-500">No likes found.</div>
895
+
)}
255
896
</>
256
897
);
257
898
}
899
+
900
+
export function FollowButton({
901
+
targetdidorhandle,
902
+
}: {
903
+
targetdidorhandle: string;
904
+
}) {
905
+
const { agent } = useAuth();
906
+
const { data: identity } = useQueryIdentity(targetdidorhandle);
907
+
const queryClient = useQueryClient();
908
+
909
+
const followRecords = useGetFollowState({
910
+
target: identity?.did ?? targetdidorhandle,
911
+
user: agent?.did,
912
+
});
913
+
914
+
return (
915
+
<>
916
+
{identity?.did !== agent?.did ? (
917
+
<>
918
+
{!(followRecords?.length && followRecords?.length > 0) ? (
919
+
<button
920
+
onClick={(e) => {
921
+
e.stopPropagation();
922
+
toggleFollow({
923
+
agent: agent || undefined,
924
+
targetDid: identity?.did,
925
+
followRecords: followRecords,
926
+
queryClient: queryClient,
927
+
});
928
+
}}
929
+
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]"
930
+
>
931
+
Follow
932
+
</button>
933
+
) : (
934
+
<button
935
+
onClick={(e) => {
936
+
e.stopPropagation();
937
+
toggleFollow({
938
+
agent: agent || undefined,
939
+
targetDid: identity?.did,
940
+
followRecords: followRecords,
941
+
queryClient: queryClient,
942
+
});
943
+
}}
944
+
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]"
945
+
>
946
+
Unfollow
947
+
</button>
948
+
)}
949
+
</>
950
+
) : (
951
+
<button
952
+
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]"
953
+
onClick={(e) => {
954
+
renderSnack({
955
+
title: "Not Implemented Yet",
956
+
description: "Sorry...",
957
+
//button: { label: 'Try Again', onClick: () => console.log('whatever') },
958
+
});
959
+
}}
960
+
>
961
+
Edit Profile
962
+
</button>
963
+
)}
964
+
</>
965
+
);
966
+
}
967
+
968
+
export function BiteButton({
969
+
targetdidorhandle,
970
+
}: {
971
+
targetdidorhandle: string;
972
+
}) {
973
+
const { agent } = useAuth();
974
+
const { data: identity } = useQueryIdentity(targetdidorhandle);
975
+
const [show] = useAtom(enableBitesAtom);
976
+
977
+
if (!show) return;
978
+
979
+
return (
980
+
<>
981
+
<button
982
+
onClick={async (e) => {
983
+
e.stopPropagation();
984
+
await sendBite({
985
+
agent: agent || undefined,
986
+
targetDid: identity?.did,
987
+
});
988
+
}}
989
+
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]"
990
+
>
991
+
Bite
992
+
</button>
993
+
</>
994
+
);
995
+
}
996
+
997
+
async function sendBite({
998
+
agent,
999
+
targetDid,
1000
+
}: {
1001
+
agent?: Agent;
1002
+
targetDid?: string;
1003
+
}) {
1004
+
if (!agent?.did || !targetDid) {
1005
+
renderSnack({
1006
+
title: "Bite Failed",
1007
+
description: "You must be logged-in to bite someone.",
1008
+
//button: { label: 'Try Again', onClick: () => console.log('whatever') },
1009
+
});
1010
+
return;
1011
+
}
1012
+
const newRecord = {
1013
+
repo: agent.did,
1014
+
collection: "net.wafrn.feed.bite",
1015
+
rkey: TID.next().toString(),
1016
+
record: {
1017
+
$type: "net.wafrn.feed.bite",
1018
+
subject: "at://" + targetDid,
1019
+
createdAt: new Date().toISOString(),
1020
+
},
1021
+
};
1022
+
1023
+
try {
1024
+
await agent.com.atproto.repo.createRecord(newRecord);
1025
+
renderSnack({
1026
+
title: "Bite Sent",
1027
+
description: "Your bite was delivered.",
1028
+
//button: { label: 'Undo', onClick: () => console.log('Undo clicked') },
1029
+
});
1030
+
} catch (err) {
1031
+
console.error("Bite failed:", err);
1032
+
renderSnack({
1033
+
title: "Bite Failed",
1034
+
description: "Your bite failed to be delivered.",
1035
+
//button: { label: 'Try Again', onClick: () => console.log('whatever') },
1036
+
});
1037
+
}
1038
+
}
1039
+
1040
+
export function Mutual({ targetdidorhandle }: { targetdidorhandle: string }) {
1041
+
const { agent } = useAuth();
1042
+
const { data: identity } = useQueryIdentity(targetdidorhandle);
1043
+
1044
+
const theyFollowYouRes = useGetOneToOneState(
1045
+
agent?.did
1046
+
? {
1047
+
target: agent?.did,
1048
+
user: identity?.did ?? targetdidorhandle,
1049
+
collection: "app.bsky.graph.follow",
1050
+
path: ".subject",
1051
+
}
1052
+
: undefined
1053
+
);
1054
+
1055
+
const youFollowThemRes = useGetFollowState({
1056
+
target: identity?.did ?? targetdidorhandle,
1057
+
user: agent?.did,
1058
+
});
1059
+
1060
+
const theyFollowYou: boolean =
1061
+
!!theyFollowYouRes?.length && theyFollowYouRes.length > 0;
1062
+
const youFollowThem: boolean =
1063
+
!!youFollowThemRes?.length && youFollowThemRes.length > 0;
1064
+
1065
+
return (
1066
+
<>
1067
+
{/* if not self */}
1068
+
{identity?.did !== agent?.did ? (
1069
+
<>
1070
+
{theyFollowYou ? (
1071
+
<>
1072
+
{youFollowThem ? (
1073
+
<div 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">
1074
+
mutuals
1075
+
</div>
1076
+
) : (
1077
+
<div 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">
1078
+
follows you
1079
+
</div>
1080
+
)}
1081
+
</>
1082
+
) : (
1083
+
<></>
1084
+
)}
1085
+
</>
1086
+
) : (
1087
+
// lmao can someone be mutuals with themselves ??
1088
+
<></>
1089
+
)}
1090
+
</>
1091
+
);
1092
+
}
1093
+
1094
+
export function RichTextRenderer({ description }: { description: string }) {
1095
+
const [richDescription, setRichDescription] = useState<string | ReactNode[]>(
1096
+
description
1097
+
);
1098
+
const { agent } = useAuth();
1099
+
const navigate = useNavigate();
1100
+
1101
+
useEffect(() => {
1102
+
let mounted = true;
1103
+
1104
+
// setRichDescription(description);
1105
+
1106
+
async function processRichText() {
1107
+
try {
1108
+
if (!agent?.did) return;
1109
+
const rt = new RichText({ text: description });
1110
+
await rt.detectFacets(agent);
1111
+
1112
+
if (!mounted) return;
1113
+
1114
+
if (rt.facets) {
1115
+
setRichDescription(
1116
+
renderTextWithFacets({ text: rt.text, facets: rt.facets, navigate })
1117
+
);
1118
+
} else {
1119
+
setRichDescription(rt.text);
1120
+
}
1121
+
} catch (error) {
1122
+
console.error("Failed to detect facets:", error);
1123
+
if (mounted) {
1124
+
setRichDescription(description);
1125
+
}
1126
+
}
1127
+
}
1128
+
1129
+
processRichText();
1130
+
1131
+
return () => {
1132
+
mounted = false;
1133
+
};
1134
+
}, [description, agent, navigate]);
1135
+
1136
+
return <>{richDescription}</>;
1137
+
}
+1
-1
src/routes/profile.$did/post.$rkey.image.$i.tsx
+1
-1
src/routes/profile.$did/post.$rkey.image.$i.tsx
···
85
85
e.stopPropagation();
86
86
e.nativeEvent.stopImmediatePropagation();
87
87
}}
88
-
className="lightbox-sidebar hidden lg:flex overscroll-none disablegutter border-l dark:border-gray-800 was7 border-gray-300 fixed z-50 flex top-0 right-0 flex-col max-w-[350px] min-w-[350px] max-h-screen overflow-y-scroll dark:bg-gray-950 bg-white"
88
+
className="lightbox-sidebar hidden lg:flex overscroll-none disablegutter disablescroll border-l dark:border-gray-800 was7 border-gray-300 fixed z-50 top-0 right-0 flex-col max-w-[350px] min-w-[350px] max-h-screen overflow-y-scroll dark:bg-gray-950 bg-white"
89
89
>
90
90
<ProfilePostComponent
91
91
key={`/profile/${did}/post/${rkey}`}
+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
+
}
+287
-117
src/routes/profile.$did/post.$rkey.tsx
+287
-117
src/routes/profile.$did/post.$rkey.tsx
···
1
-
import { useQueryClient } from "@tanstack/react-query";
2
-
import { createFileRoute, Outlet } from "@tanstack/react-router";
1
+
import { AtUri } from "@atproto/api";
2
+
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
3
+
import { createFileRoute, Outlet, useMatchRoute } from "@tanstack/react-router";
4
+
import { useAtom } from "jotai";
3
5
import React, { useLayoutEffect } from "react";
4
6
5
7
import { Header } from "~/components/Header";
6
8
import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer";
9
+
import { constellationURLAtom, slingshotURLAtom } from "~/utils/atoms";
7
10
//import { usePersistentStore } from '~/providers/PersistentStoreProvider';
8
11
import {
9
12
constructPostQuery,
13
+
type linksAllResponse,
14
+
type linksRecordsResponse,
10
15
useQueryConstellation,
11
16
useQueryIdentity,
12
17
useQueryPost,
18
+
yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks,
13
19
} from "~/utils/useQuery";
14
20
15
21
import type { LightboxProps } from "./post.$rkey.image.$i";
···
39
45
did,
40
46
rkey,
41
47
nopics,
42
-
lightboxCallback
48
+
lightboxCallback,
43
49
}: {
44
50
did: string;
45
51
rkey: string;
46
52
nopics?: boolean;
47
-
lightboxCallback?: (d:LightboxProps) => void;
53
+
lightboxCallback?: (d: LightboxProps) => void;
48
54
}) {
55
+
const matchRoute = useMatchRoute()
56
+
const showMainPostRoute = !!matchRoute({ to: '/profile/$did/post/$rkey' }) || !!matchRoute({ to: '/profile/$did/post/$rkey/image/$i' })
57
+
49
58
//const { get, set } = usePersistentStore();
50
59
const queryClient = useQueryClient();
51
60
// const [resolvedDid, setResolvedDid] = React.useState<string | null>(null);
···
184
193
data: identity,
185
194
isLoading: isIdentityLoading,
186
195
error: identityError,
187
-
} = useQueryIdentity(did);
196
+
} = useQueryIdentity(showMainPostRoute ? did : undefined);
188
197
189
198
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
190
199
191
200
const atUri = React.useMemo(
192
201
() =>
193
-
resolvedDid
202
+
resolvedDid && showMainPostRoute
194
203
? `at://${decodeURIComponent(resolvedDid)}/app.bsky.feed.post/${rkey}`
195
-
: "",
196
-
[resolvedDid, rkey]
204
+
: undefined,
205
+
[resolvedDid, rkey, showMainPostRoute]
197
206
);
198
207
199
-
const { data: mainPost } = useQueryPost(atUri);
208
+
const { data: mainPost } = useQueryPost(showMainPostRoute ? atUri : undefined);
200
209
201
-
const { data: repliesData } = useQueryConstellation({
202
-
method: "/links",
210
+
console.log("atUri",atUri)
211
+
212
+
const opdid = React.useMemo(
213
+
() =>
214
+
atUri
215
+
? new AtUri(atUri).host
216
+
: undefined,
217
+
[atUri]
218
+
);
219
+
220
+
// @ts-expect-error i hate overloads
221
+
const { data: links } = useQueryConstellation(atUri&&showMainPostRoute?{
222
+
method: "/links/all",
203
223
target: atUri,
204
-
collection: "app.bsky.feed.post",
205
-
path: ".reply.parent.uri",
224
+
} : {
225
+
method: "undefined",
226
+
target: ""
227
+
})as { data: linksAllResponse | undefined };
228
+
229
+
//const [likes, setLikes] = React.useState<number | null>(null);
230
+
//const [reposts, setReposts] = React.useState<number | null>(null);
231
+
const [replyCount, setReplyCount] = React.useState<number | null>(null);
232
+
233
+
React.useEffect(() => {
234
+
// /*mass comment*/ console.log(JSON.stringify(links, null, 2));
235
+
// setLikes(
236
+
// links
237
+
// ? links?.links?.["app.bsky.feed.like"]?.[".subject.uri"]?.records || 0
238
+
// : null
239
+
// );
240
+
// setReposts(
241
+
// links
242
+
// ? links?.links?.["app.bsky.feed.repost"]?.[".subject.uri"]?.records || 0
243
+
// : null
244
+
// );
245
+
setReplyCount(
246
+
links
247
+
? links?.links?.["app.bsky.feed.post"]?.[".reply.parent.uri"]
248
+
?.records || 0
249
+
: null
250
+
);
251
+
}, [links]);
252
+
253
+
const { data: opreplies } = useQueryConstellation(
254
+
showMainPostRoute && !!opdid && replyCount && replyCount >= 25
255
+
? {
256
+
method: "/links",
257
+
target: atUri,
258
+
// @ts-expect-error overloading sucks so much
259
+
collection: "app.bsky.feed.post",
260
+
path: ".reply.parent.uri",
261
+
//cursor?: string;
262
+
dids: [opdid],
263
+
}
264
+
: {
265
+
method: "undefined",
266
+
target: "",
267
+
}
268
+
) as { data: linksRecordsResponse | undefined };
269
+
270
+
const opReplyAturis =
271
+
opreplies?.linking_records.map(
272
+
(r) => `at://${r.did}/${r.collection}/${r.rkey}`,
273
+
) ?? [];
274
+
275
+
276
+
// const { data: repliesData } = useQueryConstellation({
277
+
// method: "/links",
278
+
// target: atUri,
279
+
// collection: "app.bsky.feed.post",
280
+
// path: ".reply.parent.uri",
281
+
// });
282
+
// const replies = repliesData?.linking_records.slice(0, 50) ?? [];
283
+
const [constellationurl] = useAtom(constellationURLAtom)
284
+
285
+
const infinitequeryresults = useInfiniteQuery({
286
+
...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(
287
+
{
288
+
constellation: constellationurl,
289
+
method: "/links",
290
+
target: atUri,
291
+
collection: "app.bsky.feed.post",
292
+
path: ".reply.parent.uri",
293
+
}
294
+
),
295
+
enabled: !!atUri && showMainPostRoute,
206
296
});
207
-
const replies = repliesData?.linking_records.slice(0, 50) ?? [];
297
+
298
+
const {
299
+
data: infiniteRepliesData,
300
+
fetchNextPage,
301
+
hasNextPage,
302
+
isFetchingNextPage,
303
+
} = infinitequeryresults;
304
+
305
+
// // auto-fetch all pages
306
+
// useEffect(() => {
307
+
// if (
308
+
// infinitequeryresults.hasNextPage &&
309
+
// !infinitequeryresults.isFetchingNextPage
310
+
// ) {
311
+
// console.log("Fetching the next page...");
312
+
// infinitequeryresults.fetchNextPage();
313
+
// }
314
+
// }, [infinitequeryresults]);
315
+
316
+
// const replyAturis = repliesData
317
+
// ? repliesData.pages.flatMap((page) =>
318
+
// page
319
+
// ? page.linking_records.map((record) => {
320
+
// const aturi = `at://${record.did}/${record.collection}/${record.rkey}`;
321
+
// return aturi;
322
+
// })
323
+
// : []
324
+
// )
325
+
// : [];
326
+
327
+
const replyAturis = React.useMemo(() => {
328
+
// Get all replies from the standard infinite query
329
+
const allReplies =
330
+
infiniteRepliesData?.pages.flatMap(
331
+
(page) =>
332
+
page?.linking_records.map(
333
+
(r) => `at://${r.did}/${r.collection}/${r.rkey}`,
334
+
) ?? [],
335
+
) ?? [];
336
+
337
+
if (replyCount && (replyCount < 25)) {
338
+
// If count is low, just use the standard list and find the oldest OP reply to move to the top
339
+
const opdidFromUri = atUri ? new AtUri(atUri).host : undefined;
340
+
const oldestOpsIndex = allReplies.findIndex(
341
+
(aturi) => new AtUri(aturi).host === opdidFromUri,
342
+
);
343
+
if (oldestOpsIndex > 0) {
344
+
const [oldestOpsReply] = allReplies.splice(oldestOpsIndex, 1);
345
+
allReplies.unshift(oldestOpsReply);
346
+
}
347
+
return allReplies;
348
+
} else {
349
+
// If count is high, prioritize OP replies from the special query
350
+
// and filter them out from the main list to avoid duplication.
351
+
const opReplySet = new Set(opReplyAturis);
352
+
const otherReplies = allReplies.filter((uri) => !opReplySet.has(uri));
353
+
return [...opReplyAturis, ...otherReplies];
354
+
}
355
+
}, [infiniteRepliesData, opReplyAturis, replyCount, atUri]);
356
+
357
+
// Find oldest OP reply
358
+
const oldestOpsIndex = replyAturis.findIndex(
359
+
(aturi) => new AtUri(aturi).host === opdid
360
+
);
361
+
362
+
// Reorder: move oldest OP reply to the front
363
+
if (oldestOpsIndex > 0) {
364
+
const [oldestOpsReply] = replyAturis.splice(oldestOpsIndex, 1);
365
+
replyAturis.unshift(oldestOpsReply);
366
+
}
208
367
209
368
const [parents, setParents] = React.useState<any[]>([]);
210
369
const [parentsLoading, setParentsLoading] = React.useState(false);
211
370
212
371
const mainPostRef = React.useRef<HTMLDivElement>(null);
213
-
const userHasScrolled = React.useRef(false);
372
+
const hasPerformedInitialLayout = React.useRef(false);
214
373
215
-
const scrollAnchor = React.useRef<{ top: number } | null>(null);
374
+
const [layoutReady, setLayoutReady] = React.useState(false);
216
375
217
-
React.useEffect(() => {
218
-
const onScroll = () => {
219
-
if (window.scrollY > 50) {
220
-
userHasScrolled.current = true;
376
+
useLayoutEffect(() => {
377
+
if (!showMainPostRoute) return
378
+
if (parents.length > 0 && !layoutReady && mainPostRef.current) {
379
+
const mainPostElement = mainPostRef.current;
221
380
222
-
window.removeEventListener("scroll", onScroll);
223
-
}
224
-
};
381
+
if (window.scrollY === 0 && !hasPerformedInitialLayout.current) {
382
+
const elementTop = mainPostElement.getBoundingClientRect().top;
383
+
const headerOffset = 70;
384
+
385
+
const targetScrollY = elementTop - headerOffset;
386
+
387
+
window.scrollBy(0, targetScrollY);
225
388
226
-
if (!userHasScrolled.current) {
227
-
window.addEventListener("scroll", onScroll, { passive: true });
389
+
hasPerformedInitialLayout.current = true;
390
+
}
391
+
392
+
// todo idk what to do with this
393
+
// eslint-disable-next-line react-hooks/set-state-in-effect
394
+
setLayoutReady(true);
228
395
}
229
-
return () => window.removeEventListener("scroll", onScroll);
230
-
}, []);
396
+
}, [parents, layoutReady, showMainPostRoute]);
397
+
231
398
232
-
useLayoutEffect(() => {
233
-
if (parentsLoading && mainPostRef.current && !userHasScrolled.current) {
234
-
scrollAnchor.current = {
235
-
top: mainPostRef.current.getBoundingClientRect().top,
236
-
};
399
+
const [slingshoturl] = useAtom(slingshotURLAtom)
400
+
401
+
React.useEffect(() => {
402
+
if (parentsLoading || !showMainPostRoute) {
403
+
setLayoutReady(false);
237
404
}
238
-
}, [parentsLoading]);
239
405
240
-
useLayoutEffect(() => {
241
-
if (
242
-
scrollAnchor.current &&
243
-
mainPostRef.current &&
244
-
!userHasScrolled.current
245
-
) {
246
-
const newTop = mainPostRef.current.getBoundingClientRect().top;
247
-
const topDiff = newTop - scrollAnchor.current.top;
248
-
if (topDiff > 0) {
249
-
window.scrollBy(0, topDiff);
250
-
}
251
-
scrollAnchor.current = null;
406
+
if (!mainPost?.value?.reply?.parent?.uri && !parentsLoading) {
407
+
setLayoutReady(true);
408
+
hasPerformedInitialLayout.current = true;
252
409
}
253
-
}, [parents]);
410
+
}, [parentsLoading, mainPost, showMainPostRoute]);
254
411
255
412
React.useEffect(() => {
256
413
if (!mainPost?.value?.reply?.parent?.uri) {
···
269
426
while (currentParentUri && safetyCounter < MAX_PARENTS) {
270
427
try {
271
428
const parentPost = await queryClient.fetchQuery(
272
-
constructPostQuery(currentParentUri)
429
+
constructPostQuery(currentParentUri, slingshoturl)
273
430
);
274
431
if (!parentPost) break;
275
432
parentChain.push(parentPost);
···
291
448
return () => {
292
449
ignore = true;
293
450
};
294
-
}, [mainPost, queryClient]);
451
+
}, [mainPost, queryClient, slingshoturl]);
295
452
296
-
if (!did || !rkey) return <div>Invalid post URI</div>;
297
-
if (isIdentityLoading) return <div>Resolving handle...</div>;
298
-
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)
299
456
return <div style={{ color: "red" }}>{identityError.message}</div>;
300
-
if (!atUri) return <div>Could not construct post URI.</div>;
457
+
if (!atUri && showMainPostRoute) return <div>Could not construct post URI.</div>;
301
458
302
459
return (
303
460
<>
304
461
<Outlet />
305
-
<Header
306
-
title={`Post`}
307
-
backButtonCallback={() => {
308
-
if (window.history.length > 1) {
309
-
window.history.back();
310
-
} else {
311
-
window.location.assign("/");
312
-
}
313
-
}}
314
-
/>
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
+
/>
315
473
316
-
{parentsLoading && (
317
-
<div className="text-center text-gray-500 dark:text-gray-400 flex flex-row">
318
-
<div className="ml-4 w-[42px] flex justify-center">
319
-
<div
320
-
style={{ width: 2, height: "100%", opacity: 0.5 }}
321
-
className="bg-gray-500 dark:bg-gray-400"
322
-
></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...
323
483
</div>
324
-
Loading conversation...
325
-
</div>
326
-
)}
484
+
)}
327
485
328
-
{/* we should use the reply lines here thats provided by UPR*/}
329
-
<div style={{ maxWidth: 600, padding: 0 }}>
330
-
{parents.map((parent, index) => (
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
+
))}
497
+
</div>
498
+
<div ref={mainPostRef}>
331
499
<UniversalPostRendererATURILoader
332
-
key={parent.uri}
333
-
atUri={parent.uri}
334
-
topReplyLine={index > 0}
335
-
bottomReplyLine={true}
336
-
bottomBorder={false}
500
+
atUri={atUri!}
501
+
detailed={true}
502
+
topReplyLine={parentsLoading || parents.length > 0}
503
+
nopics={!!nopics}
504
+
lightboxCallback={lightboxCallback}
337
505
/>
338
-
))}
339
-
</div>
340
-
<div ref={mainPostRef}>
341
-
<UniversalPostRendererATURILoader
342
-
atUri={atUri}
343
-
detailed={true}
344
-
topReplyLine={parentsLoading || parents.length > 0}
345
-
nopics={!!nopics}
346
-
lightboxCallback={lightboxCallback}
347
-
/>
348
-
</div>
349
-
<div
350
-
style={{
351
-
maxWidth: 600,
352
-
//margin: "0px auto 0",
353
-
padding: 0,
354
-
minHeight: "100dvh",
355
-
}}
356
-
>
506
+
</div>
357
507
<div
358
-
className="text-gray-500 dark:text-gray-400 text-sm font-bold"
359
508
style={{
360
-
fontSize: 18,
361
-
margin: "12px 16px 12px 16px",
362
-
fontWeight: 600,
509
+
maxWidth: 600,
510
+
//margin: "0px auto 0",
511
+
padding: 0,
512
+
minHeight: "80dvh",
513
+
paddingBottom: "20dvh",
363
514
}}
364
515
>
365
-
Replies
366
-
</div>
367
-
<div style={{ display: "flex", flexDirection: "column", gap: 0 }}>
368
-
{replies.length > 0 &&
369
-
replies.map((reply) => {
370
-
const replyAtUri = `at://${reply.did}/app.bsky.feed.post/${reply.rkey}`;
371
-
return (
372
-
<UniversalPostRendererATURILoader
373
-
key={replyAtUri}
374
-
atUri={replyAtUri}
375
-
/>
376
-
);
377
-
})}
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>
378
548
</div>
379
-
</div>
549
+
</>)}
380
550
</>
381
551
);
382
552
}
+259
-2
src/routes/search.tsx
+259
-2
src/routes/search.tsx
···
1
-
import { createFileRoute } from "@tanstack/react-router";
1
+
import type { Agent } from "@atproto/api";
2
+
import { useQueryClient } from "@tanstack/react-query";
3
+
import { createFileRoute, useSearch } from "@tanstack/react-router";
4
+
import { useAtom } from "jotai";
5
+
import { useEffect,useMemo } from "react";
6
+
7
+
import { Header } from "~/components/Header";
8
+
import { Import } from "~/components/Import";
9
+
import {
10
+
ReusableTabRoute,
11
+
useReusableTabScrollRestore,
12
+
} from "~/components/ReusableTabRoute";
13
+
import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer";
14
+
import { useAuth } from "~/providers/UnifiedAuthProvider";
15
+
import { lycanURLAtom } from "~/utils/atoms";
16
+
import {
17
+
constructLycanRequestIndexQuery,
18
+
useInfiniteQueryLycanSearch,
19
+
useQueryIdentity,
20
+
useQueryLycanStatus,
21
+
} from "~/utils/useQuery";
22
+
23
+
import { renderSnack } from "./__root";
24
+
import { SliderPrimitive } from "./settings";
2
25
3
26
export const Route = createFileRoute("/search")({
4
27
component: Search,
5
28
});
6
29
7
30
export function Search() {
8
-
return <div className="p-6">Search page (coming soon)</div>;
31
+
const queryClient = useQueryClient();
32
+
const { agent, status } = useAuth();
33
+
const { data: identity } = useQueryIdentity(agent?.did);
34
+
const [lycandomain] = useAtom(lycanURLAtom);
35
+
const lycanExists = lycandomain !== "";
36
+
const { data: lycanstatusdata, refetch } = useQueryLycanStatus();
37
+
const lycanIndexed = lycanstatusdata?.status === "finished" || false;
38
+
const lycanIndexing = lycanstatusdata?.status === "in_progress" || false;
39
+
const lycanIndexingProgress = lycanIndexing
40
+
? lycanstatusdata?.progress
41
+
: undefined;
42
+
43
+
const authed = status === "signedIn";
44
+
45
+
const lycanReady = lycanExists && lycanIndexed && authed;
46
+
47
+
const { q }: { q: string } = useSearch({ from: "/search" });
48
+
49
+
// auto-refetch Lycan status until ready
50
+
useEffect(() => {
51
+
if (!lycanExists || !authed) return;
52
+
if (lycanReady) return;
53
+
54
+
const interval = setInterval(() => {
55
+
refetch();
56
+
}, 3000);
57
+
58
+
return () => clearInterval(interval);
59
+
}, [lycanExists, authed, lycanReady, refetch]);
60
+
61
+
const maintext = !lycanExists
62
+
? "Sorry we dont have search. But instead, you can load some of these types of content into Red Dwarf:"
63
+
: authed
64
+
? lycanReady
65
+
? "Lycan Search is enabled and ready! Type to search posts you've interacted with in the past. You can also load some of these types of content into Red Dwarf:"
66
+
: "Sorry, while Lycan Search is enabled, you are not indexed. Index below please. You can load some of these types of content into Red Dwarf:"
67
+
: "Sorry, while Lycan Search is enabled, you are unauthed. Please log in to use Lycan. You can load some of these types of content into Red Dwarf:";
68
+
69
+
async function index(opts: {
70
+
agent?: Agent;
71
+
isAuthed: boolean;
72
+
pdsUrl?: string;
73
+
feedServiceDid?: string;
74
+
}) {
75
+
renderSnack({
76
+
title: "Registering account...",
77
+
});
78
+
try {
79
+
const response = await queryClient.fetchQuery(
80
+
constructLycanRequestIndexQuery(opts)
81
+
);
82
+
if (
83
+
response?.message !== "Import has already started" &&
84
+
response?.message !== "Import has been scheduled"
85
+
) {
86
+
renderSnack({
87
+
title: "Registration failed!",
88
+
description: "Unknown server error (2)",
89
+
});
90
+
} else {
91
+
renderSnack({
92
+
title: "Succesfully sent registration request!",
93
+
description: "Please wait for the server to index your account",
94
+
});
95
+
refetch();
96
+
}
97
+
} catch {
98
+
renderSnack({
99
+
title: "Registration failed!",
100
+
description: "Unknown server error (1)",
101
+
});
102
+
}
103
+
}
104
+
105
+
return (
106
+
<>
107
+
<Header
108
+
title="Explore"
109
+
backButtonCallback={() => {
110
+
if (window.history.length > 1) {
111
+
window.history.back();
112
+
} else {
113
+
window.location.assign("/");
114
+
}
115
+
}}
116
+
/>
117
+
<div className=" flex flex-col items-center mt-4 mx-4 gap-4">
118
+
<Import optionaltextstring={q} />
119
+
<div className="flex flex-col">
120
+
<p className="text-gray-600 dark:text-gray-400">{maintext}</p>
121
+
<ul className="list-disc list-inside mt-2 text-gray-600 dark:text-gray-400">
122
+
<li>
123
+
Bluesky URLs (from supported clients) (like{" "}
124
+
<code className="text-sm">bsky.app</code> or{" "}
125
+
<code className="text-sm">deer.social</code>).
126
+
</li>
127
+
<li>
128
+
AT-URIs (e.g.,{" "}
129
+
<code className="text-sm">at://did:example/collection/item</code>
130
+
).
131
+
</li>
132
+
<li>
133
+
User Handles (like{" "}
134
+
<code className="text-sm">@username.bsky.social</code>).
135
+
</li>
136
+
<li>
137
+
DIDs (Decentralized Identifiers, starting with{" "}
138
+
<code className="text-sm">did:</code>).
139
+
</li>
140
+
</ul>
141
+
<p className="mt-2 text-gray-600 dark:text-gray-400">
142
+
Simply paste one of these into the import field above and press
143
+
Enter to load the content.
144
+
</p>
145
+
146
+
{lycanExists && authed && !lycanReady ? (
147
+
!lycanIndexing ? (
148
+
<div className="mt-4 mx-auto">
149
+
<button
150
+
onClick={() =>
151
+
index({
152
+
agent: agent || undefined,
153
+
isAuthed: status === "signedIn",
154
+
pdsUrl: identity?.pds,
155
+
feedServiceDid: "did:web:" + lycandomain,
156
+
})
157
+
}
158
+
className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800
159
+
text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition"
160
+
>
161
+
Index my Account
162
+
</button>
163
+
</div>
164
+
) : (
165
+
<div className="mt-4 gap-2 flex flex-col">
166
+
<span>indexing...</span>
167
+
<SliderPrimitive
168
+
value={lycanIndexingProgress || 0}
169
+
min={0}
170
+
max={1}
171
+
/>
172
+
</div>
173
+
)
174
+
) : (
175
+
<></>
176
+
)}
177
+
</div>
178
+
</div>
179
+
{q ? <SearchTabs query={q} /> : <></>}
180
+
</>
181
+
);
182
+
}
183
+
184
+
function SearchTabs({ query }: { query: string }) {
185
+
return (
186
+
<div>
187
+
<ReusableTabRoute
188
+
route={`search` + query}
189
+
tabs={{
190
+
Likes: <LycanTab query={query} type={"likes"} key={"likes"} />,
191
+
Reposts: <LycanTab query={query} type={"reposts"} key={"reposts"} />,
192
+
Quotes: <LycanTab query={query} type={"quotes"} key={"quotes"} />,
193
+
Pins: <LycanTab query={query} type={"pins"} key={"pins"} />,
194
+
}}
195
+
/>
196
+
</div>
197
+
);
198
+
}
199
+
200
+
function LycanTab({
201
+
query,
202
+
type,
203
+
}: {
204
+
query: string;
205
+
type: "likes" | "pins" | "reposts" | "quotes";
206
+
}) {
207
+
useReusableTabScrollRestore("search" + query);
208
+
209
+
const {
210
+
data: postsData,
211
+
fetchNextPage,
212
+
hasNextPage,
213
+
isFetchingNextPage,
214
+
isLoading: arePostsLoading,
215
+
} = useInfiniteQueryLycanSearch({ query: query, type: type });
216
+
217
+
const posts = useMemo(
218
+
() =>
219
+
postsData?.pages.flatMap((page) => {
220
+
if (page) {
221
+
return page.posts;
222
+
} else {
223
+
return [];
224
+
}
225
+
}) ?? [],
226
+
[postsData]
227
+
);
228
+
229
+
return (
230
+
<>
231
+
{/* <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
232
+
Posts
233
+
</div> */}
234
+
<div>
235
+
{posts.map((post) => (
236
+
<UniversalPostRendererATURILoader
237
+
key={post}
238
+
atUri={post}
239
+
feedviewpost={true}
240
+
/>
241
+
))}
242
+
</div>
243
+
244
+
{/* Loading and "Load More" states */}
245
+
{arePostsLoading && posts.length === 0 && (
246
+
<div className="p-4 text-center text-gray-500">Loading posts...</div>
247
+
)}
248
+
{isFetchingNextPage && (
249
+
<div className="p-4 text-center text-gray-500">Loading more...</div>
250
+
)}
251
+
{hasNextPage && !isFetchingNextPage && (
252
+
<button
253
+
onClick={() => fetchNextPage()}
254
+
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"
255
+
>
256
+
Load More Posts
257
+
</button>
258
+
)}
259
+
{posts.length === 0 && !arePostsLoading && (
260
+
<div className="p-4 text-center text-gray-500">No posts found.</div>
261
+
)}
262
+
</>
263
+
);
264
+
265
+
return <></>;
9
266
}
+336
-2
src/routes/settings.tsx
+336
-2
src/routes/settings.tsx
···
1
-
import { createFileRoute } from "@tanstack/react-router";
1
+
import { createFileRoute, useNavigate } from "@tanstack/react-router";
2
+
import { useAtom, useAtomValue, useSetAtom } from "jotai";
3
+
import { Slider, Switch } from "radix-ui";
4
+
import { useEffect, useState } from "react";
2
5
3
6
import { Header } from "~/components/Header";
4
7
import Login from "~/components/Login";
8
+
import {
9
+
constellationURLAtom,
10
+
defaultconstellationURL,
11
+
defaulthue,
12
+
defaultImgCDN,
13
+
defaultLycanURL,
14
+
defaultslingshotURL,
15
+
defaultVideoCDN,
16
+
enableBitesAtom,
17
+
enableBridgyTextAtom,
18
+
enableWafrnTextAtom,
19
+
hueAtom,
20
+
imgCDNAtom,
21
+
lycanURLAtom,
22
+
slingshotURLAtom,
23
+
videoCDNAtom,
24
+
} from "~/utils/atoms";
25
+
26
+
import { MaterialNavItem } from "./__root";
5
27
6
28
export const Route = createFileRoute("/settings")({
7
29
component: Settings,
8
30
});
9
31
10
32
export function Settings() {
33
+
const navigate = useNavigate();
11
34
return (
12
35
<>
13
36
<Header
···
20
43
}
21
44
}}
22
45
/>
23
-
<Login />
46
+
<div className="lg:hidden">
47
+
<Login />
48
+
</div>
49
+
<div className="sm:hidden flex flex-col justify-around mt-4">
50
+
<SettingHeading title="Other Pages" top />
51
+
<MaterialNavItem
52
+
InactiveIcon={<IconMaterialSymbolsTag className="w-6 h-6" />}
53
+
ActiveIcon={<IconMaterialSymbolsTag className="w-6 h-6" />}
54
+
active={false}
55
+
onClickCallbback={() =>
56
+
navigate({
57
+
to: "/feeds",
58
+
//params: { did: agent.assertDid },
59
+
})
60
+
}
61
+
text="Feeds"
62
+
/>
63
+
<MaterialNavItem
64
+
InactiveIcon={<IconMdiShieldOutline className="w-6 h-6" />}
65
+
ActiveIcon={<IconMdiShield className="w-6 h-6" />}
66
+
active={false}
67
+
onClickCallbback={() =>
68
+
navigate({
69
+
to: "/moderation",
70
+
//params: { did: agent.assertDid },
71
+
})
72
+
}
73
+
text="Moderation"
74
+
/>
75
+
</div>
76
+
<div className="h-4" />
77
+
78
+
<SettingHeading title="Personalization" top />
79
+
<Hue />
80
+
81
+
<SettingHeading title="Network Configuration" />
82
+
<div className="flex flex-col px-4 pb-2">
83
+
<span className="text-md">Service Endpoints</span>
84
+
<span className="text-sm text-gray-500 dark:text-gray-400">
85
+
Customize the servers to be used by the app
86
+
</span>
87
+
</div>
88
+
<TextInputSetting
89
+
atom={constellationURLAtom}
90
+
title={"Constellation"}
91
+
description={
92
+
"Customize the Constellation instance to be used by Red Dwarf"
93
+
}
94
+
init={defaultconstellationURL}
95
+
/>
96
+
<TextInputSetting
97
+
atom={slingshotURLAtom}
98
+
title={"Slingshot"}
99
+
description={"Customize the Slingshot instance to be used by Red Dwarf"}
100
+
init={defaultslingshotURL}
101
+
/>
102
+
<TextInputSetting
103
+
atom={imgCDNAtom}
104
+
title={"Image CDN"}
105
+
description={
106
+
"Customize the Constellation instance to be used by Red Dwarf"
107
+
}
108
+
init={defaultImgCDN}
109
+
/>
110
+
<TextInputSetting
111
+
atom={videoCDNAtom}
112
+
title={"Video CDN"}
113
+
description={"Customize the Slingshot instance to be used by Red Dwarf"}
114
+
init={defaultVideoCDN}
115
+
/>
116
+
<TextInputSetting
117
+
atom={lycanURLAtom}
118
+
title={"Lycan Search"}
119
+
description={"Enable text search across posts you've interacted with"}
120
+
init={defaultLycanURL}
121
+
/>
122
+
123
+
<SettingHeading title="Experimental" />
124
+
<SwitchSetting
125
+
atom={enableBitesAtom}
126
+
title={"Bites"}
127
+
description={"Enable Wafrn Bites to bite and be bitten by other people"}
128
+
//init={false}
129
+
/>
130
+
<div className="h-4" />
131
+
<SwitchSetting
132
+
atom={enableBridgyTextAtom}
133
+
title={"Bridgy Text"}
134
+
description={
135
+
"Show the original text of posts bridged from the Fediverse"
136
+
}
137
+
//init={false}
138
+
/>
139
+
<div className="h-4" />
140
+
<SwitchSetting
141
+
atom={enableWafrnTextAtom}
142
+
title={"Wafrn Text"}
143
+
description={"Show the original text of posts from Wafrn instances"}
144
+
//init={false}
145
+
/>
146
+
<p className="text-gray-500 dark:text-gray-400 py-4 px-4 text-sm border rounded-xl mx-4 mt-8 mb-4">
147
+
Notice: Please restart/refresh the app if changes arent applying
148
+
correctly
149
+
</p>
24
150
</>
25
151
);
26
152
}
153
+
154
+
export function SettingHeading({
155
+
title,
156
+
top,
157
+
}: {
158
+
title: string;
159
+
top?: boolean;
160
+
}) {
161
+
return (
162
+
<div
163
+
className="px-4"
164
+
style={{ marginTop: top ? 0 : 18, paddingBottom: 12 }}
165
+
>
166
+
<span className=" text-sm font-medium text-gray-500 dark:text-gray-400">
167
+
{title}
168
+
</span>
169
+
</div>
170
+
);
171
+
}
172
+
173
+
export function SwitchSetting({
174
+
atom,
175
+
title,
176
+
description,
177
+
}: {
178
+
atom: typeof enableBitesAtom;
179
+
title?: string;
180
+
description?: string;
181
+
}) {
182
+
const value = useAtomValue(atom);
183
+
const setValue = useSetAtom(atom);
184
+
185
+
const [hydrated, setHydrated] = useState(false);
186
+
// eslint-disable-next-line react-hooks/set-state-in-effect
187
+
useEffect(() => setHydrated(true), []);
188
+
189
+
if (!hydrated) {
190
+
// Avoid rendering Switch until we know storage is loaded
191
+
return null;
192
+
}
193
+
194
+
return (
195
+
<div className="flex items-center gap-4 px-4 ">
196
+
<label htmlFor={`switch-${title}`} className="flex flex-row flex-1">
197
+
<div className="flex flex-col">
198
+
<span className="text-md">{title}</span>
199
+
<span className="text-sm text-gray-500 dark:text-gray-400">
200
+
{description}
201
+
</span>
202
+
</div>
203
+
</label>
204
+
205
+
<Switch.Root
206
+
id={`switch-${title}`}
207
+
checked={value}
208
+
onCheckedChange={(v) => setValue(v)}
209
+
className="m3switch root"
210
+
>
211
+
<Switch.Thumb className="m3switch thumb " />
212
+
</Switch.Root>
213
+
</div>
214
+
);
215
+
}
216
+
217
+
function Hue() {
218
+
const [hue, setHue] = useAtom(hueAtom);
219
+
return (
220
+
<div className="flex flex-col px-4">
221
+
<span className="z-[2] text-md">Hue</span>
222
+
<span className="z-[2] text-sm text-gray-500 dark:text-gray-400">
223
+
Change the colors of the app
224
+
</span>
225
+
<div className="z-[1] flex flex-row items-center gap-4">
226
+
<SliderComponent atom={hueAtom} max={360} />
227
+
<button
228
+
onClick={() => setHue(defaulthue ?? 28)}
229
+
className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800
230
+
text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition"
231
+
>
232
+
Reset
233
+
</button>
234
+
</div>
235
+
</div>
236
+
);
237
+
}
238
+
239
+
export function TextInputSetting({
240
+
atom,
241
+
title,
242
+
description,
243
+
init,
244
+
}: {
245
+
atom: typeof constellationURLAtom;
246
+
title?: string;
247
+
description?: string;
248
+
init?: string;
249
+
}) {
250
+
const [value, setValue] = useAtom(atom);
251
+
return (
252
+
<div className="flex flex-col gap-2 px-4 py-2">
253
+
{/* <div>
254
+
{title && (
255
+
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
256
+
{title}
257
+
</h3>
258
+
)}
259
+
{description && (
260
+
<p className="text-sm text-gray-500 dark:text-gray-400">
261
+
{description}
262
+
</p>
263
+
)}
264
+
</div> */}
265
+
266
+
<div className="flex flex-row gap-2 items-center">
267
+
<div className="m3input-field m3input-label m3input-border size-md flex-1">
268
+
<input
269
+
type="text"
270
+
placeholder=" "
271
+
value={value}
272
+
onChange={(e) => setValue(e.target.value)}
273
+
/>
274
+
<label>{title}</label>
275
+
</div>
276
+
{/* <input
277
+
type="text"
278
+
value={value}
279
+
onChange={(e) => setValue(e.target.value)}
280
+
className="flex-1 px-3 py-2 rounded-lg bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700
281
+
text-gray-900 dark:text-gray-100 placeholder:text-gray-500 dark:placeholder:text-gray-400
282
+
focus:outline-none focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600"
283
+
placeholder="Enter value..."
284
+
/> */}
285
+
<button
286
+
onClick={() => setValue(init ?? "")}
287
+
className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800
288
+
text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition"
289
+
>
290
+
Reset
291
+
</button>
292
+
</div>
293
+
</div>
294
+
);
295
+
}
296
+
297
+
interface SliderProps {
298
+
atom: typeof hueAtom;
299
+
min?: number;
300
+
max?: number;
301
+
step?: number;
302
+
}
303
+
304
+
export const SliderComponent: React.FC<SliderProps> = ({
305
+
atom,
306
+
min = 0,
307
+
max = 100,
308
+
step = 1,
309
+
}) => {
310
+
const [value, setValue] = useAtom(atom);
311
+
312
+
return (
313
+
<Slider.Root
314
+
className="relative flex items-center w-full h-4"
315
+
value={[value]}
316
+
min={min}
317
+
max={max}
318
+
step={step}
319
+
onValueChange={(v: number[]) => setValue(v[0])}
320
+
>
321
+
<Slider.Track className="relative flex-grow h-4 bg-gray-300 dark:bg-gray-700 rounded-full">
322
+
<Slider.Range className="absolute h-full bg-gray-500 dark:bg-gray-400 rounded-l-full rounded-r-none" />
323
+
</Slider.Track>
324
+
<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" />
325
+
</Slider.Root>
326
+
);
327
+
};
328
+
329
+
330
+
interface SliderPProps {
331
+
value: number;
332
+
min?: number;
333
+
max?: number;
334
+
step?: number;
335
+
}
336
+
337
+
338
+
export const SliderPrimitive: React.FC<SliderPProps> = ({
339
+
value,
340
+
min = 0,
341
+
max = 100,
342
+
step = 1,
343
+
}) => {
344
+
345
+
return (
346
+
<Slider.Root
347
+
className="relative flex items-center w-full h-4"
348
+
value={[value]}
349
+
min={min}
350
+
max={max}
351
+
step={step}
352
+
onValueChange={(v: number[]) => {}}
353
+
>
354
+
<Slider.Track className="relative flex-grow h-4 bg-gray-300 dark:bg-gray-700 rounded-full">
355
+
<Slider.Range className="absolute h-full bg-gray-500 dark:bg-gray-400 rounded-l-full rounded-r-none" />
356
+
</Slider.Track>
357
+
<Slider.Thumb className=" hidden 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" />
358
+
</Slider.Root>
359
+
);
360
+
};
+283
-14
src/styles/app.css
+283
-14
src/styles/app.css
···
15
15
--color-gray-950: oklch(0.129 0.050 222.000);
16
16
} */
17
17
18
+
:root {
19
+
--safe-hue: var(--tw-gray-hue, 28)
20
+
}
21
+
18
22
@theme {
19
-
--color-gray-50: oklch(0.984 0.012 28);
20
-
--color-gray-100: oklch(0.968 0.017 28);
21
-
--color-gray-200: oklch(0.929 0.025 28);
22
-
--color-gray-300: oklch(0.869 0.035 28);
23
-
--color-gray-400: oklch(0.704 0.05 28);
24
-
--color-gray-500: oklch(0.554 0.06 28);
25
-
--color-gray-600: oklch(0.446 0.058 28);
26
-
--color-gray-700: oklch(0.372 0.058 28);
27
-
--color-gray-800: oklch(0.279 0.055 28);
28
-
--color-gray-900: oklch(0.208 0.055 28);
29
-
--color-gray-950: oklch(0.129 0.055 28);
23
+
--color-gray-50: oklch(0.984 0.012 var(--safe-hue));
24
+
--color-gray-100: oklch(0.968 0.017 var(--safe-hue));
25
+
--color-gray-200: oklch(0.929 0.025 var(--safe-hue));
26
+
--color-gray-300: oklch(0.869 0.035 var(--safe-hue));
27
+
--color-gray-400: oklch(0.704 0.05 var(--safe-hue));
28
+
--color-gray-500: oklch(0.554 0.06 var(--safe-hue));
29
+
--color-gray-600: oklch(0.446 0.058 var(--safe-hue));
30
+
--color-gray-700: oklch(0.372 0.058 var(--safe-hue));
31
+
--color-gray-800: oklch(0.279 0.055 var(--safe-hue));
32
+
--color-gray-900: oklch(0.208 0.055 var(--safe-hue));
33
+
--color-gray-950: oklch(0.129 0.055 var(--safe-hue));
34
+
}
35
+
36
+
:root {
37
+
--link-text-color: oklch(0.5962 0.1987 var(--safe-hue));
38
+
/* max chroma!!! use fallback*/
39
+
/*--link-text-color: oklch(0.6 0.37 var(--safe-hue));*/
30
40
}
31
41
32
42
@layer base {
···
48
58
}
49
59
}
50
60
61
+
.gutter{
62
+
scrollbar-gutter: stable both-edges;
63
+
}
64
+
51
65
@media (width >= 64rem /* 1024px */) {
52
66
html:not(:has(.disablegutter)),
53
67
body:not(:has(.disablegutter)) {
54
68
scrollbar-gutter: stable both-edges !important;
55
69
}
56
-
html:has(.disablegutter),
57
-
body:has(.disablegutter) {
70
+
html:has(.disablescroll),
71
+
body:has(.disablescroll) {
58
72
scrollbar-width: none;
59
73
overflow-y: hidden;
60
74
}
···
76
90
.dangerousFediContent {
77
91
& a[href]{
78
92
text-decoration: none;
79
-
color: rgb(29, 122, 242);
93
+
color: var(--link-text-color);
80
94
word-break: break-all;
81
95
}
82
96
}
···
105
119
:root {
106
120
--shadow-opacity: calc(1 - var(--is-top));
107
121
--tw-shadow-header: 0 2px 8px hsl(0 0% 0% / calc(var(--shadow-opacity) * 0.15));
122
+
}
123
+
124
+
125
+
/* m3 input */
126
+
:root {
127
+
--m3input-radius: 6px;
128
+
--m3input-border-width: .0625rem;
129
+
--m3input-font-size: 16px;
130
+
--m3input-transition: 150ms cubic-bezier(.2, .8, .2, 1);
131
+
/* light theme */
132
+
--m3input-bg: var(--color-gray-50);
133
+
--m3input-border-color: var(--color-gray-400);
134
+
--m3input-label-color: var(--color-gray-500);
135
+
--m3input-text-color: var(--color-gray-900);
136
+
--m3input-focus-color: var(--color-gray-600);
137
+
}
138
+
139
+
@media (prefers-color-scheme: dark) {
140
+
:root {
141
+
--m3input-bg: var(--color-gray-950);
142
+
--m3input-border-color: var(--color-gray-700);
143
+
--m3input-label-color: var(--color-gray-400);
144
+
--m3input-text-color: var(--color-gray-50);
145
+
--m3input-focus-color: var(--color-gray-400);
146
+
}
147
+
}
148
+
149
+
/* reset page *//*
150
+
html,
151
+
body {
152
+
background: var(--m3input-bg);
153
+
margin: 0;
154
+
padding: 1rem;
155
+
color: var(--m3input-text-color);
156
+
font-family: system-ui, sans-serif;
157
+
font-size: var(--m3input-font-size);
158
+
}*/
159
+
160
+
/* base wrapper */
161
+
.m3input-field.m3input-label.m3input-border {
162
+
position: relative;
163
+
display: inline-block;
164
+
width: 100%;
165
+
/*max-width: 400px;*/
166
+
}
167
+
168
+
/* size variants */
169
+
.m3input-field.size-sm {
170
+
--m3input-h: 40px;
171
+
}
172
+
173
+
.m3input-field.size-md {
174
+
--m3input-h: 48px;
175
+
}
176
+
177
+
.m3input-field.size-lg {
178
+
--m3input-h: 56px;
179
+
}
180
+
181
+
.m3input-field.size-xl {
182
+
--m3input-h: 64px;
183
+
}
184
+
185
+
.m3input-field.m3input-label.m3input-border:not(.size-sm):not(.size-md):not(.size-lg):not(.size-xl) {
186
+
--m3input-h: 48px;
187
+
}
188
+
189
+
/* outlined input */
190
+
.m3input-field.m3input-label.m3input-border input {
191
+
width: 100%;
192
+
height: var(--m3input-h);
193
+
border: var(--m3input-border-width) solid var(--m3input-border-color);
194
+
border-radius: var(--m3input-radius);
195
+
background: var(--m3input-bg);
196
+
color: var(--m3input-text-color);
197
+
font-size: var(--m3input-font-size);
198
+
padding: 0 12px;
199
+
box-sizing: border-box;
200
+
outline: none;
201
+
transition: border-color var(--m3input-transition), box-shadow var(--m3input-transition);
202
+
}
203
+
204
+
/* focus ring */
205
+
.m3input-field.m3input-label.m3input-border input:focus {
206
+
/*border-color: var(--m3input-focus-color);*/
207
+
border-color: var(--m3input-focus-color);
208
+
box-shadow: 0 0 0 1px var(--m3input-focus-color);
209
+
/*box-shadow: 0 0 0 2px color-mix(in srgb, var(--focus-color) 20%, transparent);*/
210
+
}
211
+
212
+
/* label */
213
+
.m3input-field.m3input-label.m3input-border label {
214
+
position: absolute;
215
+
left: 12px;
216
+
top: 50%;
217
+
transform: translateY(-50%);
218
+
background: var(--m3input-bg);
219
+
padding: 0 .25em;
220
+
color: var(--m3input-label-color);
221
+
pointer-events: none;
222
+
transition: all var(--m3input-transition);
223
+
}
224
+
225
+
/* float on focus or when filled */
226
+
.m3input-field.m3input-label.m3input-border input:focus+label,
227
+
.m3input-field.m3input-label.m3input-border input:not(:placeholder-shown)+label {
228
+
top: 0;
229
+
transform: translateY(-50%) scale(.78);
230
+
left: 0;
231
+
color: var(--m3input-focus-color);
232
+
}
233
+
234
+
/* placeholder trick */
235
+
.m3input-field.m3input-label.m3input-border input::placeholder {
236
+
color: transparent;
237
+
}
238
+
239
+
/* radix i love you but like cmon man */
240
+
body[data-scroll-locked]{
241
+
margin-left: var(--removed-body-scroll-bar-size) !important;
242
+
}
243
+
244
+
/* radix tabs */
245
+
246
+
.m3tab[data-radix-collection-item] {
247
+
flex: 1;
248
+
display: flex;
249
+
padding: 12px 8px;
250
+
align-items: center;
251
+
justify-content: center;
252
+
color: var(--color-gray-500);
253
+
font-weight: 500;
254
+
&:hover {
255
+
background-color: var(--color-gray-100);
256
+
cursor: pointer;
257
+
}
258
+
&[aria-selected="true"] {
259
+
color: var(--color-gray-950);
260
+
&::before{
261
+
content: "";
262
+
position: absolute;
263
+
width: min(80px, 80%);
264
+
border-radius: 99px 99px 0px 0px ;
265
+
height: 3px;
266
+
bottom: 0;
267
+
background-color: var(--color-gray-400);
268
+
}
269
+
}
270
+
}
271
+
272
+
@media (prefers-color-scheme: dark) {
273
+
.m3tab[data-radix-collection-item] {
274
+
color: var(--color-gray-400);
275
+
&:hover {
276
+
background-color: var(--color-gray-900);
277
+
cursor: pointer;
278
+
}
279
+
&[aria-selected="true"] {
280
+
color: var(--color-gray-50);
281
+
&::before{
282
+
background-color: var(--color-gray-500);
283
+
}
284
+
}
285
+
}
286
+
}
287
+
288
+
:root{
289
+
--thumb-size: 2rem;
290
+
--root-size: 3.25rem;
291
+
292
+
--switch-off-border: var(--color-gray-400);
293
+
--switch-off-bg: var(--color-gray-200);
294
+
--switch-off-thumb: var(--color-gray-400);
295
+
296
+
297
+
--switch-on-bg: var(--color-gray-500);
298
+
--switch-on-thumb: var(--color-gray-50);
299
+
300
+
}
301
+
@media (prefers-color-scheme: dark) {
302
+
:root {
303
+
--switch-off-border: var(--color-gray-500);
304
+
--switch-off-bg: var(--color-gray-800);
305
+
--switch-off-thumb: var(--color-gray-500);
306
+
307
+
308
+
--switch-on-bg: var(--color-gray-400);
309
+
--switch-on-thumb: var(--color-gray-700);
310
+
}
311
+
}
312
+
313
+
.m3switch.root{
314
+
/*w-10 h-6 bg-gray-300 rounded-full relative data-[state=checked]:bg-gray-500 transition-colors*/
315
+
/*width: 40px;
316
+
height: 24px;*/
317
+
318
+
inline-size: var(--root-size);
319
+
block-size: 2rem;
320
+
border-radius: 99999px;
321
+
322
+
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to;
323
+
transition-timing-function: var(--default-transition-timing-function); /* cubic-bezier(0.4, 0, 0.2, 1) */
324
+
transition-duration: var(--default-transition-duration); /* 150ms */
325
+
326
+
.m3switch.thumb{
327
+
/*block w-5 h-5 bg-white rounded-full shadow-sm transition-transform translate-x-[2px] data-[state=checked]:translate-x-[20px]*/
328
+
329
+
height: var(--thumb-size);
330
+
width: var(--thumb-size);
331
+
display: inline-block;
332
+
border-radius: 9999px;
333
+
334
+
transform-origin: center;
335
+
336
+
transition-property: transform, translate, scale, rotate;
337
+
transition-timing-function: var(--default-transition-timing-function); /* cubic-bezier(0.4, 0, 0.2, 1) */
338
+
transition-duration: var(--default-transition-duration); /* 150ms */
339
+
340
+
}
341
+
342
+
&[aria-checked="true"] {
343
+
box-shadow: inset 0px 0px 0px 1.8px transparent;
344
+
background-color: var(--switch-on-bg);
345
+
346
+
.m3switch.thumb{
347
+
/*block w-5 h-5 bg-white rounded-full shadow-sm transition-transform translate-x-[2px] data-[state=checked]:translate-x-[20px]*/
348
+
349
+
background-color: var(--switch-on-thumb);
350
+
transform: translate(calc((var(--root-size) / 2) - 50%),0) scale(0.72);
351
+
&:active {
352
+
transform: translate(calc((var(--root-size) / 2) - 50%),0) scale(0.88);
353
+
}
354
+
355
+
}
356
+
&:active .m3switch.thumb{
357
+
transform: translate(calc((var(--root-size) / 2) - 50%),0) scale(0.88);
358
+
}
359
+
}
360
+
361
+
&[aria-checked="false"] {
362
+
box-shadow: inset 0px 0px 0px 1.8px var(--switch-off-border);
363
+
background-color: var(--switch-off-bg);
364
+
.m3switch.thumb{
365
+
/*block w-5 h-5 bg-white rounded-full shadow-sm transition-transform translate-x-[2px] data-[state=checked]:translate-x-[20px]*/
366
+
367
+
background-color: var(--switch-off-thumb);
368
+
transform: translate(calc(-1 * (var(--root-size) / 2) + 50%),0) scale(0.5);
369
+
&:active {
370
+
transform: translate(calc(-1 * (var(--root-size) / 2) + 50%),0) scale(0.88);
371
+
}
372
+
}
373
+
&:active .m3switch.thumb{
374
+
transform: translate(calc(-1 * (var(--root-size) / 2) + 50%),0) scale(0.88);
375
+
}
376
+
}
108
377
}
+136
-8
src/utils/atoms.ts
+136
-8
src/utils/atoms.ts
···
1
-
import type Agent from "@atproto/api";
2
-
import { atom, createStore } from "jotai";
3
-
import { atomWithStorage } from 'jotai/utils';
1
+
import { atom, createStore, useAtomValue } from "jotai";
2
+
import { atomWithStorage } from "jotai/utils";
3
+
import { useEffect } from "react";
4
+
5
+
import { type ProfilePostsFilter } from "~/routes/profile.$did";
4
6
5
7
export const store = createStore();
6
8
9
+
export const quickAuthAtom = atomWithStorage<string | null>(
10
+
"quickAuth",
11
+
null
12
+
);
13
+
7
14
export const selectedFeedUriAtom = atomWithStorage<string | null>(
8
-
'selectedFeedUri',
15
+
"selectedFeedUri",
9
16
null
10
17
);
11
18
12
19
//export const feedScrollPositionsAtom = atom<Record<string, number>>({});
13
20
14
21
export const feedScrollPositionsAtom = atomWithStorage<Record<string, number>>(
15
-
'feedscrollpositions',
22
+
"feedscrollpositions",
16
23
{}
17
24
);
18
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
+
19
59
export const likedPostsAtom = atomWithStorage<Record<string, string>>(
20
-
'likedPosts',
60
+
"likedPosts",
21
61
{}
22
62
);
23
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>>({})
76
+
77
+
export const defaultconstellationURL = "constellation.microcosm.blue";
78
+
export const constellationURLAtom = atomWithStorage<string>(
79
+
"constellationURL",
80
+
defaultconstellationURL
81
+
);
82
+
export const defaultslingshotURL = "slingshot.microcosm.blue";
83
+
export const slingshotURLAtom = atomWithStorage<string>(
84
+
"slingshotURL",
85
+
defaultslingshotURL
86
+
);
87
+
export const defaultImgCDN = "cdn.bsky.app";
88
+
export const imgCDNAtom = atomWithStorage<string>("imgcdnurl", defaultImgCDN);
89
+
export const defaultVideoCDN = "video.bsky.app";
90
+
export const videoCDNAtom = atomWithStorage<string>(
91
+
"videocdnurl",
92
+
defaultVideoCDN
93
+
);
94
+
95
+
export const defaultLycanURL = "";
96
+
export const lycanURLAtom = atomWithStorage<string>(
97
+
"lycanURL",
98
+
defaultLycanURL
99
+
);
100
+
101
+
export const defaulthue = 28;
102
+
export const hueAtom = atomWithStorage<number>("hue", defaulthue);
103
+
24
104
export const isAtTopAtom = atom<boolean>(true);
25
105
26
-
export const agentAtom = atom<Agent|null>(null);
27
-
export const authedAtom = atom<boolean>(false);
106
+
type ComposerState =
107
+
| { kind: "closed" }
108
+
| { kind: "root" }
109
+
| { kind: "reply"; parent: string }
110
+
| { kind: "quote"; subject: string };
111
+
export const composerAtom = atom<ComposerState>({ kind: "closed" });
112
+
113
+
//export const agentAtom = atom<Agent | null>(null);
114
+
//export const authedAtom = atom<boolean>(false);
115
+
116
+
export function useAtomCssVar(atom: typeof hueAtom, cssVar: string) {
117
+
const value = useAtomValue(atom);
118
+
119
+
useEffect(() => {
120
+
document.documentElement.style.setProperty(cssVar, value.toString());
121
+
}, [value, cssVar]);
122
+
123
+
useEffect(() => {
124
+
document.documentElement.style.setProperty(cssVar, value.toString());
125
+
}, []);
126
+
}
127
+
128
+
hueAtom.onMount = (setAtom) => {
129
+
const stored = localStorage.getItem("hue");
130
+
if (stored != null) setAtom(Number(stored));
131
+
};
132
+
// export function initAtomToCssVar(atom: typeof hueAtom, cssVar: string) {
133
+
// const initial = store.get(atom);
134
+
// console.log("atom get ", initial);
135
+
// document.documentElement.style.setProperty(cssVar, initial.toString());
136
+
// }
137
+
138
+
139
+
140
+
// fun stuff
141
+
142
+
export const enableBitesAtom = atomWithStorage<boolean>(
143
+
"enableBitesAtom",
144
+
false
145
+
);
146
+
147
+
export const enableBridgyTextAtom = atomWithStorage<boolean>(
148
+
"enableBridgyTextAtom",
149
+
false
150
+
);
151
+
152
+
export const enableWafrnTextAtom = atomWithStorage<boolean>(
153
+
"enableWafrnTextAtom",
154
+
false
155
+
);
+37
-3
src/utils/followState.ts
+37
-3
src/utils/followState.ts
···
1
-
import { AtUri, type Agent } from "@atproto/api";
2
-
import { useQueryConstellation, type linksRecordsResponse } from "./useQuery";
1
+
import { type Agent,AtUri } from "@atproto/api";
2
+
import { TID } from "@atproto/common-web";
3
3
import type { QueryClient } from "@tanstack/react-query";
4
-
import { TID } from "@atproto/common-web";
4
+
5
+
import { type linksRecordsResponse,useQueryConstellation } from "./useQuery";
5
6
6
7
export function useGetFollowState({
7
8
target,
···
127
128
};
128
129
});
129
130
}
131
+
132
+
133
+
134
+
export function useGetOneToOneState(params?: {
135
+
target: string;
136
+
user: string;
137
+
collection: string;
138
+
path: string;
139
+
}): string[] | undefined {
140
+
const { data: arbitrarydata } = useQueryConstellation(
141
+
params && params.user
142
+
? {
143
+
method: "/links",
144
+
target: params.target,
145
+
// @ts-expect-error overloading sucks so much
146
+
collection: params.collection,
147
+
path: params.path,
148
+
dids: [params.user],
149
+
}
150
+
: { method: "undefined", target: "whatever" }
151
+
// overloading sucks so much
152
+
) as { data: linksRecordsResponse | undefined };
153
+
if (!params || !params.user) return undefined;
154
+
const data = arbitrarydata?.linking_records.slice(0, 50) ?? [];
155
+
156
+
if (data.length > 0) {
157
+
return data.map((linksRecord) => {
158
+
return `at://${linksRecord.did}/${linksRecord.collection}/${linksRecord.rkey}`;
159
+
});
160
+
}
161
+
162
+
return undefined;
163
+
}
+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
+53
-23
src/utils/useHydrated.ts
+53
-23
src/utils/useHydrated.ts
···
9
9
AppBskyFeedPost,
10
10
AtUri,
11
11
} from "@atproto/api";
12
+
import { useAtom } from "jotai";
12
13
import { useMemo } from "react";
13
14
14
-
import { useQueryIdentity,useQueryPost, useQueryProfile } from "./useQuery";
15
+
import { imgCDNAtom, videoCDNAtom } from "./atoms";
16
+
import { useQueryIdentity, useQueryPost, useQueryProfile } from "./useQuery";
15
17
16
-
type QueryResultData<T extends (...args: any) => any> = ReturnType<T> extends
17
-
| { data: infer D }
18
-
| undefined
19
-
? D
20
-
: never;
18
+
type QueryResultData<T extends (...args: any) => any> =
19
+
ReturnType<T> extends { data: infer D } | undefined ? D : never;
21
20
22
21
function asTyped<T extends { $type: string }>(obj: T): $Typed<T> {
23
22
return obj as $Typed<T>;
···
26
25
export function hydrateEmbedImages(
27
26
embed: AppBskyEmbedImages.Main,
28
27
did: string,
28
+
cdn: string
29
29
): $Typed<AppBskyEmbedImages.View> {
30
30
return asTyped({
31
31
$type: "app.bsky.embed.images#view" as const,
···
34
34
const link = img.image.ref?.["$link"];
35
35
if (!link) return null;
36
36
return {
37
-
thumb: `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${link}@jpeg`,
38
-
fullsize: `https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${link}@jpeg`,
37
+
thumb: `https://${cdn}/img/feed_thumbnail/plain/${did}/${link}@jpeg`,
38
+
fullsize: `https://${cdn}/img/feed_fullsize/plain/${did}/${link}@jpeg`,
39
39
alt: img.alt || "",
40
40
aspectRatio: img.aspectRatio,
41
41
};
···
47
47
export function hydrateEmbedExternal(
48
48
embed: AppBskyEmbedExternal.Main,
49
49
did: string,
50
+
cdn: string
50
51
): $Typed<AppBskyEmbedExternal.View> {
51
52
return asTyped({
52
53
$type: "app.bsky.embed.external#view" as const,
···
55
56
title: embed.external.title,
56
57
description: embed.external.description,
57
58
thumb: embed.external.thumb?.ref?.$link
58
-
? `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${embed.external.thumb.ref.$link}@jpeg`
59
+
? `https://${cdn}/img/feed_thumbnail/plain/${did}/${embed.external.thumb.ref.$link}@jpeg`
59
60
: undefined,
60
61
},
61
62
});
···
64
65
export function hydrateEmbedVideo(
65
66
embed: AppBskyEmbedVideo.Main,
66
67
did: string,
68
+
videocdn: string
67
69
): $Typed<AppBskyEmbedVideo.View> {
68
70
const videoLink = embed.video.ref.$link;
69
71
return asTyped({
70
72
$type: "app.bsky.embed.video#view" as const,
71
-
playlist: `https://video.bsky.app/watch/${did}/${videoLink}/playlist.m3u8`,
72
-
thumbnail: `https://video.bsky.app/watch/${did}/${videoLink}/thumbnail.jpg`,
73
+
playlist: `https://${videocdn}/watch/${did}/${videoLink}/playlist.m3u8`,
74
+
thumbnail: `https://${videocdn}/watch/${did}/${videoLink}/thumbnail.jpg`,
73
75
aspectRatio: embed.aspectRatio,
74
76
cid: videoLink,
75
77
});
···
80
82
quotedPost: QueryResultData<typeof useQueryPost>,
81
83
quotedProfile: QueryResultData<typeof useQueryProfile>,
82
84
quotedIdentity: QueryResultData<typeof useQueryIdentity>,
85
+
cdn: string
83
86
): $Typed<AppBskyEmbedRecord.View> | undefined {
84
87
if (!quotedPost || !quotedProfile || !quotedIdentity) {
85
88
return undefined;
···
91
94
handle: quotedIdentity.handle,
92
95
displayName: quotedProfile.value.displayName ?? quotedIdentity.handle,
93
96
avatar: quotedProfile.value.avatar?.ref?.$link
94
-
? `https://cdn.bsky.app/img/avatar/plain/${quotedIdentity.did}/${quotedProfile.value.avatar.ref.$link}@jpeg`
97
+
? `https://${cdn}/img/avatar/plain/${quotedIdentity.did}/${quotedProfile.value.avatar.ref.$link}@jpeg`
95
98
: undefined,
96
99
viewer: {},
97
100
labels: [],
···
122
125
quotedPost: QueryResultData<typeof useQueryPost>,
123
126
quotedProfile: QueryResultData<typeof useQueryProfile>,
124
127
quotedIdentity: QueryResultData<typeof useQueryIdentity>,
128
+
cdn: string
125
129
): $Typed<AppBskyEmbedRecordWithMedia.View> | undefined {
126
130
const hydratedRecord = hydrateEmbedRecord(
127
131
embed.record,
128
132
quotedPost,
129
133
quotedProfile,
130
134
quotedIdentity,
135
+
cdn
131
136
);
132
137
133
138
if (!hydratedRecord) return undefined;
···
148
153
149
154
export function useHydratedEmbed(
150
155
embed: AppBskyFeedPost.Record["embed"],
151
-
postAuthorDid: string | undefined,
156
+
postAuthorDid: string | undefined
152
157
) {
153
158
const recordInfo = useMemo(() => {
154
159
if (AppBskyEmbedRecordWithMedia.isMain(embed)) {
···
181
186
error: profileError,
182
187
} = useQueryProfile(profileUri);
183
188
189
+
const [imgcdn] = useAtom(imgCDNAtom);
190
+
const [videocdn] = useAtom(videoCDNAtom);
191
+
184
192
const queryidentityresult = useQueryIdentity(quotedAuthorDid);
185
193
186
194
const hydratedEmbed: HydratedEmbedView | undefined = (() => {
187
195
if (!embed || !postAuthorDid) return undefined;
188
196
189
-
if (isRecordType && (!usequerypostresults?.data || !quotedProfile || !queryidentityresult?.data)) {
197
+
if (
198
+
isRecordType &&
199
+
(!usequerypostresults?.data ||
200
+
!quotedProfile ||
201
+
!queryidentityresult?.data)
202
+
) {
190
203
return undefined;
191
204
}
192
205
193
206
try {
194
207
if (AppBskyEmbedImages.isMain(embed)) {
195
-
return hydrateEmbedImages(embed, postAuthorDid);
208
+
return hydrateEmbedImages(embed, postAuthorDid, imgcdn);
196
209
} else if (AppBskyEmbedExternal.isMain(embed)) {
197
-
return hydrateEmbedExternal(embed, postAuthorDid);
210
+
return hydrateEmbedExternal(embed, postAuthorDid, imgcdn);
198
211
} else if (AppBskyEmbedVideo.isMain(embed)) {
199
-
return hydrateEmbedVideo(embed, postAuthorDid);
212
+
return hydrateEmbedVideo(embed, postAuthorDid, videocdn);
200
213
} else if (AppBskyEmbedRecord.isMain(embed)) {
201
214
return hydrateEmbedRecord(
202
215
embed,
203
216
usequerypostresults?.data,
204
217
quotedProfile,
205
218
queryidentityresult?.data,
219
+
imgcdn
206
220
);
207
221
} else if (AppBskyEmbedRecordWithMedia.isMain(embed)) {
208
222
let hydratedMedia:
···
212
226
| undefined;
213
227
214
228
if (AppBskyEmbedImages.isMain(embed.media)) {
215
-
hydratedMedia = hydrateEmbedImages(embed.media, postAuthorDid);
229
+
hydratedMedia = hydrateEmbedImages(
230
+
embed.media,
231
+
postAuthorDid,
232
+
imgcdn
233
+
);
216
234
} else if (AppBskyEmbedExternal.isMain(embed.media)) {
217
-
hydratedMedia = hydrateEmbedExternal(embed.media, postAuthorDid);
235
+
hydratedMedia = hydrateEmbedExternal(
236
+
embed.media,
237
+
postAuthorDid,
238
+
imgcdn
239
+
);
218
240
} else if (AppBskyEmbedVideo.isMain(embed.media)) {
219
-
hydratedMedia = hydrateEmbedVideo(embed.media, postAuthorDid);
241
+
hydratedMedia = hydrateEmbedVideo(
242
+
embed.media,
243
+
postAuthorDid,
244
+
videocdn
245
+
);
220
246
}
221
247
222
248
if (hydratedMedia) {
···
226
252
usequerypostresults?.data,
227
253
quotedProfile,
228
254
queryidentityresult?.data,
255
+
imgcdn
229
256
);
230
257
}
231
258
}
···
236
263
})();
237
264
238
265
const isLoading = isRecordType
239
-
? usequerypostresults?.isLoading || isLoadingProfile || queryidentityresult?.isLoading
266
+
? usequerypostresults?.isLoading ||
267
+
isLoadingProfile ||
268
+
queryidentityresult?.isLoading
240
269
: false;
241
270
242
-
const error = usequerypostresults?.error || profileError || queryidentityresult?.error;
271
+
const error =
272
+
usequerypostresults?.error || profileError || queryidentityresult?.error;
243
273
244
274
return { data: hydratedEmbed, isLoading, error };
245
-
}
275
+
}
+452
-137
src/utils/useQuery.ts
+452
-137
src/utils/useQuery.ts
···
1
1
import * as ATPAPI from "@atproto/api";
2
2
import {
3
+
infiniteQueryOptions,
3
4
type QueryFunctionContext,
4
5
queryOptions,
5
6
useInfiniteQuery,
6
7
useQuery,
7
-
type UseQueryResult} from "@tanstack/react-query";
8
+
type UseQueryResult,
9
+
} from "@tanstack/react-query";
10
+
import { useAtom } from "jotai";
8
11
9
-
export function constructIdentityQuery(didorhandle?: string) {
12
+
import { useAuth } from "~/providers/UnifiedAuthProvider";
13
+
14
+
import { constellationURLAtom, lycanURLAtom, slingshotURLAtom } from "./atoms";
15
+
16
+
export function constructIdentityQuery(
17
+
didorhandle?: string,
18
+
slingshoturl?: string
19
+
) {
10
20
return queryOptions({
11
21
queryKey: ["identity", didorhandle],
12
22
queryFn: async () => {
13
-
if (!didorhandle) return undefined as undefined
23
+
if (!didorhandle) return undefined as undefined;
14
24
const res = await fetch(
15
-
`https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(didorhandle)}`
25
+
`https://${slingshoturl}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(didorhandle)}`
16
26
);
17
27
if (!res.ok) throw new Error("Failed to fetch post");
18
28
try {
···
27
37
}
28
38
},
29
39
staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
30
-
gcTime: /*0//*/5 * 60 * 1000,
40
+
gcTime: /*0//*/ 5 * 60 * 1000,
31
41
});
32
42
}
33
43
export function useQueryIdentity(didorhandle: string): UseQueryResult<
···
39
49
},
40
50
Error
41
51
>;
42
-
export function useQueryIdentity(): UseQueryResult<
43
-
undefined,
44
-
Error
45
-
>
46
-
export function useQueryIdentity(didorhandle?: string):
47
-
UseQueryResult<
48
-
{
49
-
did: string;
50
-
handle: string;
51
-
pds: string;
52
-
signing_key: string;
53
-
} | undefined,
54
-
Error
55
-
>
52
+
export function useQueryIdentity(): UseQueryResult<undefined, Error>;
53
+
export function useQueryIdentity(didorhandle?: string): UseQueryResult<
54
+
| {
55
+
did: string;
56
+
handle: string;
57
+
pds: string;
58
+
signing_key: string;
59
+
}
60
+
| undefined,
61
+
Error
62
+
>;
56
63
export function useQueryIdentity(didorhandle?: string) {
57
-
return useQuery(constructIdentityQuery(didorhandle));
64
+
const [slingshoturl] = useAtom(slingshotURLAtom);
65
+
return useQuery(constructIdentityQuery(didorhandle, slingshoturl));
58
66
}
59
67
60
-
export function constructPostQuery(uri?: string) {
68
+
export function constructPostQuery(uri?: string, slingshoturl?: string) {
61
69
return queryOptions({
62
70
queryKey: ["post", uri],
63
71
queryFn: async () => {
64
-
if (!uri) return undefined as undefined
72
+
if (!uri) return undefined as undefined;
65
73
const res = await fetch(
66
-
`https://slingshot.microcosm.blue/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
74
+
`https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
67
75
);
68
76
let data: any;
69
77
try {
···
72
80
return undefined;
73
81
}
74
82
if (res.status === 400) return undefined;
75
-
if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) {
83
+
if (
84
+
data?.error === "InvalidRequest" &&
85
+
data.message?.includes("Could not find repo")
86
+
) {
76
87
return undefined; // cache โnot foundโ
77
88
}
78
89
try {
79
90
if (!res.ok) throw new Error("Failed to fetch post");
80
-
return (data) as {
91
+
return data as {
81
92
uri: string;
82
93
cid: string;
83
94
value: any;
···
92
103
return failureCount < 2;
93
104
},
94
105
staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
95
-
gcTime: /*0//*/5 * 60 * 1000,
106
+
gcTime: /*0//*/ 5 * 60 * 1000,
96
107
});
97
108
}
98
109
export function useQueryPost(uri: string): UseQueryResult<
···
103
114
},
104
115
Error
105
116
>;
106
-
export function useQueryPost(): UseQueryResult<
107
-
undefined,
108
-
Error
109
-
>
110
-
export function useQueryPost(uri?: string):
111
-
UseQueryResult<
112
-
{
113
-
uri: string;
114
-
cid: string;
115
-
value: ATPAPI.AppBskyFeedPost.Record;
116
-
} | undefined,
117
-
Error
118
-
>
117
+
export function useQueryPost(): UseQueryResult<undefined, Error>;
118
+
export function useQueryPost(uri?: string): UseQueryResult<
119
+
| {
120
+
uri: string;
121
+
cid: string;
122
+
value: ATPAPI.AppBskyFeedPost.Record;
123
+
}
124
+
| undefined,
125
+
Error
126
+
>;
119
127
export function useQueryPost(uri?: string) {
120
-
return useQuery(constructPostQuery(uri));
128
+
const [slingshoturl] = useAtom(slingshotURLAtom);
129
+
return useQuery(constructPostQuery(uri, slingshoturl));
121
130
}
122
131
123
-
export function constructProfileQuery(uri?: string) {
132
+
export function constructProfileQuery(uri?: string, slingshoturl?: string) {
124
133
return queryOptions({
125
134
queryKey: ["profile", uri],
126
135
queryFn: async () => {
127
-
if (!uri) return undefined as undefined
136
+
if (!uri) return undefined as undefined;
128
137
const res = await fetch(
129
-
`https://slingshot.microcosm.blue/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
138
+
`https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
130
139
);
131
140
let data: any;
132
141
try {
···
135
144
return undefined;
136
145
}
137
146
if (res.status === 400) return undefined;
138
-
if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) {
147
+
if (
148
+
data?.error === "InvalidRequest" &&
149
+
data.message?.includes("Could not find repo")
150
+
) {
139
151
return undefined; // cache โnot foundโ
140
152
}
141
153
try {
142
154
if (!res.ok) throw new Error("Failed to fetch post");
143
-
return (data) as {
155
+
return data as {
144
156
uri: string;
145
157
cid: string;
146
158
value: any;
···
155
167
return failureCount < 2;
156
168
},
157
169
staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
158
-
gcTime: /*0//*/5 * 60 * 1000,
170
+
gcTime: /*0//*/ 5 * 60 * 1000,
159
171
});
160
172
}
161
173
export function useQueryProfile(uri: string): UseQueryResult<
···
166
178
},
167
179
Error
168
180
>;
169
-
export function useQueryProfile(): UseQueryResult<
170
-
undefined,
171
-
Error
172
-
>;
173
-
export function useQueryProfile(uri?: string):
174
-
UseQueryResult<
175
-
{
181
+
export function useQueryProfile(): UseQueryResult<undefined, Error>;
182
+
export function useQueryProfile(uri?: string): UseQueryResult<
183
+
| {
176
184
uri: string;
177
185
cid: string;
178
186
value: ATPAPI.AppBskyActorProfile.Record;
179
-
} | undefined,
180
-
Error
181
-
>
187
+
}
188
+
| undefined,
189
+
Error
190
+
>;
182
191
export function useQueryProfile(uri?: string) {
183
-
return useQuery(constructProfileQuery(uri));
192
+
const [slingshoturl] = useAtom(slingshotURLAtom);
193
+
return useQuery(constructProfileQuery(uri, slingshoturl));
184
194
}
185
195
186
196
// export function constructConstellationQuery(
···
215
225
// method: "/links/all",
216
226
// target: string
217
227
// ): QueryOptions<linksAllResponse, Error>;
218
-
export function constructConstellationQuery(query?:{
228
+
export function constructConstellationQuery(query?: {
229
+
constellation: string;
219
230
method:
220
231
| "/links"
221
232
| "/links/distinct-dids"
222
233
| "/links/count"
223
234
| "/links/count/distinct-dids"
224
235
| "/links/all"
225
-
| "undefined",
226
-
target: string,
227
-
collection?: string,
228
-
path?: string,
229
-
cursor?: string,
230
-
dids?: string[]
231
-
}
232
-
) {
236
+
| "undefined";
237
+
target: string;
238
+
collection?: string;
239
+
path?: string;
240
+
cursor?: string;
241
+
dids?: string[];
242
+
}) {
233
243
// : QueryOptions<
234
244
// | linksRecordsResponse
235
245
// | linksDidsResponse
···
239
249
// Error
240
250
// >
241
251
return queryOptions({
242
-
queryKey: ["constellation", query?.method, query?.target, query?.collection, query?.path, query?.cursor, query?.dids] as const,
252
+
queryKey: [
253
+
"constellation",
254
+
query?.method,
255
+
query?.target,
256
+
query?.collection,
257
+
query?.path,
258
+
query?.cursor,
259
+
query?.dids,
260
+
] as const,
243
261
queryFn: async () => {
244
-
if (!query || query.method === "undefined") return undefined as undefined
245
-
const method = query.method
246
-
const target = query.target
247
-
const collection = query?.collection
248
-
const path = query?.path
249
-
const cursor = query.cursor
250
-
const dids = query?.dids
262
+
if (!query || query.method === "undefined") return undefined as undefined;
263
+
const method = query.method;
264
+
const target = query.target;
265
+
const collection = query?.collection;
266
+
const path = query?.path;
267
+
const cursor = query.cursor;
268
+
const dids = query?.dids;
251
269
const res = await fetch(
252
-
`https://constellation.microcosm.blue${method}?target=${encodeURIComponent(target)}${collection ? `&collection=${encodeURIComponent(collection)}` : ""}${path ? `&path=${encodeURIComponent(path)}` : ""}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}${dids ? dids.map((did) => `&did=${encodeURIComponent(did)}`).join("") : ""}`
270
+
`https://${query.constellation}${method}?target=${encodeURIComponent(target)}${collection ? `&collection=${encodeURIComponent(collection)}` : ""}${path ? `&path=${encodeURIComponent(path)}` : ""}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}${dids ? dids.map((did) => `&did=${encodeURIComponent(did)}`).join("") : ""}`
253
271
);
254
272
if (!res.ok) throw new Error("Failed to fetch post");
255
273
try {
···
273
291
},
274
292
// enforce short lifespan
275
293
staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
276
-
gcTime: /*0//*/5 * 60 * 1000,
294
+
gcTime: /*0//*/ 5 * 60 * 1000,
277
295
});
278
296
}
297
+
// todo do more of these instead of overloads since overloads sucks so much apparently
298
+
export function useQueryConstellationLinksCountDistinctDids(query?: {
299
+
method: "/links/count/distinct-dids";
300
+
target: string;
301
+
collection: string;
302
+
path: string;
303
+
cursor?: string;
304
+
}): UseQueryResult<linksCountResponse, Error> | undefined {
305
+
//if (!query) return;
306
+
const [constellationurl] = useAtom(constellationURLAtom);
307
+
const queryres = useQuery(
308
+
constructConstellationQuery(
309
+
query && { constellation: constellationurl, ...query }
310
+
)
311
+
) as unknown as UseQueryResult<linksCountResponse, Error>;
312
+
if (!query) {
313
+
return undefined as undefined;
314
+
}
315
+
return queryres as UseQueryResult<linksCountResponse, Error>;
316
+
}
317
+
279
318
export function useQueryConstellation(query: {
280
319
method: "/links";
281
320
target: string;
···
338
377
>
339
378
| undefined {
340
379
//if (!query) return;
380
+
const [constellationurl] = useAtom(constellationURLAtom);
341
381
return useQuery(
342
-
constructConstellationQuery(query)
382
+
constructConstellationQuery(
383
+
query && { constellation: constellationurl, ...query }
384
+
)
343
385
);
344
386
}
345
387
346
-
type linksRecord = {
388
+
export type linksRecord = {
347
389
did: string;
348
390
collection: string;
349
391
rkey: string;
···
361
403
type linksCountResponse = {
362
404
total: string;
363
405
};
364
-
type linksAllResponse = {
406
+
export type linksAllResponse = {
365
407
links: Record<
366
408
string,
367
409
Record<
···
383
425
}) {
384
426
return queryOptions({
385
427
// The query key includes all dependencies to ensure it refetches when they change
386
-
queryKey: ["feedSkeleton", options?.feedUri, { isAuthed: options?.isAuthed, did: options?.agent?.did }],
428
+
queryKey: [
429
+
"feedSkeleton",
430
+
options?.feedUri,
431
+
{ isAuthed: options?.isAuthed, did: options?.agent?.did },
432
+
],
387
433
queryFn: async () => {
388
-
if (!options) return undefined as undefined
434
+
if (!options) return undefined as undefined;
389
435
const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid } = options;
390
436
if (isAuthed) {
391
437
// Authenticated flow
392
438
if (!agent || !pdsUrl || !feedServiceDid) {
393
-
throw new Error("Missing required info for authenticated feed fetch.");
439
+
throw new Error(
440
+
"Missing required info for authenticated feed fetch."
441
+
);
394
442
}
395
443
const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`;
396
444
const res = await agent.fetchHandler(url, {
···
400
448
"Content-Type": "application/json",
401
449
},
402
450
});
403
-
if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`);
451
+
if (!res.ok)
452
+
throw new Error(`Authenticated feed fetch failed: ${res.statusText}`);
404
453
return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema;
405
454
} else {
406
455
// Unauthenticated flow (using a public PDS/AppView)
407
456
const url = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`;
408
457
const res = await fetch(url);
409
-
if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`);
458
+
if (!res.ok)
459
+
throw new Error(`Public feed fetch failed: ${res.statusText}`);
410
460
return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema;
411
461
}
412
462
},
···
424
474
return useQuery(constructFeedSkeletonQuery(options));
425
475
}
426
476
427
-
export function constructPreferencesQuery(agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined) {
477
+
export function constructPreferencesQuery(
478
+
agent?: ATPAPI.Agent | undefined,
479
+
pdsUrl?: string | undefined
480
+
) {
428
481
return queryOptions({
429
-
queryKey: ['preferences', agent?.did],
482
+
queryKey: ["preferences", agent?.did],
430
483
queryFn: async () => {
431
484
if (!agent || !pdsUrl) throw new Error("Agent or PDS URL not available");
432
485
const url = `${pdsUrl}/xrpc/app.bsky.actor.getPreferences`;
···
437
490
});
438
491
}
439
492
export function useQueryPreferences(options: {
440
-
agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined
493
+
agent?: ATPAPI.Agent | undefined;
494
+
pdsUrl?: string | undefined;
441
495
}) {
442
496
return useQuery(constructPreferencesQuery(options.agent, options.pdsUrl));
443
497
}
444
498
445
-
446
-
447
-
export function constructArbitraryQuery(uri?: string) {
499
+
export function constructArbitraryQuery(uri?: string, slingshoturl?: string) {
448
500
return queryOptions({
449
501
queryKey: ["arbitrary", uri],
450
502
queryFn: async () => {
451
-
if (!uri) return undefined as undefined
503
+
if (!uri) return undefined as undefined;
452
504
const res = await fetch(
453
-
`https://slingshot.microcosm.blue/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
505
+
`https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
454
506
);
455
507
let data: any;
456
508
try {
···
459
511
return undefined;
460
512
}
461
513
if (res.status === 400) return undefined;
462
-
if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) {
514
+
if (
515
+
data?.error === "InvalidRequest" &&
516
+
data.message?.includes("Could not find repo")
517
+
) {
463
518
return undefined; // cache โnot foundโ
464
519
}
465
520
try {
466
521
if (!res.ok) throw new Error("Failed to fetch post");
467
-
return (data) as {
522
+
return data as {
468
523
uri: string;
469
524
cid: string;
470
525
value: any;
···
479
534
return failureCount < 2;
480
535
},
481
536
staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
482
-
gcTime: /*0//*/5 * 60 * 1000,
537
+
gcTime: /*0//*/ 5 * 60 * 1000,
483
538
});
484
539
}
485
540
export function useQueryArbitrary(uri: string): UseQueryResult<
···
490
545
},
491
546
Error
492
547
>;
493
-
export function useQueryArbitrary(): UseQueryResult<
494
-
undefined,
495
-
Error
496
-
>;
548
+
export function useQueryArbitrary(): UseQueryResult<undefined, Error>;
497
549
export function useQueryArbitrary(uri?: string): UseQueryResult<
498
-
{
499
-
uri: string;
500
-
cid: string;
501
-
value: any;
502
-
} | undefined,
550
+
| {
551
+
uri: string;
552
+
cid: string;
553
+
value: any;
554
+
}
555
+
| undefined,
503
556
Error
504
557
>;
505
558
export function useQueryArbitrary(uri?: string) {
506
-
return useQuery(constructArbitraryQuery(uri));
559
+
const [slingshoturl] = useAtom(slingshotURLAtom);
560
+
return useQuery(constructArbitraryQuery(uri, slingshoturl));
507
561
}
508
562
509
-
export function constructFallbackNothingQuery(){
563
+
export function constructFallbackNothingQuery() {
510
564
return queryOptions({
511
565
queryKey: ["nothing"],
512
566
queryFn: async () => {
513
-
return undefined
567
+
return undefined;
514
568
},
515
569
});
516
570
}
···
524
578
}[];
525
579
};
526
580
527
-
export function constructAuthorFeedQuery(did: string, pdsUrl: string) {
581
+
export function constructAuthorFeedQuery(
582
+
did: string,
583
+
pdsUrl: string,
584
+
collection: string = "app.bsky.feed.post"
585
+
) {
528
586
return queryOptions({
529
-
queryKey: ['authorFeed', did],
587
+
queryKey: ["authorFeed", did, collection],
530
588
queryFn: async ({ pageParam }: QueryFunctionContext) => {
531
589
const limit = 25;
532
-
590
+
533
591
const cursor = pageParam as string | undefined;
534
-
const cursorParam = cursor ? `&cursor=${cursor}` : '';
535
-
536
-
const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post&limit=${limit}${cursorParam}`;
537
-
592
+
const cursorParam = cursor ? `&cursor=${cursor}` : "";
593
+
594
+
const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${collection}&limit=${limit}${cursorParam}`;
595
+
538
596
const res = await fetch(url);
539
597
if (!res.ok) throw new Error("Failed to fetch author's posts");
540
-
598
+
541
599
return res.json() as Promise<ListRecordsResponse>;
542
600
},
543
601
});
544
602
}
545
603
546
-
export function useInfiniteQueryAuthorFeed(did: string | undefined, pdsUrl: string | undefined) {
547
-
const { queryKey, queryFn } = constructAuthorFeedQuery(did!, pdsUrl!);
548
-
604
+
export function useInfiniteQueryAuthorFeed(
605
+
did: string | undefined,
606
+
pdsUrl: string | undefined,
607
+
collection?: string
608
+
) {
609
+
const { queryKey, queryFn } = constructAuthorFeedQuery(
610
+
did!,
611
+
pdsUrl!,
612
+
collection
613
+
);
614
+
549
615
return useInfiniteQuery({
550
616
queryKey,
551
617
queryFn,
···
563
629
isAuthed: boolean;
564
630
pdsUrl?: string;
565
631
feedServiceDid?: string;
632
+
// todo the hell is a unauthedfeedurl
633
+
unauthedfeedurl?: string;
566
634
}) {
567
-
const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid } = options;
568
-
635
+
const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid, unauthedfeedurl } =
636
+
options;
637
+
569
638
return queryOptions({
570
639
queryKey: ["feedSkeleton", feedUri, { isAuthed, did: agent?.did }],
571
-
572
-
queryFn: async ({ pageParam }: QueryFunctionContext): Promise<FeedSkeletonPage> => {
640
+
641
+
queryFn: async ({
642
+
pageParam,
643
+
}: QueryFunctionContext): Promise<FeedSkeletonPage> => {
573
644
const cursorParam = pageParam ? `&cursor=${pageParam}` : "";
574
-
575
-
if (isAuthed) {
645
+
646
+
if (isAuthed && !unauthedfeedurl) {
576
647
if (!agent || !pdsUrl || !feedServiceDid) {
577
-
throw new Error("Missing required info for authenticated feed fetch.");
648
+
throw new Error(
649
+
"Missing required info for authenticated feed fetch."
650
+
);
578
651
}
579
652
const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`;
580
653
const res = await agent.fetchHandler(url, {
···
584
657
"Content-Type": "application/json",
585
658
},
586
659
});
587
-
if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`);
660
+
if (!res.ok)
661
+
throw new Error(`Authenticated feed fetch failed: ${res.statusText}`);
588
662
return (await res.json()) as FeedSkeletonPage;
589
663
} else {
590
-
const url = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`;
664
+
const url = `https://${unauthedfeedurl ? unauthedfeedurl : "discover.bsky.app"}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`;
591
665
const res = await fetch(url);
592
-
if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`);
666
+
if (!res.ok)
667
+
throw new Error(`Public feed fetch failed: ${res.statusText}`);
593
668
return (await res.json()) as FeedSkeletonPage;
594
669
}
595
670
},
···
602
677
isAuthed: boolean;
603
678
pdsUrl?: string;
604
679
feedServiceDid?: string;
680
+
unauthedfeedurl?: string;
605
681
}) {
606
682
const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery(options);
607
-
608
-
return useInfiniteQuery({
609
-
queryKey,
610
-
queryFn,
683
+
684
+
return {
685
+
...useInfiniteQuery({
686
+
queryKey,
687
+
queryFn,
688
+
initialPageParam: undefined as never,
689
+
getNextPageParam: (lastPage) => lastPage.cursor as null | undefined,
690
+
staleTime: Infinity,
691
+
refetchOnWindowFocus: false,
692
+
enabled:
693
+
!!options.feedUri &&
694
+
(options.isAuthed
695
+
? ((!!options.agent && !!options.pdsUrl) ||
696
+
!!options.unauthedfeedurl) &&
697
+
!!options.feedServiceDid
698
+
: true),
699
+
}),
700
+
queryKey: queryKey,
701
+
};
702
+
}
703
+
704
+
export function yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(query?: {
705
+
constellation: string;
706
+
method: "/links";
707
+
target?: string;
708
+
collection: string;
709
+
path: string;
710
+
staleMult?: number;
711
+
}) {
712
+
const safemult = query?.staleMult ?? 1;
713
+
// console.log(
714
+
// 'yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks',
715
+
// query,
716
+
// )
717
+
718
+
return infiniteQueryOptions({
719
+
enabled: !!query?.target,
720
+
queryKey: [
721
+
"reddwarf_constellation",
722
+
query?.method,
723
+
query?.target,
724
+
query?.collection,
725
+
query?.path,
726
+
] as const,
727
+
728
+
queryFn: async ({ pageParam }: { pageParam?: string }) => {
729
+
if (!query || !query?.target) return undefined;
730
+
731
+
const method = query.method;
732
+
const target = query.target;
733
+
const collection = query.collection;
734
+
const path = query.path;
735
+
const cursor = pageParam;
736
+
737
+
const res = await fetch(
738
+
`https://${query.constellation}${method}?target=${encodeURIComponent(target)}${
739
+
collection ? `&collection=${encodeURIComponent(collection)}` : ""
740
+
}${path ? `&path=${encodeURIComponent(path)}` : ""}${
741
+
cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""
742
+
}`
743
+
);
744
+
745
+
if (!res.ok) throw new Error("Failed to fetch");
746
+
747
+
return (await res.json()) as linksRecordsResponse;
748
+
},
749
+
750
+
getNextPageParam: (lastPage) => {
751
+
return (lastPage as any)?.cursor ?? undefined;
752
+
},
753
+
initialPageParam: undefined,
754
+
staleTime: 5 * 60 * 1000 * safemult,
755
+
gcTime: 5 * 60 * 1000 * safemult,
756
+
});
757
+
}
758
+
759
+
export function useQueryLycanStatus() {
760
+
const [lycanurl] = useAtom(lycanURLAtom);
761
+
const { agent, status } = useAuth();
762
+
const { data: identity } = useQueryIdentity(agent?.did);
763
+
return useQuery(
764
+
constructLycanStatusCheckQuery({
765
+
agent: agent || undefined,
766
+
isAuthed: status === "signedIn",
767
+
pdsUrl: identity?.pds,
768
+
feedServiceDid: "did:web:"+lycanurl,
769
+
})
770
+
);
771
+
}
772
+
773
+
export function constructLycanStatusCheckQuery(options: {
774
+
agent?: ATPAPI.Agent;
775
+
isAuthed: boolean;
776
+
pdsUrl?: string;
777
+
feedServiceDid?: string;
778
+
}) {
779
+
const { agent, isAuthed, pdsUrl, feedServiceDid } = options;
780
+
781
+
return queryOptions({
782
+
queryKey: ["lycanStatus", { isAuthed, did: agent?.did }],
783
+
784
+
queryFn: async () => {
785
+
if (isAuthed && agent && pdsUrl && feedServiceDid) {
786
+
const url = `${pdsUrl}/xrpc/blue.feeds.lycan.getImportStatus`;
787
+
const res = await agent.fetchHandler(url, {
788
+
method: "GET",
789
+
headers: {
790
+
"atproto-proxy": `${feedServiceDid}#lycan`,
791
+
"Content-Type": "application/json",
792
+
},
793
+
});
794
+
if (!res.ok)
795
+
throw new Error(
796
+
`Authenticated lycan status fetch failed: ${res.statusText}`
797
+
);
798
+
return (await res.json()) as statuschek;
799
+
}
800
+
return undefined;
801
+
},
802
+
});
803
+
}
804
+
805
+
type statuschek = {
806
+
[key: string]: unknown;
807
+
error?: "MethodNotImplemented";
808
+
message?: "Method Not Implemented";
809
+
status?: "finished" | "in_progress";
810
+
position?: string,
811
+
progress?: number,
812
+
813
+
};
814
+
815
+
//{"status":"in_progress","position":"2025-08-30T06:53:18Z","progress":0.0878319661441268}
816
+
type importtype = {
817
+
message?: "Import has already started" | "Import has been scheduled"
818
+
}
819
+
820
+
export function constructLycanRequestIndexQuery(options: {
821
+
agent?: ATPAPI.Agent;
822
+
isAuthed: boolean;
823
+
pdsUrl?: string;
824
+
feedServiceDid?: string;
825
+
}) {
826
+
const { agent, isAuthed, pdsUrl, feedServiceDid } = options;
827
+
828
+
return queryOptions({
829
+
queryKey: ["lycanIndex", { isAuthed, did: agent?.did }],
830
+
831
+
queryFn: async () => {
832
+
if (isAuthed && agent && pdsUrl && feedServiceDid) {
833
+
const url = `${pdsUrl}/xrpc/blue.feeds.lycan.startImport`;
834
+
const res = await agent.fetchHandler(url, {
835
+
method: "POST",
836
+
headers: {
837
+
"atproto-proxy": `${feedServiceDid}#lycan`,
838
+
"Content-Type": "application/json",
839
+
},
840
+
});
841
+
if (!res.ok)
842
+
throw new Error(
843
+
`Authenticated lycan status fetch failed: ${res.statusText}`
844
+
);
845
+
return await res.json() as importtype;
846
+
}
847
+
return undefined;
848
+
},
849
+
});
850
+
}
851
+
852
+
type LycanSearchPage = {
853
+
terms: string[];
854
+
posts: string[];
855
+
cursor?: string;
856
+
};
857
+
858
+
859
+
export function useInfiniteQueryLycanSearch(options: { query: string, type: "likes" | "pins" | "reposts" | "quotes"}) {
860
+
861
+
862
+
const [lycanurl] = useAtom(lycanURLAtom);
863
+
const { agent, status } = useAuth();
864
+
const { data: identity } = useQueryIdentity(agent?.did);
865
+
866
+
const { queryKey, queryFn } = constructLycanSearchQuery({
867
+
agent: agent || undefined,
868
+
isAuthed: status === "signedIn",
869
+
pdsUrl: identity?.pds,
870
+
feedServiceDid: "did:web:"+lycanurl,
871
+
query: options.query,
872
+
type: options.type,
873
+
})
874
+
875
+
return {
876
+
...useInfiniteQuery({
877
+
queryKey,
878
+
queryFn,
879
+
initialPageParam: undefined as never,
880
+
getNextPageParam: (lastPage) => lastPage?.cursor as null | undefined,
881
+
//staleTime: Infinity,
882
+
refetchOnWindowFocus: false,
883
+
// enabled:
884
+
// !!options.feedUri &&
885
+
// (options.isAuthed
886
+
// ? ((!!options.agent && !!options.pdsUrl) ||
887
+
// !!options.unauthedfeedurl) &&
888
+
// !!options.feedServiceDid
889
+
// : true),
890
+
}),
891
+
queryKey: queryKey,
892
+
};
893
+
}
894
+
895
+
896
+
export function constructLycanSearchQuery(options: {
897
+
agent?: ATPAPI.Agent;
898
+
isAuthed: boolean;
899
+
pdsUrl?: string;
900
+
feedServiceDid?: string;
901
+
type: "likes" | "pins" | "reposts" | "quotes";
902
+
query: string;
903
+
}) {
904
+
const { agent, isAuthed, pdsUrl, feedServiceDid, type, query } = options;
905
+
906
+
return infiniteQueryOptions({
907
+
queryKey: ["lycanSearch", query, type, { isAuthed, did: agent?.did }],
908
+
909
+
queryFn: async ({
910
+
pageParam,
911
+
}: QueryFunctionContext): Promise<LycanSearchPage | undefined> => {
912
+
if (isAuthed && agent && pdsUrl && feedServiceDid) {
913
+
const url = `${pdsUrl}/xrpc/blue.feeds.lycan.searchPosts?query=${query}&collection=${type}${pageParam ? `&cursor=${pageParam}` : ""}`;
914
+
const res = await agent.fetchHandler(url, {
915
+
method: "GET",
916
+
headers: {
917
+
"atproto-proxy": `${feedServiceDid}#lycan`,
918
+
"Content-Type": "application/json",
919
+
},
920
+
});
921
+
if (!res.ok)
922
+
throw new Error(
923
+
`Authenticated lycan status fetch failed: ${res.statusText}`
924
+
);
925
+
return (await res.json()) as LycanSearchPage;
926
+
}
927
+
return undefined;
928
+
},
611
929
initialPageParam: undefined as never,
612
-
getNextPageParam: (lastPage) => lastPage.cursor as null | undefined,
613
-
staleTime: Infinity,
614
-
refetchOnWindowFocus: false,
615
-
enabled: !!options.feedUri && (options.isAuthed ? !!options.agent && !!options.pdsUrl && !!options.feedServiceDid : true),
930
+
getNextPageParam: (lastPage) => lastPage?.cursor as null | undefined,
616
931
});
617
-
}
932
+
}
+6
-1
vite.config.ts
+6
-1
vite.config.ts
···
10
10
11
11
import { generateMetadataPlugin } from "./oauthdev.mts";
12
12
13
-
const PROD_URL = "https://reddwarf.whey.party"
13
+
const PROD_URL = "https://reddwarf.app"
14
14
const DEV_URL = "https://local3768forumtest.whey.party"
15
+
16
+
const PROD_HANDLE_RESOLVER_PDS = "https://pds-nd.whey.party"
17
+
const DEV_HANDLE_RESOLVER_PDS = "https://bsky.social"
15
18
16
19
function shp(url: string): string {
17
20
return url.replace(/^https?:\/\//, '');
···
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({