this repo has no description
0
fork

Configure Feed

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

Many updates, see changelog

uwx 3d0d42c6 77a1efbd

+1570 -72
+95
lexicons/blue.microcosm/links/getBacklinks.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "blue.microcosm.links.getBacklinks", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "a list of records linking to any record, identity, or uri", 8 + "parameters": { 9 + "type": "params", 10 + "required": [ 11 + "subject", 12 + "source" 13 + ], 14 + "properties": { 15 + "subject": { 16 + "type": "string", 17 + "format": "uri", 18 + "description": "the target being linked to (at-uri, did, or uri)" 19 + }, 20 + "source": { 21 + "type": "string", 22 + "description": "collection and path specification (e.g., 'app.bsky.feed.like:subject.uri')" 23 + }, 24 + "did": { 25 + "type": "array", 26 + "description": "filter links to those from specific users", 27 + "items": { 28 + "type": "string", 29 + "format": "did" 30 + } 31 + }, 32 + "limit": { 33 + "type": "integer", 34 + "minimum": 1, 35 + "maximum": 100, 36 + "default": 16, 37 + "description": "number of results to return" 38 + } 39 + } 40 + }, 41 + "output": { 42 + "encoding": "application/json", 43 + "schema": { 44 + "type": "object", 45 + "required": [ 46 + "total", 47 + "records" 48 + ], 49 + "properties": { 50 + "total": { 51 + "type": "integer", 52 + "description": "total number of matching links" 53 + }, 54 + "records": { 55 + "type": "array", 56 + "items": { 57 + "type": "ref", 58 + "ref": "#linkRecord" 59 + } 60 + }, 61 + "cursor": { 62 + "type": "string", 63 + "description": "pagination cursor" 64 + } 65 + } 66 + } 67 + } 68 + }, 69 + "linkRecord": { 70 + "type": "object", 71 + "required": [ 72 + "did", 73 + "collection", 74 + "rkey" 75 + ], 76 + "properties": { 77 + "did": { 78 + "type": "string", 79 + "format": "did", 80 + "description": "the DID of the linking record's repository" 81 + }, 82 + "collection": { 83 + "type": "string", 84 + "format": "nsid", 85 + "description": "the collection of the linking record" 86 + }, 87 + "rkey": { 88 + "type": "string", 89 + "format": "record-key", 90 + "description": "the record key of the linking record" 91 + } 92 + } 93 + } 94 + } 95 + }
+38
lexicons/blue.microcosm/links/getBacklinksCount.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "blue.microcosm.links.getBacklinksCount", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "count records that link to another record", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["subject", "source"], 11 + "properties": { 12 + "subject": { 13 + "type": "string", 14 + "format": "uri", 15 + "description": "the primary target being linked to (at-uri, did, or uri)" 16 + }, 17 + "source": { 18 + "type": "string", 19 + "description": "collection and path specification for the primary link" 20 + } 21 + } 22 + }, 23 + "output": { 24 + "encoding": "application/json", 25 + "schema": { 26 + "type": "object", 27 + "required": ["total"], 28 + "properties": { 29 + "total": { 30 + "type": "integer", 31 + "description": "total number of matching links" 32 + } 33 + } 34 + } 35 + } 36 + } 37 + } 38 + }
+99
lexicons/blue.microcosm/links/getManyToManyCounts.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "blue.microcosm.links.getManyToManyCounts", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "count many-to-many relationships with secondary link paths", 8 + "parameters": { 9 + "type": "params", 10 + "required": [ 11 + "subject", 12 + "source", 13 + "pathToOther" 14 + ], 15 + "properties": { 16 + "subject": { 17 + "type": "string", 18 + "format": "uri", 19 + "description": "the primary target being linked to (at-uri, did, or uri)" 20 + }, 21 + "source": { 22 + "type": "string", 23 + "description": "collection and path specification for the primary link" 24 + }, 25 + "pathToOther": { 26 + "type": "string", 27 + "description": "path to the secondary link in the many-to-many record (e.g., 'otherThing.uri')" 28 + }, 29 + "did": { 30 + "type": "array", 31 + "description": "filter links to those from specific users", 32 + "items": { 33 + "type": "string", 34 + "format": "did" 35 + } 36 + }, 37 + "otherSubject": { 38 + "type": "array", 39 + "description": "filter secondary links to specific subjects", 40 + "items": { 41 + "type": "string" 42 + } 43 + }, 44 + "limit": { 45 + "type": "integer", 46 + "minimum": 1, 47 + "maximum": 100, 48 + "default": 16, 49 + "description": "number of results to return" 50 + } 51 + } 52 + }, 53 + "output": { 54 + "encoding": "application/json", 55 + "schema": { 56 + "type": "object", 57 + "required": [ 58 + "counts_by_other_subject" 59 + ], 60 + "properties": { 61 + "counts_by_other_subject": { 62 + "type": "array", 63 + "items": { 64 + "type": "ref", 65 + "ref": "#countBySubject" 66 + } 67 + }, 68 + "cursor": { 69 + "type": "string", 70 + "description": "pagination cursor" 71 + } 72 + } 73 + } 74 + } 75 + }, 76 + "countBySubject": { 77 + "type": "object", 78 + "required": [ 79 + "subject", 80 + "total", 81 + "distinct" 82 + ], 83 + "properties": { 84 + "subject": { 85 + "type": "string", 86 + "description": "the secondary subject being counted" 87 + }, 88 + "total": { 89 + "type": "integer", 90 + "description": "total number of links to this subject" 91 + }, 92 + "distinct": { 93 + "type": "integer", 94 + "description": "number of distinct DIDs linking to this subject" 95 + } 96 + } 97 + } 98 + } 99 + }
+56
lexicons/com.bad-example/identity/resolveMiniDoc.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.bad-example.identity.resolveMiniDoc", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "like com.atproto.identity.resolveIdentity but instead of the full didDoc it returns an atproto-relevant subset", 8 + "parameters": { 9 + "type": "params", 10 + "required": [ 11 + "identifier" 12 + ], 13 + "properties": { 14 + "identifier": { 15 + "type": "string", 16 + "format": "at-identifier", 17 + "description": "handle or DID to resolve" 18 + } 19 + } 20 + }, 21 + "output": { 22 + "encoding": "application/json", 23 + "schema": { 24 + "type": "object", 25 + "required": [ 26 + "did", 27 + "handle", 28 + "pds", 29 + "signing_key" 30 + ], 31 + "properties": { 32 + "did": { 33 + "type": "string", 34 + "format": "did", 35 + "description": "DID, bi-directionally verified if a handle was provided in the query" 36 + }, 37 + "handle": { 38 + "type": "string", 39 + "format": "handle", 40 + "description": "the validated handle of the account or 'handle.invalid' if the handle did not bi-directionally match the DID document" 41 + }, 42 + "pds": { 43 + "type": "string", 44 + "format": "uri", 45 + "description": "the identity's PDS URL" 46 + }, 47 + "signing_key": { 48 + "type": "string", 49 + "description": "the atproto signing key publicKeyMultibase" 50 + } 51 + } 52 + } 53 + } 54 + } 55 + } 56 + }
+54
lexicons/com.bad-example/repo/getUriRecord.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.bad-example.repo.getUriRecord", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "ergonomic complement to com.atproto.repo.getRecord which accepts an at-uri instead of individual repo/collection/rkey params", 8 + "parameters": { 9 + "type": "params", 10 + "required": [ 11 + "at_uri" 12 + ], 13 + "properties": { 14 + "at_uri": { 15 + "type": "string", 16 + "format": "at-uri", 17 + "description": "the at-uri of the record (identifier can be a DID or handle)" 18 + }, 19 + "cid": { 20 + "type": "string", 21 + "format": "cid", 22 + "description": "optional CID of the version of the record. if not specified, return the most recent version. if specified and a newer version exists, returns 404." 23 + } 24 + } 25 + }, 26 + "output": { 27 + "encoding": "application/json", 28 + "schema": { 29 + "type": "object", 30 + "required": [ 31 + "uri", 32 + "value" 33 + ], 34 + "properties": { 35 + "uri": { 36 + "type": "string", 37 + "format": "at-uri", 38 + "description": "at-uri for this record" 39 + }, 40 + "cid": { 41 + "type": "string", 42 + "format": "cid", 43 + "description": "CID for this exact version of the record" 44 + }, 45 + "value": { 46 + "type": "unknown", 47 + "description": "the record itself" 48 + } 49 + } 50 + } 51 + } 52 + } 53 + } 54 + }
+59
lexicons/io/gitlab/kinklist/kinklist/profile.json
··· 8 8 "record": { 9 9 "type": "object", 10 10 "properties": { 11 + "kinkDefinitions": { 12 + "type": "array", 13 + "items": { 14 + "type": "ref", 15 + "ref": "#kinkCategory" 16 + }, 17 + "description": "Array of kink category/section definitions" 18 + }, 11 19 "kinks": { 12 20 "type": "array", 13 21 "items": { ··· 34 42 ] 35 43 }, 36 44 "description": "My kink list profile." 45 + }, 46 + "kinkCategory": { 47 + "type": "object", 48 + "properties": { 49 + "name": { 50 + "type": "string", 51 + "description": "The category/section name (e.g., \"General\", \"Taboo\", \"Bodies\")" 52 + }, 53 + "description": { 54 + "type": "string", 55 + "description": "Description of the category/section (e.g., \"General kinks\", \"Taboo kinks\", \"Bodies kinks\")" 56 + }, 57 + "kinks": { 58 + "type": "array", 59 + "items": { 60 + "type": "ref", 61 + "ref": "#kinkDefinition" 62 + }, 63 + "description": "Array of kink definitions" 64 + }, 65 + "participants": { 66 + "type": "array", 67 + "items": { 68 + "type": "string" 69 + }, 70 + "description": "Array of participant types (e.g., \"Self\", \"Partner\", \"Giving\", \"Receiving\")" 71 + } 72 + }, 73 + "description": "A category/section definition of kinks, containing multiple kink entries", 74 + "required": [ 75 + "name", 76 + "kinks", 77 + "participants" 78 + ] 79 + }, 80 + "kinkDefinition": { 81 + "type": "object", 82 + "properties": { 83 + "name": { 84 + "type": "string", 85 + "description": "The name of the kink (e.g., \"Bondage\", \"Voyeurism\")" 86 + }, 87 + "description": { 88 + "type": "string", 89 + "description": "Description of the kink (e.g., \"Restraining or being restrained\", \"Watching others engage in sexual activities\")" 90 + } 91 + }, 92 + "description": "A single kink definition within a category, describing the kink and its possible preferences", 93 + "required": [ 94 + "name" 95 + ] 37 96 }, 38 97 "kinkEntry": { 39 98 "type": "object",
+7 -1
package.json
··· 27 27 "typescript": "6.0.0-dev.20260213" 28 28 }, 29 29 "dependencies": { 30 + "@atcute/bluesky": "^3.2.18", 31 + "@atcute/client": "^4.2.1", 30 32 "@atcute/lexicons": "^1.2.9", 31 33 "@atcute/tid": "^1.1.2", 34 + "@fortawesome/fontawesome-svg-core": "^7.2.0", 35 + "@fortawesome/free-regular-svg-icons": "^7.2.0", 36 + "@fortawesome/free-solid-svg-icons": "^7.2.0", 37 + "@fortawesome/react-fontawesome": "^3.2.0", 32 38 "@tippyjs/react": "^4.2.6", 33 - "kitty-agent": "^10.0.0", 39 + "kitty-agent": "^10.1.0", 34 40 "lz-string": "^1.5.0", 35 41 "masonic": "^4.1.0", 36 42 "preact": "^10.28.3",
+73 -5
pnpm-lock.yaml
··· 11 11 12 12 .: 13 13 dependencies: 14 + '@atcute/bluesky': 15 + specifier: ^3.2.18 16 + version: 3.2.18 17 + '@atcute/client': 18 + specifier: ^4.2.1 19 + version: 4.2.1 14 20 '@atcute/lexicons': 15 21 specifier: ^1.2.9 16 22 version: 1.2.9 17 23 '@atcute/tid': 18 24 specifier: ^1.1.2 19 25 version: 1.1.2 26 + '@fortawesome/fontawesome-svg-core': 27 + specifier: ^7.2.0 28 + version: 7.2.0 29 + '@fortawesome/free-regular-svg-icons': 30 + specifier: ^7.2.0 31 + version: 7.2.0 32 + '@fortawesome/free-solid-svg-icons': 33 + specifier: ^7.2.0 34 + version: 7.2.0 35 + '@fortawesome/react-fontawesome': 36 + specifier: ^3.2.0 37 + version: 3.2.0(@fortawesome/fontawesome-svg-core@7.2.0)(react@19.2.4) 20 38 '@tippyjs/react': 21 39 specifier: ^4.2.6 22 40 version: 4.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) 23 41 kitty-agent: 24 - specifier: ^10.0.0 25 - version: 10.0.0(@atcute/atproto@3.1.10)(@atcute/cid@2.4.1)(@atcute/client@4.2.1)(@atcute/identity-resolver@1.2.2(@atcute/identity@1.1.3))(@atcute/identity@1.1.3)(@atcute/lexicons@1.2.9)(@atcute/oauth-browser-client@3.0.0(@atcute/identity@1.1.3))(@atproto/syntax@0.4.3) 42 + specifier: ^10.1.0 43 + version: 10.1.0(@atcute/atproto@3.1.10)(@atcute/cid@2.4.1)(@atcute/client@4.2.1)(@atcute/identity-resolver@1.2.2(@atcute/identity@1.1.3))(@atcute/identity@1.1.3)(@atcute/lexicons@1.2.9)(@atcute/oauth-browser-client@3.0.0(@atcute/identity@1.1.3))(@atproto/syntax@0.4.3) 26 44 lz-string: 27 45 specifier: ^1.5.0 28 46 version: 1.5.0 ··· 80 98 81 99 '@atcute/atproto@3.1.10': 82 100 resolution: {integrity: sha512-+GKZpOc0PJcdWMQEkTfg/rSNDAAHxmAUGBl60g2az15etqJn5WaUPNGFE2sB7hKpwi5Ue2h/L0OacINcE/JDDQ==} 101 + 102 + '@atcute/bluesky@3.2.18': 103 + resolution: {integrity: sha512-8S4D0YMUUtvZFchBpEEkvIk7luMu0Z3l50ppUa+EGDDNqF6P5gkgm8q0qfaqpULtDyInKHR+MqJ8fMm20xWgFg==} 83 104 84 105 '@atcute/car@5.1.1': 85 106 resolution: {integrity: sha512-MeRUJNXYgAHrJZw7mMoZJb9xIqv3LZLQw90rRRAVAo8SGNdICwyqe6Bf2LGesX73QM04MBuYO6Kqhvold3TFfg==} ··· 195 216 '@essentials/request-timeout@1.3.0': 196 217 resolution: {integrity: sha512-lKZPhKScNFnR1MBnk4+sxshk46fpvdN+Uh1LlKWFO5g1ocuz4EcknNIL7tm/rsCAs/+xMWiBTwbDUvm+pDNlXw==} 197 218 219 + '@fortawesome/fontawesome-common-types@7.2.0': 220 + resolution: {integrity: sha512-IpR0bER9FY25p+e7BmFH25MZKEwFHTfRAfhOyJubgiDnoJNsSvJ7nigLraHtp4VOG/cy8D7uiV0dLkHOne5Fhw==} 221 + engines: {node: '>=6'} 222 + 223 + '@fortawesome/fontawesome-svg-core@7.2.0': 224 + resolution: {integrity: sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q==} 225 + engines: {node: '>=6'} 226 + 227 + '@fortawesome/free-regular-svg-icons@7.2.0': 228 + resolution: {integrity: sha512-iycmlN51EULlQ4D/UU9WZnHiN0CvjJ2TuuCrAh+1MVdzD+4ViKYH2deNAll4XAAYlZa8WAefHR5taSK8hYmSMw==} 229 + engines: {node: '>=6'} 230 + 231 + '@fortawesome/free-solid-svg-icons@7.2.0': 232 + resolution: {integrity: sha512-YTVITFGN0/24PxzXrwqCgnyd7njDuzp5ZvaCx5nq/jg55kUYd94Nj8UTchBdBofi/L0nwRfjGOg0E41d2u9T1w==} 233 + engines: {node: '>=6'} 234 + 235 + '@fortawesome/react-fontawesome@3.2.0': 236 + resolution: {integrity: sha512-E9Gu1hqd6JussVO26EC4WqRZssXMnQr2ol7ZNWkkFOH8jZUaxDJ9Z9WF9wIVkC+kJGXUdY3tlffpDwEKfgQrQw==} 237 + engines: {node: '>=20'} 238 + peerDependencies: 239 + '@fortawesome/fontawesome-svg-core': ~6 || ~7 240 + react: ^18.0.0 || ^19.0.0 241 + 198 242 '@inquirer/ansi@2.0.3': 199 243 resolution: {integrity: sha512-g44zhR3NIKVs0zUesa4iMzExmZpLUdTLRMCStqX3GE5NT6VkPcxQGJ+uC8tDgBUC/vB1rUhUd55cOf++4NZcmw==} 200 244 engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} ··· 787 831 json-schema-traverse@1.0.0: 788 832 resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} 789 833 790 - kitty-agent@10.0.0: 791 - resolution: {integrity: sha512-8K68JyE2HM02cV3dqdjnjw7kIAbVApESCinypZ8wAKzpZpnRsv5vcTRiTbhhrpEiBOc6H0WkMZzudR9T3ax/PA==} 834 + kitty-agent@10.1.0: 835 + resolution: {integrity: sha512-kI17Xi6F01byT/MIbPqLHf0Zk8DfHWna2rwWix5/i2WVf9d4/I7VDj13taP9QBJhw0AD0GjmtO6LSMqSeSt/pg==} 792 836 peerDependencies: 793 837 '@atcute/atproto': ^3.1.10 794 838 '@atcute/cid': ^2.4.1 ··· 1136 1180 dependencies: 1137 1181 '@atcute/lexicons': 1.2.9 1138 1182 1183 + '@atcute/bluesky@3.2.18': 1184 + dependencies: 1185 + '@atcute/atproto': 3.1.10 1186 + '@atcute/lexicons': 1.2.9 1187 + 1139 1188 '@atcute/car@5.1.1': 1140 1189 dependencies: 1141 1190 '@atcute/cbor': 2.3.2 ··· 1327 1376 dependencies: 1328 1377 '@essentials/raf': 1.2.0 1329 1378 1379 + '@fortawesome/fontawesome-common-types@7.2.0': {} 1380 + 1381 + '@fortawesome/fontawesome-svg-core@7.2.0': 1382 + dependencies: 1383 + '@fortawesome/fontawesome-common-types': 7.2.0 1384 + 1385 + '@fortawesome/free-regular-svg-icons@7.2.0': 1386 + dependencies: 1387 + '@fortawesome/fontawesome-common-types': 7.2.0 1388 + 1389 + '@fortawesome/free-solid-svg-icons@7.2.0': 1390 + dependencies: 1391 + '@fortawesome/fontawesome-common-types': 7.2.0 1392 + 1393 + '@fortawesome/react-fontawesome@3.2.0(@fortawesome/fontawesome-svg-core@7.2.0)(react@19.2.4)': 1394 + dependencies: 1395 + '@fortawesome/fontawesome-svg-core': 7.2.0 1396 + react: 19.2.4 1397 + 1330 1398 '@inquirer/ansi@2.0.3': {} 1331 1399 1332 1400 '@inquirer/checkbox@5.0.6(@types/node@25.2.3)': ··· 1892 1960 1893 1961 json-schema-traverse@1.0.0: {} 1894 1962 1895 - kitty-agent@10.0.0(@atcute/atproto@3.1.10)(@atcute/cid@2.4.1)(@atcute/client@4.2.1)(@atcute/identity-resolver@1.2.2(@atcute/identity@1.1.3))(@atcute/identity@1.1.3)(@atcute/lexicons@1.2.9)(@atcute/oauth-browser-client@3.0.0(@atcute/identity@1.1.3))(@atproto/syntax@0.4.3): 1963 + kitty-agent@10.1.0(@atcute/atproto@3.1.10)(@atcute/cid@2.4.1)(@atcute/client@4.2.1)(@atcute/identity-resolver@1.2.2(@atcute/identity@1.1.3))(@atcute/identity@1.1.3)(@atcute/lexicons@1.2.9)(@atcute/oauth-browser-client@3.0.0(@atcute/identity@1.1.3))(@atproto/syntax@0.4.3): 1896 1964 dependencies: 1897 1965 '@atcute/atproto': 3.1.10 1898 1966 '@atcute/cid': 2.4.1
+25 -1
public/atproto/client.ts
··· 1 1 import type { KittyAgent } from "kitty-agent"; 2 2 import { IoGitlabKinklistKinklistProfile } from "../lexicons"; 3 3 import { Did } from "@atcute/lexicons"; 4 + import { slingshotClient } from "./slingshot"; 4 5 5 6 export class KinklistClient { 6 7 constructor(private readonly loginState: { ··· 8 9 readonly did: Did; 9 10 readonly pds: string; 10 11 readonly agent: KittyAgent; 11 - }) {} 12 + }) { } 13 + 14 + appviewAgent = this.agent.clone({ 15 + proxy: { 16 + did: 'did:web:api.bsky.app', 17 + serviceId: '#bsky_appview' 18 + }, 19 + }) 12 20 13 21 get agent(): KittyAgent { 14 22 return this.loginState.agent; ··· 36 44 updatedAt: new Date().toISOString(), 37 45 }, 38 46 swapRecord: cid ?? undefined, 47 + }); 48 + } 49 + 50 + async tryGetOwnProfile() { 51 + return await slingshotClient.tryGetRecord({ 52 + collection: 'io.gitlab.kinklist.kinklist.profile', 53 + repo: this.user.did, 54 + rkey: 'self' 55 + }); 56 + } 57 + 58 + async tryGetProfile(did: Did) { 59 + return await slingshotClient.tryGetRecord({ 60 + collection: 'io.gitlab.kinklist.kinklist.profile', 61 + repo: did, 62 + rkey: 'self' 39 63 }); 40 64 } 41 65 }
+92
public/atproto/constellation.ts
··· 1 + import { simpleFetchHandler } from '@atcute/client'; 2 + import { ActorIdentifier, Did, Nsid, RecordKey, ResourceUri } from '@atcute/lexicons'; 3 + import { Records } from '@atcute/lexicons/ambient'; 4 + import { KittyAgent } from 'kitty-agent'; 5 + 6 + export const constellationClient = new KittyAgent({ 7 + handler: simpleFetchHandler({ 8 + service: 'https://constellation.microcosm.blue', 9 + fetch(input, init) { 10 + return fetch(input, { 11 + ...init, 12 + headers: { 13 + ...init?.headers, 14 + 'User-Agent': 'kinklist.wisp.place', 15 + }, 16 + }); 17 + }, 18 + }) 19 + }); 20 + 21 + export async function getBacklinks({ 22 + subject, 23 + source, 24 + did, 25 + limit, 26 + reverse, 27 + cursor 28 + }: { 29 + subject: `at://${ActorIdentifier}/${Nsid}/${RecordKey}` | Did | string, 30 + source: `${(keyof Records) & Nsid}:${string}`, 31 + did?: Did[], 32 + limit?: number, 33 + reverse?: boolean, 34 + cursor?: string 35 + }) { 36 + return await constellationClient.getSafe('blue.microcosm.links.getBacklinks', { 37 + as: 'json', 38 + params: { 39 + subject: subject as ResourceUri, 40 + source, 41 + did, 42 + limit, 43 + reverse, 44 + cursor 45 + } 46 + }); 47 + } 48 + 49 + export async function getAllBacklinks({ 50 + subject, 51 + source, 52 + did, 53 + limit, 54 + reverse 55 + }: { 56 + subject: `at://${ActorIdentifier}/${Nsid}/${RecordKey}` | Did | string, 57 + source: `${(keyof Records) & Nsid}:${string}`, 58 + did?: Did[], 59 + limit?: number, 60 + reverse?: boolean 61 + }) { 62 + let result = await getBacklinks({ 63 + subject: subject as ResourceUri, 64 + source, 65 + did, 66 + limit: 100, 67 + reverse 68 + }); 69 + 70 + let records = result.records; 71 + 72 + if (limit && records.length >= limit) { 73 + return { ...result, records: records.slice(0, limit) }; 74 + } 75 + 76 + while (result.cursor) { 77 + result = await getBacklinks({ 78 + subject, 79 + source, 80 + did, 81 + limit: 100, 82 + reverse, 83 + cursor: result.cursor 84 + }); 85 + records.push(...result.records); 86 + if (limit && records.length >= limit) { 87 + return { ...result, records: records.slice(0, limit) }; 88 + } 89 + } 90 + 91 + return { ...result, records }; 92 + }
+7 -4
public/atproto/signed-in-user.ts
··· 2 2 import { KinklistClient } from "./client"; 3 3 import { computed, signal } from "@preact/signals"; 4 4 5 - import metadata from '../client-metadata.json' with { type: 'json' }; 5 + import metadata from '../client-metadata4.json' with { type: 'json' }; 6 6 7 7 export const oauthClient = new StatefulPreactOAuthClient<KinklistClient>( 8 8 { 9 - clientId: metadata.client_id, 10 - redirectUri: document.location.host === 'localhost' 11 - ? 'http://localhost:8080/oauth-redirect.html' 9 + clientId: document.location.hostname === '127.0.0.1' || document.location.hostname === 'localhost' 10 + ? `http://localhost?redirect_uri=${encodeURIComponent(`http://127.0.0.1:${document.location.port}/oauth-redirect.html`)}` + 11 + `&scope=${encodeURIComponent(metadata.scope)}` 12 + : metadata.client_id, 13 + redirectUri: document.location.hostname === '127.0.0.1' || document.location.hostname === 'localhost' 14 + ? `http://127.0.0.1:${document.location.port}/oauth-redirect.html` 12 15 : document.location.host !== 'kinklist.github.io' 13 16 ? 'https://kinklist.wisp.place/oauth-redirect.html' 14 17 : 'https://kinklist.github.io/oauth-redirect.html',
+17
public/atproto/slingshot.ts
··· 1 + import { simpleFetchHandler } from '@atcute/client'; 2 + import { KittyAgent } from 'kitty-agent'; 3 + 4 + export const slingshotClient = new KittyAgent({ 5 + handler: simpleFetchHandler({ 6 + service: 'https://slingshot.microcosm.blue', 7 + fetch(input, init) { 8 + return fetch(input, { 9 + ...init, 10 + headers: { 11 + ...init?.headers, 12 + 'User-Agent': 'kinklist.wisp.place', 13 + }, 14 + }); 15 + }, 16 + }) 17 + });
+2 -2
public/client-metadata.json public/client-metadata4.json
··· 1 1 { 2 - "client_id": "https://kinklist.wisp.place/client-metadata.json", 2 + "client_id": "https://kinklist.wisp.place/client-metadata4.json", 3 3 "client_name": "atpaste", 4 4 "client_uri": "https://kinklist.wisp.place/", 5 5 "redirect_uris": ["https://kinklist.wisp.place/oauth-redirect.html", "https://kinklist.github.io/oauth-redirect.html", "https://sites.wisp.place/did:plc:nmc77zslrwafxn75j66mep6o/kinklist/oauth-redirect.html"], 6 - "scope": "atproto repo:io.gitlab.kinklist.kinklist.profile", 6 + "scope": "atproto repo:io.gitlab.kinklist.kinklist.profile rpc:app.bsky.actor.getProfile?aud=* rpc:app.bsky.graph.getFollowers?aud=* rpc:app.bsky.graph.getFollows?aud=*", 7 7 "grant_types": ["authorization_code", "refresh_token"], 8 8 "response_types": ["code"], 9 9 "token_endpoint_auth_method": "none",
+5
public/lexicons/index.ts
··· 1 + export * as BlueMicrocosmLinksGetBacklinks from "./types/blue/microcosm/links/getBacklinks.js"; 2 + export * as BlueMicrocosmLinksGetBacklinksCount from "./types/blue/microcosm/links/getBacklinksCount.js"; 3 + export * as BlueMicrocosmLinksGetManyToManyCounts from "./types/blue/microcosm/links/getManyToManyCounts.js"; 4 + export * as ComBadExampleIdentityResolveMiniDoc from "./types/com/bad-example/identity/resolveMiniDoc.js"; 5 + export * as ComBadExampleRepoGetUriRecord from "./types/com/bad-example/repo/getUriRecord.js"; 1 6 export * as IoGitlabKinklistKinklistProfile from "./types/io/gitlab/kinklist/kinklist/profile.js";
+87
public/lexicons/types/blue/microcosm/links/getBacklinks.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + 5 + const _linkRecordSchema = /*#__PURE__*/ v.object({ 6 + $type: /*#__PURE__*/ v.optional( 7 + /*#__PURE__*/ v.literal("blue.microcosm.links.getBacklinks#linkRecord"), 8 + ), 9 + /** 10 + * the collection of the linking record 11 + */ 12 + collection: /*#__PURE__*/ v.nsidString(), 13 + /** 14 + * the DID of the linking record's repository 15 + */ 16 + did: /*#__PURE__*/ v.didString(), 17 + /** 18 + * the record key of the linking record 19 + */ 20 + rkey: /*#__PURE__*/ v.recordKeyString(), 21 + }); 22 + const _mainSchema = /*#__PURE__*/ v.query("blue.microcosm.links.getBacklinks", { 23 + params: /*#__PURE__*/ v.object({ 24 + /** 25 + * filter links to those from specific users 26 + */ 27 + did: /*#__PURE__*/ v.optional( 28 + /*#__PURE__*/ v.array(/*#__PURE__*/ v.didString()), 29 + ), 30 + /** 31 + * number of results to return 32 + * @minimum 1 33 + * @maximum 100 34 + * @default 16 35 + */ 36 + limit: /*#__PURE__*/ v.optional( 37 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.integer(), [ 38 + /*#__PURE__*/ v.integerRange(1, 100), 39 + ]), 40 + 16, 41 + ), 42 + /** 43 + * collection and path specification (e.g., 'app.bsky.feed.like:subject.uri') 44 + */ 45 + source: /*#__PURE__*/ v.string(), 46 + /** 47 + * the target being linked to (at-uri, did, or uri) 48 + */ 49 + subject: /*#__PURE__*/ v.genericUriString(), 50 + }), 51 + output: { 52 + type: "lex", 53 + schema: /*#__PURE__*/ v.object({ 54 + /** 55 + * pagination cursor 56 + */ 57 + cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 58 + get records() { 59 + return /*#__PURE__*/ v.array(linkRecordSchema); 60 + }, 61 + /** 62 + * total number of matching links 63 + */ 64 + total: /*#__PURE__*/ v.integer(), 65 + }), 66 + }, 67 + }); 68 + 69 + type linkRecord$schematype = typeof _linkRecordSchema; 70 + type main$schematype = typeof _mainSchema; 71 + 72 + export interface linkRecordSchema extends linkRecord$schematype {} 73 + export interface mainSchema extends main$schematype {} 74 + 75 + export const linkRecordSchema = _linkRecordSchema as linkRecordSchema; 76 + export const mainSchema = _mainSchema as mainSchema; 77 + 78 + export interface LinkRecord extends v.InferInput<typeof linkRecordSchema> {} 79 + 80 + export interface $params extends v.InferInput<mainSchema["params"]> {} 81 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 82 + 83 + declare module "@atcute/lexicons/ambient" { 84 + interface XRPCQueries { 85 + "blue.microcosm.links.getBacklinks": mainSchema; 86 + } 87 + }
+43
public/lexicons/types/blue/microcosm/links/getBacklinksCount.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + 5 + const _mainSchema = /*#__PURE__*/ v.query( 6 + "blue.microcosm.links.getBacklinksCount", 7 + { 8 + params: /*#__PURE__*/ v.object({ 9 + /** 10 + * collection and path specification for the primary link 11 + */ 12 + source: /*#__PURE__*/ v.string(), 13 + /** 14 + * the primary target being linked to (at-uri, did, or uri) 15 + */ 16 + subject: /*#__PURE__*/ v.genericUriString(), 17 + }), 18 + output: { 19 + type: "lex", 20 + schema: /*#__PURE__*/ v.object({ 21 + /** 22 + * total number of matching links 23 + */ 24 + total: /*#__PURE__*/ v.integer(), 25 + }), 26 + }, 27 + }, 28 + ); 29 + 30 + type main$schematype = typeof _mainSchema; 31 + 32 + export interface mainSchema extends main$schematype {} 33 + 34 + export const mainSchema = _mainSchema as mainSchema; 35 + 36 + export interface $params extends v.InferInput<mainSchema["params"]> {} 37 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 38 + 39 + declare module "@atcute/lexicons/ambient" { 40 + interface XRPCQueries { 41 + "blue.microcosm.links.getBacklinksCount": mainSchema; 42 + } 43 + }
+101
public/lexicons/types/blue/microcosm/links/getManyToManyCounts.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + 5 + const _countBySubjectSchema = /*#__PURE__*/ v.object({ 6 + $type: /*#__PURE__*/ v.optional( 7 + /*#__PURE__*/ v.literal( 8 + "blue.microcosm.links.getManyToManyCounts#countBySubject", 9 + ), 10 + ), 11 + /** 12 + * number of distinct DIDs linking to this subject 13 + */ 14 + distinct: /*#__PURE__*/ v.integer(), 15 + /** 16 + * the secondary subject being counted 17 + */ 18 + subject: /*#__PURE__*/ v.string(), 19 + /** 20 + * total number of links to this subject 21 + */ 22 + total: /*#__PURE__*/ v.integer(), 23 + }); 24 + const _mainSchema = /*#__PURE__*/ v.query( 25 + "blue.microcosm.links.getManyToManyCounts", 26 + { 27 + params: /*#__PURE__*/ v.object({ 28 + /** 29 + * filter links to those from specific users 30 + */ 31 + did: /*#__PURE__*/ v.optional( 32 + /*#__PURE__*/ v.array(/*#__PURE__*/ v.didString()), 33 + ), 34 + /** 35 + * number of results to return 36 + * @minimum 1 37 + * @maximum 100 38 + * @default 16 39 + */ 40 + limit: /*#__PURE__*/ v.optional( 41 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.integer(), [ 42 + /*#__PURE__*/ v.integerRange(1, 100), 43 + ]), 44 + 16, 45 + ), 46 + /** 47 + * filter secondary links to specific subjects 48 + */ 49 + otherSubject: /*#__PURE__*/ v.optional( 50 + /*#__PURE__*/ v.array(/*#__PURE__*/ v.string()), 51 + ), 52 + /** 53 + * path to the secondary link in the many-to-many record (e.g., 'otherThing.uri') 54 + */ 55 + pathToOther: /*#__PURE__*/ v.string(), 56 + /** 57 + * collection and path specification for the primary link 58 + */ 59 + source: /*#__PURE__*/ v.string(), 60 + /** 61 + * the primary target being linked to (at-uri, did, or uri) 62 + */ 63 + subject: /*#__PURE__*/ v.genericUriString(), 64 + }), 65 + output: { 66 + type: "lex", 67 + schema: /*#__PURE__*/ v.object({ 68 + get counts_by_other_subject() { 69 + return /*#__PURE__*/ v.array(countBySubjectSchema); 70 + }, 71 + /** 72 + * pagination cursor 73 + */ 74 + cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 75 + }), 76 + }, 77 + }, 78 + ); 79 + 80 + type countBySubject$schematype = typeof _countBySubjectSchema; 81 + type main$schematype = typeof _mainSchema; 82 + 83 + export interface countBySubjectSchema extends countBySubject$schematype {} 84 + export interface mainSchema extends main$schematype {} 85 + 86 + export const countBySubjectSchema = 87 + _countBySubjectSchema as countBySubjectSchema; 88 + export const mainSchema = _mainSchema as mainSchema; 89 + 90 + export interface CountBySubject extends v.InferInput< 91 + typeof countBySubjectSchema 92 + > {} 93 + 94 + export interface $params extends v.InferInput<mainSchema["params"]> {} 95 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 96 + 97 + declare module "@atcute/lexicons/ambient" { 98 + interface XRPCQueries { 99 + "blue.microcosm.links.getManyToManyCounts": mainSchema; 100 + } 101 + }
+51
public/lexicons/types/com/bad-example/identity/resolveMiniDoc.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + 5 + const _mainSchema = /*#__PURE__*/ v.query( 6 + "com.bad-example.identity.resolveMiniDoc", 7 + { 8 + params: /*#__PURE__*/ v.object({ 9 + /** 10 + * handle or DID to resolve 11 + */ 12 + identifier: /*#__PURE__*/ v.actorIdentifierString(), 13 + }), 14 + output: { 15 + type: "lex", 16 + schema: /*#__PURE__*/ v.object({ 17 + /** 18 + * DID, bi-directionally verified if a handle was provided in the query 19 + */ 20 + did: /*#__PURE__*/ v.didString(), 21 + /** 22 + * the validated handle of the account or 'handle.invalid' if the handle did not bi-directionally match the DID document 23 + */ 24 + handle: /*#__PURE__*/ v.handleString(), 25 + /** 26 + * the identity's PDS URL 27 + */ 28 + pds: /*#__PURE__*/ v.genericUriString(), 29 + /** 30 + * the atproto signing key publicKeyMultibase 31 + */ 32 + signing_key: /*#__PURE__*/ v.string(), 33 + }), 34 + }, 35 + }, 36 + ); 37 + 38 + type main$schematype = typeof _mainSchema; 39 + 40 + export interface mainSchema extends main$schematype {} 41 + 42 + export const mainSchema = _mainSchema as mainSchema; 43 + 44 + export interface $params extends v.InferInput<mainSchema["params"]> {} 45 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 46 + 47 + declare module "@atcute/lexicons/ambient" { 48 + interface XRPCQueries { 49 + "com.bad-example.identity.resolveMiniDoc": mainSchema; 50 + } 51 + }
+48
public/lexicons/types/com/bad-example/repo/getUriRecord.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + 5 + const _mainSchema = /*#__PURE__*/ v.query("com.bad-example.repo.getUriRecord", { 6 + params: /*#__PURE__*/ v.object({ 7 + /** 8 + * the at-uri of the record (identifier can be a DID or handle) 9 + */ 10 + at_uri: /*#__PURE__*/ v.resourceUriString(), 11 + /** 12 + * optional CID of the version of the record. if not specified, return the most recent version. if specified and a newer version exists, returns 404. 13 + */ 14 + cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.cidString()), 15 + }), 16 + output: { 17 + type: "lex", 18 + schema: /*#__PURE__*/ v.object({ 19 + /** 20 + * CID for this exact version of the record 21 + */ 22 + cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.cidString()), 23 + /** 24 + * at-uri for this record 25 + */ 26 + uri: /*#__PURE__*/ v.resourceUriString(), 27 + /** 28 + * the record itself 29 + */ 30 + value: /*#__PURE__*/ v.unknown(), 31 + }), 32 + }, 33 + }); 34 + 35 + type main$schematype = typeof _mainSchema; 36 + 37 + export interface mainSchema extends main$schematype {} 38 + 39 + export const mainSchema = _mainSchema as mainSchema; 40 + 41 + export interface $params extends v.InferInput<mainSchema["params"]> {} 42 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 43 + 44 + declare module "@atcute/lexicons/ambient" { 45 + interface XRPCQueries { 46 + "com.bad-example.repo.getUriRecord": mainSchema; 47 + } 48 + }
+57
public/lexicons/types/io/gitlab/kinklist/kinklist/profile.ts
··· 2 2 import * as v from "@atcute/lexicons/validations"; 3 3 import type {} from "@atcute/lexicons/ambient"; 4 4 5 + const _kinkCategorySchema = /*#__PURE__*/ v.object({ 6 + $type: /*#__PURE__*/ v.optional( 7 + /*#__PURE__*/ v.literal("io.gitlab.kinklist.kinklist.profile#kinkCategory"), 8 + ), 9 + /** 10 + * Description of the category/section (e.g., "General kinks", "Taboo kinks", "Bodies kinks") 11 + */ 12 + description: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 13 + /** 14 + * Array of kink definitions 15 + */ 16 + get kinks() { 17 + return /*#__PURE__*/ v.array(kinkDefinitionSchema); 18 + }, 19 + /** 20 + * The category/section name (e.g., "General", "Taboo", "Bodies") 21 + */ 22 + name: /*#__PURE__*/ v.string(), 23 + /** 24 + * Array of participant types (e.g., "Self", "Partner", "Giving", "Receiving") 25 + */ 26 + participants: /*#__PURE__*/ v.array(/*#__PURE__*/ v.string()), 27 + }); 28 + const _kinkDefinitionSchema = /*#__PURE__*/ v.object({ 29 + $type: /*#__PURE__*/ v.optional( 30 + /*#__PURE__*/ v.literal( 31 + "io.gitlab.kinklist.kinklist.profile#kinkDefinition", 32 + ), 33 + ), 34 + /** 35 + * Description of the kink (e.g., "Restraining or being restrained", "Watching others engage in sexual activities") 36 + */ 37 + description: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 38 + /** 39 + * The name of the kink (e.g., "Bondage", "Voyeurism") 40 + */ 41 + name: /*#__PURE__*/ v.string(), 42 + }); 5 43 const _kinkEntrySchema = /*#__PURE__*/ v.object({ 6 44 $type: /*#__PURE__*/ v.optional( 7 45 /*#__PURE__*/ v.literal("io.gitlab.kinklist.kinklist.profile#kinkEntry"), ··· 40 78 */ 41 79 createdAt: /*#__PURE__*/ v.datetimeString(), 42 80 /** 81 + * Array of kink category/section definitions 82 + */ 83 + get kinkDefinitions() { 84 + return /*#__PURE__*/ v.optional( 85 + /*#__PURE__*/ v.array(kinkCategorySchema), 86 + ); 87 + }, 88 + /** 43 89 * Array of kink preferences 44 90 */ 45 91 get kinks() { ··· 52 98 }), 53 99 ); 54 100 101 + type kinkCategory$schematype = typeof _kinkCategorySchema; 102 + type kinkDefinition$schematype = typeof _kinkDefinitionSchema; 55 103 type kinkEntry$schematype = typeof _kinkEntrySchema; 56 104 type main$schematype = typeof _mainSchema; 57 105 106 + export interface kinkCategorySchema extends kinkCategory$schematype {} 107 + export interface kinkDefinitionSchema extends kinkDefinition$schematype {} 58 108 export interface kinkEntrySchema extends kinkEntry$schematype {} 59 109 export interface mainSchema extends main$schematype {} 60 110 111 + export const kinkCategorySchema = _kinkCategorySchema as kinkCategorySchema; 112 + export const kinkDefinitionSchema = 113 + _kinkDefinitionSchema as kinkDefinitionSchema; 61 114 export const kinkEntrySchema = _kinkEntrySchema as kinkEntrySchema; 62 115 export const mainSchema = _mainSchema as mainSchema; 63 116 117 + export interface KinkCategory extends v.InferInput<typeof kinkCategorySchema> {} 118 + export interface KinkDefinition extends v.InferInput< 119 + typeof kinkDefinitionSchema 120 + > {} 64 121 export interface KinkEntry extends v.InferInput<typeof kinkEntrySchema> {} 65 122 export interface Main extends v.InferInput<typeof mainSchema> {} 66 123
+527 -59
public/main.tsx
··· 9 9 import { createPortal } from "preact/compat"; 10 10 import { kinkText as kinkTextContent } from "./kinks"; 11 11 import { oauthClient, savedHandle, user } from "./atproto/signed-in-user"; 12 + import { Did } from "@atcute/lexicons"; 13 + import { getAllBacklinks, getBacklinks } from "./atproto/constellation"; 14 + import type {} from '@atcute/bluesky'; 15 + import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 16 + import { faSun } from "@fortawesome/free-solid-svg-icons/faSun"; 17 + import { faMoon } from "@fortawesome/free-solid-svg-icons/faMoon"; 12 18 13 19 const root = document.querySelector("#root"); 14 20 ··· 75 81 return { kinkCategories, kinksById }; 76 82 } 77 83 78 - const kinkText = signal(kinkTextContent); 84 + const kinkText = signal(localStorage.getItem("kink-text") ?? kinkTextContent); 79 85 const kinkData = computed(() => parseKinks(kinkText.value)); 80 86 81 87 /** ··· 516 522 517 523 const changelog = [ 518 524 { 525 + version: "17 - February 18th 2026", 526 + changes: ["Add Customize List", "Add friends section", "Reorganize navbar"], 527 + }, 528 + { 519 529 version: "16 - February 16th 2026", 520 530 changes: ["Add Quiz Mode"], 521 531 }, ··· 772 782 ); 773 783 } 774 784 785 + async function getFollowers(did: Did, limit = 1500) { 786 + if (!user.value) return { followers: [] }; 787 + 788 + let { cursor, followers } = await user.value.agent.getSafe('app.bsky.graph.getFollowers', { 789 + as: 'json', 790 + params: { 791 + actor: did, 792 + limit: 100 793 + } 794 + }); 795 + 796 + if (followers.length >= limit) { 797 + return { followers: followers.slice(0, limit) }; 798 + } 799 + 800 + while (cursor) { 801 + let moreFollowers; 802 + ({ cursor, followers: moreFollowers } = await user.value.agent.getSafe('app.bsky.graph.getFollowers', { 803 + as: 'json', 804 + params: { 805 + actor: did, 806 + cursor, 807 + limit: 100 808 + } 809 + })); 810 + 811 + followers.push(...moreFollowers); 812 + if (followers.length >= limit) { 813 + return { followers: followers.slice(0, limit) }; 814 + } 815 + } 816 + 817 + return { followers }; 818 + } 819 + 820 + async function getFollows(did: Did, limit = 1500) { 821 + if (!user.value) return { follows: [] }; 822 + 823 + let { cursor, follows } = await user.value.client.appviewAgent.getSafe('app.bsky.graph.getFollows', { 824 + as: 'json', 825 + params: { 826 + actor: did, 827 + limit: 100 828 + } 829 + }); 830 + 831 + if (follows.length >= limit) { 832 + return { follows: follows.slice(0, limit) }; 833 + } 834 + 835 + while (cursor) { 836 + let moreFollows; 837 + ({ cursor, follows: moreFollows } = await user.value.client.appviewAgent.getSafe('app.bsky.graph.getFollows', { 838 + as: 'json', 839 + params: { 840 + actor: did, 841 + cursor, 842 + limit: 100 843 + } 844 + })); 845 + 846 + follows.push(...moreFollows); 847 + if (follows.length >= limit) { 848 + return { follows: follows.slice(0, limit) }; 849 + } 850 + } 851 + 852 + return { follows }; 853 + } 854 + 855 + async function getProfile(actor: Did) { 856 + if (!user.value) return undefined; 857 + 858 + return await user.value.agent.getSafe('app.bsky.actor.getProfile', { 859 + as: 'json', 860 + params: { 861 + actor 862 + } 863 + }); 864 + } 865 + 866 + function usePromise<T>(promise: Promise<T>): [ 867 + state: T, 868 + error: undefined, 869 + loading: false, 870 + ] | [ 871 + state: undefined, 872 + error: unknown, 873 + loading: true, 874 + ] | [ 875 + state: undefined, 876 + error: undefined, 877 + loading: true, 878 + ]; 879 + function usePromise<T>(promise?: Promise<T>): [ 880 + state: T | undefined, 881 + error: undefined, 882 + loading: false, 883 + ] | [ 884 + state: undefined, 885 + error: unknown, 886 + loading: true, 887 + ] | [ 888 + state: undefined, 889 + error: undefined, 890 + loading: true, 891 + ]; 892 + function usePromise<T>(promise?: Promise<T>) { 893 + const [state, setState] = useState<T>(); 894 + const [error, setError] = useState<unknown>(); 895 + const [loading, setLoading] = useState(false); 896 + 897 + useEffect(() => { 898 + if (!promise) { 899 + setState(undefined); 900 + setError(undefined); 901 + return; 902 + } 903 + 904 + setLoading(true); 905 + promise 906 + .then(setState) 907 + .catch(setError) 908 + .finally(() => setLoading(false)); 909 + }, [promise]); 910 + 911 + return [state, error, loading] as [ 912 + state: T | undefined, 913 + error: undefined, 914 + loading: false, 915 + ] | [ 916 + state: undefined, 917 + error: unknown, 918 + loading: true, 919 + ] | [ 920 + state: undefined, 921 + error: undefined, 922 + loading: true, 923 + ]; 924 + } 925 + 926 + type KinksArray = { section: string, name: string, participant: string, choice: Choice }[]; 927 + 928 + const compatibilityScoreThresholds = { 929 + Super: 250, 930 + Good: 100, 931 + Decent: 50, 932 + Bad: 25, 933 + Poor: 0 934 + }; 935 + 936 + function areAllKinksUnset() { 937 + return kinkData.value.kinkCategories.every(category => 938 + category.kinks.every(kink => 939 + category.participants.every(participant => 940 + getSelectedKinkOrDefault(kink, participant).value === 'not-entered' 941 + ) 942 + ) 943 + ); 944 + } 945 + 946 + function getCompatibilityScore( 947 + myUserKinks: KinksArray, 948 + otherUserKinks: KinksArray 949 + ) { 950 + const choiceScoreWeight: Record<Choice, number> = { 951 + favorite: 10, 952 + like: 7, 953 + okay: 5, 954 + maybe: 5, 955 + no: 5, 956 + 'not-entered': 0, 957 + 'want-to-try': 5 958 + }; 959 + 960 + const similarChoiceWeight: Record<Choice, Record<Choice, number>> = { 961 + favorite: { 962 + favorite: 1, 963 + like: 0.7, 964 + okay: 0.5, 965 + maybe: 0.2, 966 + no: 0, 967 + 'not-entered': 0, 968 + 'want-to-try': 0.5 969 + }, 970 + like: { 971 + favorite: 1, 972 + like: 1, 973 + okay: 0.5, 974 + maybe: 0.2, 975 + no: 0, 976 + 'not-entered': 0, 977 + 'want-to-try': 0.5 978 + }, 979 + okay: { 980 + favorite: 1, 981 + like: 0.5, 982 + okay: 1, 983 + maybe: 0.2, 984 + no: 0, 985 + 'not-entered': 0, 986 + 'want-to-try': 0.5 987 + }, 988 + maybe: { 989 + favorite: 0.7, 990 + like: 0.5, 991 + okay: 0.5, 992 + maybe: 1, 993 + no: 0, 994 + 'not-entered': 0, 995 + 'want-to-try': 0.2 996 + }, 997 + no: { 998 + favorite: 0, 999 + like: 0, 1000 + okay: 0, 1001 + maybe: 0, 1002 + no: 1, 1003 + 'not-entered': 0, 1004 + 'want-to-try': 0 1005 + }, 1006 + 'not-entered': { 1007 + favorite: 0, 1008 + like: 0, 1009 + okay: 0, 1010 + maybe: 0, 1011 + no: 0, 1012 + 'not-entered': 0, 1013 + 'want-to-try': 0 1014 + }, 1015 + 'want-to-try': { 1016 + favorite: 0.5, 1017 + like: 0.5, 1018 + okay: 0.5, 1019 + maybe: 0.2, 1020 + no: 0, 1021 + 'not-entered': 0, 1022 + 'want-to-try': 1 1023 + } 1024 + }; 1025 + 1026 + const myKinks = new Map<string, Choice>(); 1027 + for (const kink of myUserKinks) { 1028 + myKinks.set(kink.section + '::' + kink.name, kink.choice); 1029 + } 1030 + 1031 + const otherKinks = new Map<string, Choice>(); 1032 + for (const kink of otherUserKinks) { 1033 + otherKinks.set(kink.section + '::' + kink.name, kink.choice); 1034 + } 1035 + 1036 + let score = 0; 1037 + for (const [key, myChoice] of myKinks) { 1038 + const otherChoice = otherKinks.get(key); 1039 + if (otherChoice === undefined) continue; 1040 + 1041 + score += (similarChoiceWeight[myChoice][otherChoice] ?? 0) * choiceScoreWeight[myChoice]; 1042 + } 1043 + 1044 + return score; 1045 + } 1046 + 1047 + function UserCard({ did, handle, displayName, avatar, myKinks, kinks }: { did: Did, handle?: string, displayName?: string, avatar?: string, myKinks: KinksArray, kinks: KinksArray }) { 1048 + const compatibilityScore = useMemo(() => { 1049 + if (!myKinks || !kinks) return 0; 1050 + return getCompatibilityScore(myKinks, kinks); 1051 + }, [myKinks, kinks]); 1052 + 1053 + console.log('Compatibility score for', handle, 'is', compatibilityScore); 1054 + 1055 + return ( 1056 + <div class="card"> 1057 + <div class="card-content"> 1058 + <div class="media"> 1059 + {avatar && <div class="media-left"> 1060 + <figure class="image is-64x64"> 1061 + <img 1062 + src={avatar} 1063 + alt={displayName + "'s avatar"} 1064 + /> 1065 + </figure> 1066 + </div>} 1067 + <div class="media-content"> 1068 + {displayName && <p class="title is-5">{displayName}</p>} 1069 + {handle && <p class="subtitle is-6">@{handle}</p>} 1070 + </div> 1071 + </div> 1072 + 1073 + <div class="content"> 1074 + {compatibilityScore >= compatibilityScoreThresholds.Super ? <p><strong>Compatibility: Super</strong> 🥵</p> 1075 + : compatibilityScore >= compatibilityScoreThresholds.Good ? <p><strong>Compatibility: Good</strong> 🔥</p> 1076 + : compatibilityScore >= compatibilityScoreThresholds.Decent ? <p><strong>Compatibility: Decent</strong> 🙂</p> 1077 + : compatibilityScore >= compatibilityScoreThresholds.Bad ? <p><strong>Compatibility: Bad</strong> 😐</p> 1078 + : <p><strong>Compatibility: Zero!</strong> 😢</p>} 1079 + </div> 1080 + </div> 1081 + </div> 1082 + ); 1083 + } 1084 + 1085 + async function getUserKinks<T extends { did: Did }>(users: T[]): Promise<{ users: { 1086 + user: T, 1087 + kinks: KinksArray 1088 + }[], userCount: number }> { 1089 + if (!user.value) return { users: [], userCount: users.length }; 1090 + 1091 + let userCount = users.length; 1092 + const result: { user: T, kinks: KinksArray }[] = await Promise.all( 1093 + users.map(async (theUser) => { 1094 + 1095 + let profile; 1096 + try { 1097 + profile = await user.value!.client.tryGetProfile(theUser.did); 1098 + if (!profile.value) return { user: theUser, kinks: [] }; 1099 + } catch (err) { 1100 + return { user: theUser, kinks: [] }; 1101 + } 1102 + 1103 + const kinks = profile.value.kinks.map(kink => ({ 1104 + section: kink.section, 1105 + name: kink.name, 1106 + participant: kink.participant, 1107 + choice: kink.choice as Choice 1108 + })); 1109 + 1110 + return { user: theUser, kinks }; 1111 + }) 1112 + ); 1113 + 1114 + return { users: result.filter(e => e.kinks.length > 0), userCount }; 1115 + } 1116 + 1117 + function Friends() { 1118 + const followers = useMemo(() => user.value && getFollowers(user.value.did).then(e => getUserKinks(e.followers)), [user.value?.did]); 1119 + const [followersState, followersError, followersLoading] = usePromise(followers); 1120 + 1121 + const follows = useMemo(() => user.value && getFollows(user.value.did).then(e => getUserKinks(e.follows)), [user.value?.did]); 1122 + const [followsState, followsError, followsLoading] = usePromise(follows); 1123 + 1124 + const myKinks = useMemo(() => getKinksArray(), []); 1125 + 1126 + if (followersLoading) { 1127 + return <div class="box content">Loading followers...</div>; 1128 + } 1129 + 1130 + if (followsLoading) { 1131 + return <div class="box content">Loading follows...</div>; 1132 + } 1133 + 1134 + if (followersError) { 1135 + console.error(followersError); 1136 + return <div class="box content">Failed to load followers: {followersError}</div>; 1137 + } 1138 + 1139 + if (followsError) { 1140 + return <div class="box content">Failed to load follows: {followsError}</div>; 1141 + } 1142 + 1143 + return ( 1144 + <div class="box content"> 1145 + <h2 class="subtitle">Followers</h2> 1146 + <p class="subtitle is-6">{followersState?.userCount} followers, {followersState?.users.length} have profiles</p> 1147 + <div class="grid is-col-min-16"> 1148 + {followersState?.users.map((e) => ( 1149 + <div class="cell"> 1150 + <UserCard {...e.user} kinks={e.kinks} myKinks={myKinks} /> 1151 + </div> 1152 + ))} 1153 + </div> 1154 + 1155 + <h2 class="subtitle">Following</h2> 1156 + <p class="subtitle is-6">{followsState?.userCount} following, {followsState?.users.length} have profiles</p> 1157 + <div class="grid is-col-min-16"> 1158 + {followsState?.users.map((e) => ( 1159 + <div class="cell"> 1160 + <UserCard {...e.user} kinks={e.kinks} myKinks={myKinks} /> 1161 + </div> 1162 + ))} 1163 + </div> 1164 + </div> 1165 + ); 1166 + } 1167 + 1168 + function CustomizeList({ 1169 + onClose 1170 + }: { 1171 + onClose: () => void; 1172 + }) { 1173 + const [localKinkText, setLocalKinkText] = useState(kinkText.value); 1174 + 1175 + function save() { 1176 + localStorage.setItem("kink-text", localKinkText); 1177 + kinkText.value = localKinkText; 1178 + onClose(); 1179 + } 1180 + 1181 + function resetToDefault() { 1182 + localStorage.removeItem("kink-text"); 1183 + setLocalKinkText(kinkTextContent); 1184 + } 1185 + 1186 + // async function publish() { 1187 + // const success = await uploadKinks(); 1188 + 1189 + // if (success) { 1190 + // alert("Successfully uploaded kinks!"); 1191 + // } 1192 + // } 1193 + 1194 + return ( 1195 + <div class="box content"> 1196 + <h1 class="title">Customize List</h1> 1197 + 1198 + <div class="field"> 1199 + <textarea class="textarea" placeholder={localKinkText} value={localKinkText} onChange={e => { 1200 + setLocalKinkText(e.currentTarget.value); 1201 + }} style={{'--bulma-textarea-min-height': '24em'}} /> 1202 + </div> 1203 + 1204 + <div class="field is-grouped"> 1205 + <button class="button is-primary" onClick={save}>Save</button> 1206 + <button class="button is-secondary" onClick={resetToDefault}>Reset to Default</button> 1207 + </div> 1208 + {/* {user.value && <button class="button is-primary" onClick={publish}>Publish to ATProto</button>} */} 1209 + </div> 1210 + ) 1211 + } 1212 + 775 1213 function RealRoot() { 776 1214 const [changelogOpen, setChangelogOpen] = useState(false); 777 1215 const [exportImageOpen, setExportImageOpen] = useState(false); 778 1216 const [exportModalContent, setExportModalContent] = useState<HTMLElement | null>(null); 779 1217 const [quizModeOpen, setQuizModeOpen] = useState(false); 780 1218 const [navbarMenuActive, setNavbarMenuActive] = useState(false); 1219 + const [friendsOpen, setFriendsOpen] = useState(false); 1220 + const [customizeListOpen, setCustomizeListOpen] = useState(false); 1221 + const [exportDropdownActive, setExportDropdownActive] = useState(false); 781 1222 782 1223 function exportImageCallback() { 783 1224 const canvas = exportImage(kinkData.value.kinkCategories, kinkData.value.kinksById, (kink, participant) => getSelectedKinkOrDefault(kink, participant).value); ··· 866 1307 } 867 1308 868 1309 await user.value.client.createOrUpdateProfile({ 869 - kinks: kinkData.value.kinkCategories.flatMap((category) => 870 - category.kinks.flatMap((kink) => 871 - category.participants.map((participant) => ({ 872 - section: category.name, 873 - name: kink.name, 874 - participant: participant, 875 - choice: getSelectedKinkOrDefault(kink, participant).value, 876 - })), 877 - ), 878 - ), 1310 + kinks: getKinksArray(), 1311 + kinkDefinitions: kinkData.value.kinkCategories 879 1312 }); 880 1313 881 1314 return true; ··· 930 1363 localStorage.removeItem("oauth-redirect-action"); 931 1364 } 932 1365 } 1366 + 1367 + if (areAllKinksUnset() && user.value) { 1368 + const profile = await user.value.client.tryGetOwnProfile(); 1369 + // Update local kinks with the ones from the profile 1370 + if (profile.value) { 1371 + for (const profileKink of profile.value.kinks) { 1372 + const kink = kinkData.value.kinkCategories 1373 + .find(category => category.name === profileKink.section) 1374 + ?.kinks.find(kink => kink.name === profileKink.name); 1375 + 1376 + if (kink) { 1377 + setKinkSelection(kink, profileKink.participant, profileKink.choice as Choice, false); 1378 + } 1379 + } 1380 + window.location.hash = serializeChoices(); 1381 + } 1382 + } 933 1383 }); 934 1384 935 1385 if (hash && !document.location.pathname.endsWith("/oauth-redirect.html")) { ··· 951 1401 <h1 class="title">Kink list</h1> 952 1402 </div> 953 1403 954 - <a class="navbar-item" onClick={e => { 955 - e.preventDefault(); 956 - e.stopPropagation(); 957 - 958 - if (!darkThemeChecked) { 959 - localStorage.setItem("theme", "dark"); 960 - document.documentElement.classList.add("theme-dark"); 961 - setDarkThemeChecked(true); 962 - } else { 963 - localStorage.setItem("theme", "light"); 964 - document.documentElement.classList.remove("theme-dark"); 965 - setDarkThemeChecked(false); 966 - } 967 - }}> 968 - <label class="checkbox"> 969 - <input 970 - id="dark-theme" 971 - type="checkbox" 972 - onChange={(event) => { 973 - if (!darkThemeChecked) { 974 - localStorage.setItem("theme", "dark"); 975 - document.documentElement.classList.add("theme-dark"); 976 - setDarkThemeChecked(true); 977 - } else { 978 - localStorage.setItem("theme", "light"); 979 - document.documentElement.classList.remove("theme-dark"); 980 - setDarkThemeChecked(false); 981 - } 982 - }} 983 - checked={darkThemeChecked} 984 - />{" "} 985 - Dark theme 986 - </label> 987 - </a> 988 - 989 1404 <div role="button" class={`navbar-burger ${navbarMenuActive ? "is-active" : ""}`} onClick={() => setNavbarMenuActive(!navbarMenuActive)} aria-label="menu" aria-expanded={navbarMenuActive} data-target="navMenu"> 990 1405 <span aria-hidden="true"></span> 991 1406 <span aria-hidden="true"></span> ··· 996 1411 997 1412 <div class={`navbar-menu ${navbarMenuActive ? "is-active" : ""}`}> 998 1413 <div class="navbar-start"> 999 - <a class="navbar-item" id="export-image" onClick={exportImageCallback}> 1000 - Export to Clipboard 1001 - </a> 1414 + <div class={`navbar-item has-dropdown ${exportDropdownActive ? "is-active" : ""}`} onClick={() => setExportDropdownActive(!exportDropdownActive)}> 1415 + <a class="navbar-link"> 1416 + Export 1417 + </a> 1002 1418 1003 - <Modal open={exportImageOpen} onClose={() => setExportImageOpen(false)} container={document.body}> 1004 - <div class="box"> 1005 - <h1 class="title">Exported! Copy the image to your clipboard or save it now.</h1> 1006 - <div id="export-modal-content">{exportModalContent}</div> 1007 - </div> 1008 - </Modal> 1419 + <div class="navbar-dropdown"> 1420 + <a class="navbar-item" onClick={exportImageCallback}> 1421 + to Clipboard 1422 + </a> 1009 1423 1010 - <a class="navbar-item" onClick={exportAtprotoCallback}> 1011 - Export to ATProto 1012 - </a> 1424 + <Modal open={exportImageOpen} onClose={() => setExportImageOpen(false)} container={document.body}> 1425 + <div class="box"> 1426 + <h1 class="title">Exported! Copy the image to your clipboard or save it now.</h1> 1427 + <div id="export-modal-content">{exportModalContent}</div> 1428 + </div> 1429 + </Modal> 1013 1430 1431 + <a class="navbar-item" onClick={exportAtprotoCallback}> 1432 + to ATProto 1433 + </a> 1434 + </div> 1435 + </div> 1014 1436 <a class="navbar-item" onClick={() => setChangelogOpen(true)}> 1015 1437 Changelog 1016 1438 </a> ··· 1038 1460 <Modal open={quizModeOpen} onClose={() => setQuizModeOpen(false)} container={document.body}> 1039 1461 <QuizMode /> 1040 1462 </Modal> 1463 + 1464 + <a class="navbar-item" id="customize-list" onClick={() => setCustomizeListOpen(true)}> 1465 + Customize List 1466 + </a> 1467 + 1468 + <Modal open={customizeListOpen} onClose={() => setCustomizeListOpen(false)} container={document.body}> 1469 + <CustomizeList onClose={() => setCustomizeListOpen(false)} /> 1470 + </Modal> 1041 1471 </div> 1042 1472 1043 1473 {hasInitialSession && <div class="navbar-end"> 1044 1474 {user.value 1045 1475 ? <> 1476 + <a class="navbar-item" onClick={() => setFriendsOpen(true)}> 1477 + Friends 1478 + </a> 1046 1479 <div class="navbar-item"> 1047 - Signed in as @{user.value.handle} 1480 + @{user.value.handle} 1048 1481 </div> 1049 1482 <a class="navbar-item" onClick={() => oauthClient.revokeSessions()}> 1050 1483 Sign out ··· 1056 1489 </a> 1057 1490 )} 1058 1491 </div>} 1492 + 1493 + <Modal open={friendsOpen} onClose={() => setFriendsOpen(false)} container={document.body}> 1494 + {friendsOpen && <Friends />} 1495 + </Modal> 1496 + 1497 + 1498 + <a class="navbar-item" onClick={e => { 1499 + e.preventDefault(); 1500 + e.stopPropagation(); 1501 + 1502 + if (!darkThemeChecked) { 1503 + localStorage.setItem("theme", "dark"); 1504 + document.documentElement.classList.add("theme-dark"); 1505 + setDarkThemeChecked(true); 1506 + } else { 1507 + localStorage.setItem("theme", "light"); 1508 + document.documentElement.classList.remove("theme-dark"); 1509 + setDarkThemeChecked(false); 1510 + } 1511 + }}> 1512 + {darkThemeChecked ? <FontAwesomeIcon icon={faSun} /> : <FontAwesomeIcon icon={faMoon} />} 1513 + </a> 1059 1514 </div> 1060 1515 </nav> 1061 1516 ··· 1073 1528 } 1074 1529 1075 1530 render(<RealRoot />, root!); 1531 + function getKinksArray(): KinksArray { 1532 + return kinkData.value.kinkCategories.flatMap((category) => 1533 + category.kinks.flatMap((kink) => 1534 + category.participants.map((participant) => ({ 1535 + section: category.name, 1536 + name: kink.name, 1537 + participant: participant, 1538 + choice: getSelectedKinkOrDefault(kink, participant).value, 1539 + })), 1540 + ), 1541 + ); 1542 + } 1543 +
+27
typelex/main.tsp
··· 5 5 /** My kink list profile. */ 6 6 @rec("literal:self") 7 7 model Main { 8 + /** Array of kink category/section definitions */ 9 + kinkDefinitions?: KinkCategory[]; 10 + 8 11 /** Array of kink preferences */ 9 12 @required kinks: KinkEntry[]; 10 13 ··· 13 16 14 17 /** When this profile was last updated */ 15 18 @required updatedAt: datetime; 19 + } 20 + 21 + /** A category/section definition of kinks, containing multiple kink entries */ 22 + model KinkCategory { 23 + /** The category/section name (e.g., "General", "Taboo", "Bodies") */ 24 + @required name: string; 25 + 26 + /** Description of the category/section (e.g., "General kinks", "Taboo kinks", "Bodies kinks") */ 27 + description?: string; 28 + 29 + /** Array of kink definitions */ 30 + @required kinks: KinkDefinition[]; 31 + 32 + /** Array of participant types (e.g., "Self", "Partner", "Giving", "Receiving") */ 33 + @required participants: string[]; 34 + } 35 + 36 + /** A single kink definition within a category, describing the kink and its possible preferences */ 37 + model KinkDefinition { 38 + /** The name of the kink (e.g., "Bondage", "Voyeurism") */ 39 + @required name: string; 40 + 41 + /** Description of the kink (e.g., "Restraining or being restrained", "Watching others engage in sexual activities") */ 42 + description?: string; 16 43 } 17 44 18 45 /** A single kink entry with preferences */