This repository has no description
0

Configure Feed

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

Merge branch 'development' into posthog-setup

+1802 -1134
+21 -215
package-lock.json
··· 6120 6120 "node": ">=10" 6121 6121 } 6122 6122 }, 6123 - "node_modules/@parcel/optimizer-swc/node_modules/@swc/helpers": { 6124 - "version": "0.5.17", 6125 - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", 6126 - "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", 6127 - "dev": true, 6128 - "optional": true, 6129 - "peer": true, 6130 - "dependencies": { 6131 - "tslib": "^2.8.0" 6132 - } 6133 - }, 6134 6123 "node_modules/@parcel/package-manager": { 6135 6124 "version": "2.9.3", 6136 6125 "resolved": "https://registry.npmjs.org/@parcel/package-manager/-/package-manager-2.9.3.tgz", ··· 7280 7269 "version": "2.5.1", 7281 7270 "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", 7282 7271 "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", 7283 - "dev": true, 7272 + "devOptional": true, 7284 7273 "hasInstallScript": true, 7285 7274 "dependencies": { 7286 7275 "detect-libc": "^1.0.3", ··· 7318 7307 "cpu": [ 7319 7308 "arm64" 7320 7309 ], 7321 - "dev": true, 7322 7310 "optional": true, 7323 7311 "os": [ 7324 7312 "android" 7325 7313 ], 7326 - "peer": true, 7327 7314 "engines": { 7328 7315 "node": ">= 10.0.0" 7329 7316 }, ··· 7339 7326 "cpu": [ 7340 7327 "arm64" 7341 7328 ], 7342 - "dev": true, 7343 7329 "optional": true, 7344 7330 "os": [ 7345 7331 "darwin" 7346 7332 ], 7347 - "peer": true, 7348 7333 "engines": { 7349 7334 "node": ">= 10.0.0" 7350 7335 }, ··· 7360 7345 "cpu": [ 7361 7346 "x64" 7362 7347 ], 7363 - "dev": true, 7364 7348 "optional": true, 7365 7349 "os": [ 7366 7350 "darwin" 7367 7351 ], 7368 - "peer": true, 7369 7352 "engines": { 7370 7353 "node": ">= 10.0.0" 7371 7354 }, ··· 7381 7364 "cpu": [ 7382 7365 "x64" 7383 7366 ], 7384 - "dev": true, 7385 7367 "optional": true, 7386 7368 "os": [ 7387 7369 "freebsd" 7388 7370 ], 7389 - "peer": true, 7390 7371 "engines": { 7391 7372 "node": ">= 10.0.0" 7392 7373 }, ··· 7402 7383 "cpu": [ 7403 7384 "arm" 7404 7385 ], 7405 - "dev": true, 7406 7386 "optional": true, 7407 7387 "os": [ 7408 7388 "linux" 7409 7389 ], 7410 - "peer": true, 7411 7390 "engines": { 7412 7391 "node": ">= 10.0.0" 7413 7392 }, ··· 7423 7402 "cpu": [ 7424 7403 "arm" 7425 7404 ], 7426 - "dev": true, 7427 7405 "optional": true, 7428 7406 "os": [ 7429 7407 "linux" 7430 7408 ], 7431 - "peer": true, 7432 7409 "engines": { 7433 7410 "node": ">= 10.0.0" 7434 7411 }, ··· 7444 7421 "cpu": [ 7445 7422 "arm64" 7446 7423 ], 7447 - "dev": true, 7448 7424 "optional": true, 7449 7425 "os": [ 7450 7426 "linux" 7451 7427 ], 7452 - "peer": true, 7453 7428 "engines": { 7454 7429 "node": ">= 10.0.0" 7455 7430 }, ··· 7465 7440 "cpu": [ 7466 7441 "arm64" 7467 7442 ], 7468 - "dev": true, 7469 7443 "optional": true, 7470 7444 "os": [ 7471 7445 "linux" 7472 7446 ], 7473 - "peer": true, 7474 7447 "engines": { 7475 7448 "node": ">= 10.0.0" 7476 7449 }, ··· 7486 7459 "cpu": [ 7487 7460 "x64" 7488 7461 ], 7489 - "dev": true, 7490 7462 "optional": true, 7491 7463 "os": [ 7492 7464 "linux" 7493 7465 ], 7494 - "peer": true, 7495 7466 "engines": { 7496 7467 "node": ">= 10.0.0" 7497 7468 }, ··· 7507 7478 "cpu": [ 7508 7479 "x64" 7509 7480 ], 7510 - "dev": true, 7511 7481 "optional": true, 7512 7482 "os": [ 7513 7483 "linux" 7514 7484 ], 7515 - "peer": true, 7516 7485 "engines": { 7517 7486 "node": ">= 10.0.0" 7518 7487 }, ··· 7528 7497 "cpu": [ 7529 7498 "arm64" 7530 7499 ], 7531 - "dev": true, 7532 7500 "optional": true, 7533 7501 "os": [ 7534 7502 "win32" 7535 7503 ], 7536 - "peer": true, 7537 7504 "engines": { 7538 7505 "node": ">= 10.0.0" 7539 7506 }, ··· 7549 7516 "cpu": [ 7550 7517 "ia32" 7551 7518 ], 7552 - "dev": true, 7553 7519 "optional": true, 7554 7520 "os": [ 7555 7521 "win32" 7556 7522 ], 7557 - "peer": true, 7558 7523 "engines": { 7559 7524 "node": ">= 10.0.0" 7560 7525 }, ··· 7570 7535 "cpu": [ 7571 7536 "x64" 7572 7537 ], 7573 - "dev": true, 7574 7538 "optional": true, 7575 7539 "os": [ 7576 7540 "win32" 7577 7541 ], 7578 - "peer": true, 7579 7542 "engines": { 7580 7543 "node": ">= 10.0.0" 7581 7544 }, ··· 7588 7551 "version": "1.0.3", 7589 7552 "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", 7590 7553 "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", 7591 - "dev": true, 7554 + "devOptional": true, 7592 7555 "bin": { 7593 7556 "detect-libc": "bin/detect-libc.js" 7594 7557 }, ··· 9142 9105 "node": ">=10" 9143 9106 } 9144 9107 }, 9145 - "node_modules/@plasmohq/parcel-transformer-vue/node_modules/react": { 9146 - "version": "18.3.1", 9147 - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", 9148 - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", 9149 - "dev": true, 9150 - "optional": true, 9151 - "peer": true, 9152 - "dependencies": { 9153 - "loose-envify": "^1.1.0" 9154 - }, 9155 - "engines": { 9156 - "node": ">=0.10.0" 9157 - } 9158 - }, 9159 - "node_modules/@plasmohq/parcel-transformer-vue/node_modules/react-dom": { 9160 - "version": "18.3.1", 9161 - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", 9162 - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", 9163 - "dev": true, 9164 - "optional": true, 9165 - "peer": true, 9166 - "dependencies": { 9167 - "loose-envify": "^1.1.0", 9168 - "scheduler": "^0.23.2" 9169 - }, 9170 - "peerDependencies": { 9171 - "react": "^18.3.1" 9172 - } 9173 - }, 9174 - "node_modules/@plasmohq/parcel-transformer-vue/node_modules/scheduler": { 9175 - "version": "0.23.2", 9176 - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", 9177 - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", 9178 - "dev": true, 9179 - "optional": true, 9180 - "peer": true, 9181 - "dependencies": { 9182 - "loose-envify": "^1.1.0" 9183 - } 9184 - }, 9185 9108 "node_modules/@plasmohq/parcel-transformer-vue/node_modules/semver": { 9186 9109 "version": "7.5.4", 9187 9110 "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", ··· 10468 10391 "os": [ 10469 10392 "darwin" 10470 10393 ], 10471 - "peer": true, 10472 10394 "engines": { 10473 10395 "node": ">=10" 10474 10396 } ··· 10485 10407 "os": [ 10486 10408 "darwin" 10487 10409 ], 10488 - "peer": true, 10489 10410 "engines": { 10490 10411 "node": ">=10" 10491 10412 } ··· 10502 10423 "os": [ 10503 10424 "linux" 10504 10425 ], 10505 - "peer": true, 10506 10426 "engines": { 10507 10427 "node": ">=10" 10508 10428 } ··· 10519 10439 "os": [ 10520 10440 "linux" 10521 10441 ], 10522 - "peer": true, 10523 10442 "engines": { 10524 10443 "node": ">=10" 10525 10444 } ··· 10536 10455 "os": [ 10537 10456 "linux" 10538 10457 ], 10539 - "peer": true, 10540 10458 "engines": { 10541 10459 "node": ">=10" 10542 10460 } ··· 10553 10471 "os": [ 10554 10472 "linux" 10555 10473 ], 10556 - "peer": true, 10557 10474 "engines": { 10558 10475 "node": ">=10" 10559 10476 } ··· 10570 10487 "os": [ 10571 10488 "linux" 10572 10489 ], 10573 - "peer": true, 10574 10490 "engines": { 10575 10491 "node": ">=10" 10576 10492 } ··· 10587 10503 "os": [ 10588 10504 "win32" 10589 10505 ], 10590 - "peer": true, 10591 10506 "engines": { 10592 10507 "node": ">=10" 10593 10508 } ··· 10604 10519 "os": [ 10605 10520 "win32" 10606 10521 ], 10607 - "peer": true, 10608 10522 "engines": { 10609 10523 "node": ">=10" 10610 10524 } ··· 10621 10535 "os": [ 10622 10536 "win32" 10623 10537 ], 10624 - "peer": true, 10625 10538 "engines": { 10626 10539 "node": ">=10" 10627 10540 } ··· 13209 13122 "version": "3.0.3", 13210 13123 "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", 13211 13124 "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", 13212 - "dev": true, 13125 + "devOptional": true, 13213 13126 "dependencies": { 13214 13127 "fill-range": "^7.1.1" 13215 13128 }, ··· 14591 14504 "node": ">=4" 14592 14505 } 14593 14506 }, 14594 - "node_modules/csso": { 14595 - "version": "5.0.5", 14596 - "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", 14597 - "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", 14598 - "dev": true, 14599 - "optional": true, 14600 - "peer": true, 14601 - "dependencies": { 14602 - "css-tree": "~2.2.0" 14603 - }, 14604 - "engines": { 14605 - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", 14606 - "npm": ">=7.0.0" 14607 - } 14608 - }, 14609 - "node_modules/csso/node_modules/css-tree": { 14610 - "version": "2.2.1", 14611 - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", 14612 - "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", 14613 - "dev": true, 14614 - "optional": true, 14615 - "peer": true, 14616 - "dependencies": { 14617 - "mdn-data": "2.0.28", 14618 - "source-map-js": "^1.0.1" 14619 - }, 14620 - "engines": { 14621 - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", 14622 - "npm": ">=7.0.0" 14623 - } 14624 - }, 14625 - "node_modules/csso/node_modules/mdn-data": { 14626 - "version": "2.0.28", 14627 - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", 14628 - "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", 14629 - "dev": true, 14630 - "optional": true, 14631 - "peer": true 14632 - }, 14633 14507 "node_modules/cssstyle": { 14634 14508 "version": "4.6.0", 14635 14509 "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", ··· 15520 15394 "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==", 15521 15395 "dev": true, 15522 15396 "optional": true, 15523 - "peer": true, 15524 15397 "dependencies": { 15525 15398 "prr": "~1.0.1" 15526 15399 }, ··· 16931 16804 "version": "7.1.1", 16932 16805 "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", 16933 16806 "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", 16934 - "dev": true, 16807 + "devOptional": true, 16935 16808 "dependencies": { 16936 16809 "to-regex-range": "^5.0.1" 16937 16810 }, ··· 17934 17807 "integrity": "sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==", 17935 17808 "dev": true, 17936 17809 "optional": true, 17937 - "peer": true, 17938 17810 "bin": { 17939 17811 "image-size": "bin/image-size.js" 17940 17812 }, ··· 18320 18192 "version": "2.1.1", 18321 18193 "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", 18322 18194 "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", 18323 - "dev": true, 18195 + "devOptional": true, 18324 18196 "engines": { 18325 18197 "node": ">=0.10.0" 18326 18198 } ··· 18381 18253 "version": "4.0.3", 18382 18254 "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", 18383 18255 "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", 18384 - "dev": true, 18256 + "devOptional": true, 18385 18257 "dependencies": { 18386 18258 "is-extglob": "^2.1.1" 18387 18259 }, ··· 18432 18304 "version": "7.0.0", 18433 18305 "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", 18434 18306 "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", 18435 - "dev": true, 18307 + "devOptional": true, 18436 18308 "engines": { 18437 18309 "node": ">=0.12.0" 18438 18310 } ··· 18811 18683 "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", 18812 18684 "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", 18813 18685 "dev": true, 18686 + "license": "MIT", 18814 18687 "dependencies": { 18815 18688 "@jest/core": "^29.7.0", 18816 18689 "@jest/types": "^29.6.3", ··· 19826 19699 "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", 19827 19700 "dev": true, 19828 19701 "optional": true, 19829 - "peer": true, 19830 19702 "dependencies": { 19831 19703 "pify": "^4.0.1", 19832 19704 "semver": "^5.6.0" ··· 19841 19713 "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", 19842 19714 "dev": true, 19843 19715 "optional": true, 19844 - "peer": true, 19845 19716 "bin": { 19846 19717 "mime": "cli.js" 19847 19718 }, ··· 19855 19726 "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", 19856 19727 "dev": true, 19857 19728 "optional": true, 19858 - "peer": true, 19859 19729 "bin": { 19860 19730 "semver": "bin/semver" 19861 19731 } ··· 19866 19736 "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", 19867 19737 "dev": true, 19868 19738 "optional": true, 19869 - "peer": true, 19870 19739 "engines": { 19871 19740 "node": ">=0.10.0" 19872 19741 } ··· 19934 19803 "os": [ 19935 19804 "android" 19936 19805 ], 19937 - "peer": true, 19938 19806 "engines": { 19939 19807 "node": ">= 12.0.0" 19940 19808 }, ··· 19955 19823 "os": [ 19956 19824 "darwin" 19957 19825 ], 19958 - "peer": true, 19959 19826 "engines": { 19960 19827 "node": ">= 12.0.0" 19961 19828 }, ··· 19976 19843 "os": [ 19977 19844 "darwin" 19978 19845 ], 19979 - "peer": true, 19980 19846 "engines": { 19981 19847 "node": ">= 12.0.0" 19982 19848 }, ··· 19997 19863 "os": [ 19998 19864 "freebsd" 19999 19865 ], 20000 - "peer": true, 20001 19866 "engines": { 20002 19867 "node": ">= 12.0.0" 20003 19868 }, ··· 20018 19883 "os": [ 20019 19884 "linux" 20020 19885 ], 20021 - "peer": true, 20022 19886 "engines": { 20023 19887 "node": ">= 12.0.0" 20024 19888 }, ··· 20039 19903 "os": [ 20040 19904 "linux" 20041 19905 ], 20042 - "peer": true, 20043 19906 "engines": { 20044 19907 "node": ">= 12.0.0" 20045 19908 }, ··· 20060 19923 "os": [ 20061 19924 "linux" 20062 19925 ], 20063 - "peer": true, 20064 19926 "engines": { 20065 19927 "node": ">= 12.0.0" 20066 19928 }, ··· 20081 19943 "os": [ 20082 19944 "linux" 20083 19945 ], 20084 - "peer": true, 20085 19946 "engines": { 20086 19947 "node": ">= 12.0.0" 20087 19948 }, ··· 20102 19963 "os": [ 20103 19964 "linux" 20104 19965 ], 20105 - "peer": true, 20106 19966 "engines": { 20107 19967 "node": ">= 12.0.0" 20108 19968 }, ··· 20123 19983 "os": [ 20124 19984 "win32" 20125 19985 ], 20126 - "peer": true, 20127 19986 "engines": { 20128 19987 "node": ">= 12.0.0" 20129 19988 }, ··· 20144 20003 "os": [ 20145 20004 "win32" 20146 20005 ], 20147 - "peer": true, 20148 20006 "engines": { 20149 20007 "node": ">= 12.0.0" 20150 20008 }, ··· 21264 21122 "version": "4.0.8", 21265 21123 "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", 21266 21124 "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", 21267 - "dev": true, 21125 + "devOptional": true, 21268 21126 "dependencies": { 21269 21127 "braces": "^3.0.3", 21270 21128 "picomatch": "^2.3.1" ··· 21277 21135 "version": "2.3.1", 21278 21136 "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", 21279 21137 "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", 21280 - "dev": true, 21138 + "devOptional": true, 21281 21139 "engines": { 21282 21140 "node": ">=8.6" 21283 21141 }, ··· 21545 21403 "integrity": "sha512-6k0YULvhpw+RoLNiQCRKOl09Rv1dPLr8hHnVjHqdolKwDrdNyk+Hmrthi4lIGPPz3r39dLx0hsF5s40sZ3Us4Q==", 21546 21404 "dev": true, 21547 21405 "optional": true, 21548 - "peer": true, 21549 21406 "dependencies": { 21550 21407 "iconv-lite": "^0.6.3", 21551 21408 "sax": "^1.2.4" ··· 21658 21515 "version": "7.1.1", 21659 21516 "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", 21660 21517 "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", 21661 - "dev": true 21518 + "devOptional": true 21662 21519 }, 21663 21520 "node_modules/node-fetch": { 21664 21521 "version": "2.7.0", ··· 22619 22476 "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", 22620 22477 "dev": true, 22621 22478 "optional": true, 22622 - "peer": true, 22623 22479 "engines": { 22624 22480 "node": ">=6" 22625 22481 } ··· 23886 23742 "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", 23887 23743 "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==", 23888 23744 "dev": true, 23889 - "optional": true, 23890 - "peer": true 23745 + "optional": true 23891 23746 }, 23892 23747 "node_modules/pstree.remy": { 23893 23748 "version": "1.1.8", ··· 25026 24881 "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", 25027 24882 "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", 25028 24883 "dev": true, 25029 - "optional": true, 25030 - "peer": true 24884 + "optional": true 25031 24885 }, 25032 24886 "node_modules/saxes": { 25033 24887 "version": "6.0.0", ··· 25456 25310 "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", 25457 25311 "dev": true 25458 25312 }, 25459 - "node_modules/srcset": { 25460 - "version": "5.0.1", 25461 - "resolved": "https://registry.npmjs.org/srcset/-/srcset-5.0.1.tgz", 25462 - "integrity": "sha512-/P1UYbGfJVlxZag7aABNRrulEXAwCSDo7fklafOQrantuPTDmYgijJMks2zusPCVzgW9+4P69mq7w6pYuZpgxw==", 25463 - "dev": true, 25464 - "optional": true, 25465 - "peer": true, 25466 - "engines": { 25467 - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" 25468 - }, 25469 - "funding": { 25470 - "url": "https://github.com/sponsors/sindresorhus" 25471 - } 25472 - }, 25473 25313 "node_modules/ssh-remote-port-forward": { 25474 25314 "version": "1.0.4", 25475 25315 "resolved": "https://registry.npmjs.org/ssh-remote-port-forward/-/ssh-remote-port-forward-1.0.4.tgz", ··· 26220 26060 "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==", 26221 26061 "dev": true 26222 26062 }, 26223 - "node_modules/svgo": { 26224 - "version": "3.3.2", 26225 - "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.2.tgz", 26226 - "integrity": "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==", 26227 - "dev": true, 26228 - "optional": true, 26229 - "peer": true, 26230 - "dependencies": { 26231 - "@trysound/sax": "0.2.0", 26232 - "commander": "^7.2.0", 26233 - "css-select": "^5.1.0", 26234 - "css-tree": "^2.3.1", 26235 - "css-what": "^6.1.0", 26236 - "csso": "^5.0.5", 26237 - "picocolors": "^1.0.0" 26238 - }, 26239 - "bin": { 26240 - "svgo": "bin/svgo" 26241 - }, 26242 - "engines": { 26243 - "node": ">=14.0.0" 26244 - }, 26245 - "funding": { 26246 - "type": "opencollective", 26247 - "url": "https://opencollective.com/svgo" 26248 - } 26249 - }, 26250 - "node_modules/svgo/node_modules/commander": { 26251 - "version": "7.2.0", 26252 - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", 26253 - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", 26254 - "dev": true, 26255 - "optional": true, 26256 - "peer": true, 26257 - "engines": { 26258 - "node": ">= 10" 26259 - } 26260 - }, 26261 26063 "node_modules/symbol-tree": { 26262 26064 "version": "3.2.4", 26263 26065 "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", ··· 26584 26386 "version": "5.0.1", 26585 26387 "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", 26586 26388 "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", 26587 - "dev": true, 26389 + "devOptional": true, 26588 26390 "dependencies": { 26589 26391 "is-number": "^7.0.0" 26590 26392 }, ··· 26694 26496 "dev": true 26695 26497 }, 26696 26498 "node_modules/ts-jest": { 26697 - "version": "29.4.5", 26698 - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.5.tgz", 26699 - "integrity": "sha512-HO3GyiWn2qvTQA4kTgjDcXiMwYQt68a1Y8+JuLRVpdIzm+UOLSHgl/XqR4c6nzJkq5rOkjc02O2I7P7l/Yof0Q==", 26499 + "version": "29.4.6", 26500 + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", 26501 + "integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==", 26700 26502 "dev": true, 26503 + "license": "MIT", 26701 26504 "dependencies": { 26702 26505 "bs-logger": "^0.2.6", 26703 26506 "fast-json-stable-stringify": "^2.1.0", ··· 28397 28200 "url-metadata": "^5.2.3" 28398 28201 }, 28399 28202 "devDependencies": { 28203 + "@types/jest": "^29.5.14", 28400 28204 "@types/node": "^20.10.4", 28205 + "jest": "^29.7.0", 28206 + "ts-jest": "^29.4.6", 28401 28207 "typescript": "^5.8.3" 28402 28208 } 28403 28209 },
+43
src/modules/atproto/infrastructure/services/BlueskyProfileService.ts
··· 78 78 name: profile.displayName || profile.handle, 79 79 handle: profile.handle, 80 80 avatarUrl: profile.avatar, 81 + bannerUrl: profile.banner, 81 82 bio: profile.description, 82 83 }; 83 84 85 + // Fetch follower/following counts in parallel 86 + const [ 87 + followerCountResult, 88 + followingCountResult, 89 + collectionsCountResult, 90 + ] = await Promise.all([ 91 + this.followsRepository.getFollowersCount(userId, FollowTargetType.USER), 92 + this.followsRepository.getFollowingCount(userId, FollowTargetType.USER), 93 + this.followsRepository.getFollowingCount( 94 + userId, 95 + FollowTargetType.COLLECTION, 96 + ), 97 + ]); 98 + 99 + // Add counts to profile (default to 0 on error) 100 + const followerCount = followerCountResult.isOk() 101 + ? followerCountResult.value 102 + : 0; 103 + const followingCount = followingCountResult.isOk() 104 + ? followingCountResult.value 105 + : 0; 106 + const followedCollectionsCount = collectionsCountResult.isOk() 107 + ? collectionsCountResult.value 108 + : 0; 109 + 84 110 // Add follow status if callerId is provided 85 111 let isFollowing: boolean | undefined = undefined; 112 + let followsYou: boolean | undefined = undefined; 86 113 if (callerDid && callerDid !== userId) { 87 114 const followResult = 88 115 await this.followsRepository.findByFollowerAndTarget( ··· 94 121 if (followResult.isOk()) { 95 122 isFollowing = followResult.value !== null; 96 123 } 124 + 125 + // Check if the profile user follows the caller 126 + const followsYouResult = 127 + await this.followsRepository.findByFollowerAndTarget( 128 + userId, 129 + callerDid, 130 + FollowTargetType.USER, 131 + ); 132 + 133 + if (followsYouResult.isOk()) { 134 + followsYou = followsYouResult.value !== null; 135 + } 97 136 } 98 137 99 138 return ok({ 100 139 ...userProfile, 101 140 isFollowing, 141 + followsYou, 142 + followerCount, 143 + followingCount, 144 + followedCollectionsCount, 102 145 }); 103 146 } catch (error) { 104 147 return err(
+117 -8
src/modules/atproto/infrastructure/services/CachedBlueskyProfileService.ts
··· 10 10 export class CachedBlueskyProfileService implements IProfileService { 11 11 private readonly CACHE_TTL_SECONDS = 3600 * 12; // 12 hours 12 12 private readonly CACHE_KEY_PREFIX = 'profile:'; 13 + private readonly COUNTS_CACHE_TTL_SECONDS = 900; // 15 minutes 14 + private readonly COUNTS_CACHE_KEY_PREFIX = 'profile:counts:'; 13 15 14 16 constructor( 15 17 private readonly profileService: IProfileService, ··· 21 23 userId: string, 22 24 callerId?: string, 23 25 ): Promise<Result<UserProfile>> { 24 - const cacheKey = this.getCacheKey(userId); 26 + const profileCacheKey = this.getCacheKey(userId); 27 + const countsCacheKey = this.getCountsCacheKey(userId); 25 28 26 29 try { 30 + // Fetch both profile and counts from cache in parallel 31 + const [cachedProfile, cachedCounts] = await Promise.all([ 32 + this.redis.get(profileCacheKey), 33 + this.redis.get(countsCacheKey), 34 + ]); 35 + 27 36 let profile: UserProfile; 37 + let counts: { 38 + followerCount: number; 39 + followingCount: number; 40 + followedCollectionsCount: number; 41 + }; 28 42 29 - // Try cache first (without follow status) 30 - const cached = await this.redis.get(cacheKey); 31 - if (cached) { 43 + // Handle profile cache 44 + if (cachedProfile) { 32 45 try { 33 - profile = JSON.parse(cached) as UserProfile; 46 + profile = JSON.parse(cachedProfile) as UserProfile; 34 47 } catch (parseError) { 35 48 // If JSON parsing fails, continue to fetch fresh data 36 49 console.warn( ··· 54 67 55 68 profile = result.value; 56 69 57 - // Cache the profile (without follow status) 70 + // Cache the profile (without follow status and counts) 58 71 try { 59 72 const profileToCache = { ...profile }; 60 - delete profileToCache.isFollowing; // Don't cache follow status 73 + delete profileToCache.isFollowing; 74 + delete profileToCache.followerCount; 75 + delete profileToCache.followingCount; 76 + delete profileToCache.followedCollectionsCount; 61 77 await this.redis.setex( 62 - cacheKey, 78 + profileCacheKey, 63 79 this.CACHE_TTL_SECONDS, 64 80 JSON.stringify(profileToCache), 65 81 ); ··· 69 85 } 70 86 } 71 87 88 + // Handle counts cache 89 + if (cachedCounts) { 90 + try { 91 + counts = JSON.parse(cachedCounts) as { 92 + followerCount: number; 93 + followingCount: number; 94 + followedCollectionsCount: number; 95 + }; 96 + } catch (parseError) { 97 + console.warn( 98 + `Failed to parse cached counts for ${userId}:`, 99 + parseError, 100 + ); 101 + // Use counts from profile or defaults 102 + counts = { 103 + followerCount: profile.followerCount ?? 0, 104 + followingCount: profile.followingCount ?? 0, 105 + followedCollectionsCount: profile.followedCollectionsCount ?? 0, 106 + }; 107 + } 108 + } else { 109 + // Counts cache miss - use from profile or fetch fresh 110 + if ( 111 + profile.followerCount !== undefined && 112 + profile.followingCount !== undefined && 113 + profile.followedCollectionsCount !== undefined 114 + ) { 115 + counts = { 116 + followerCount: profile.followerCount, 117 + followingCount: profile.followingCount, 118 + followedCollectionsCount: profile.followedCollectionsCount, 119 + }; 120 + } else { 121 + // If profile doesn't have counts, fetch fresh 122 + const result = await this.profileService.getProfile(userId, callerId); 123 + if (result.isOk()) { 124 + counts = { 125 + followerCount: result.value.followerCount ?? 0, 126 + followingCount: result.value.followingCount ?? 0, 127 + followedCollectionsCount: 128 + result.value.followedCollectionsCount ?? 0, 129 + }; 130 + } else { 131 + counts = { 132 + followerCount: 0, 133 + followingCount: 0, 134 + followedCollectionsCount: 0, 135 + }; 136 + } 137 + } 138 + 139 + // Cache the counts 140 + try { 141 + await this.redis.setex( 142 + countsCacheKey, 143 + this.COUNTS_CACHE_TTL_SECONDS, 144 + JSON.stringify(counts), 145 + ); 146 + } catch (cacheError) { 147 + console.warn(`Failed to cache counts for ${userId}:`, cacheError); 148 + } 149 + } 150 + 72 151 // Add follow status if callerId is provided 73 152 let isFollowing: boolean | undefined = undefined; 153 + let followsYou: boolean | undefined = undefined; 74 154 if (callerId && callerId !== userId) { 75 155 const followResult = 76 156 await this.followsRepository.findByFollowerAndTarget( ··· 82 162 if (followResult.isOk()) { 83 163 isFollowing = followResult.value !== null; 84 164 } 165 + 166 + // Check if the profile user follows the caller 167 + const followsYouResult = 168 + await this.followsRepository.findByFollowerAndTarget( 169 + userId, 170 + callerId, 171 + FollowTargetType.USER, 172 + ); 173 + 174 + if (followsYouResult.isOk()) { 175 + followsYou = followsYouResult.value !== null; 176 + } 85 177 } 86 178 87 179 return ok({ 88 180 ...profile, 89 181 isFollowing, 182 + followsYou, 183 + ...counts, 90 184 }); 91 185 } catch (redisError) { 92 186 // If Redis is down, fall back to direct service call ··· 102 196 return `${this.CACHE_KEY_PREFIX}${userId}`; 103 197 } 104 198 199 + private getCountsCacheKey(userId: string): string { 200 + return `${this.COUNTS_CACHE_KEY_PREFIX}${userId}`; 201 + } 202 + 105 203 /** 106 204 * Invalidate cached profile for a specific user 107 205 */ ··· 110 208 await this.redis.del(this.getCacheKey(userId)); 111 209 } catch (error) { 112 210 console.warn(`Failed to invalidate profile cache for ${userId}:`, error); 211 + } 212 + } 213 + 214 + /** 215 + * Invalidate cached counts for a specific user 216 + */ 217 + async invalidateCounts(userId: string): Promise<void> { 218 + try { 219 + await this.redis.del(this.getCountsCacheKey(userId)); 220 + } catch (error) { 221 + console.warn(`Failed to invalidate counts cache for ${userId}:`, error); 113 222 } 114 223 } 115 224
+5
src/modules/atproto/infrastructure/services/FakeAgentService.ts
··· 101 101 avatarUrl: string; 102 102 bio: string; 103 103 did: string; 104 + bannerUrl: string; 104 105 } { 105 106 const did2 = process.env.BSKY_DID_2 || 'did:plc:mock456'; 106 107 ··· 112 113 'https://cdn.bsky.app/img/avatar/plain/did:plc:rlknsba2qldjkicxsmni3vyn/bafkreid4nmxspygkftep5b3m2wlcm3xvnwefkswzej7dhipojjxylkzfby@jpeg', 113 114 bio: 'This is Bob - a second mock profile for testing purposes (https://semble.so/), made by @cosmik.network.', 114 115 did: did2, 116 + bannerUrl: 117 + 'https://cdn.bsky.app/img/banner/plain/did:plc:rlknsba2qldjkicxsmni3vyn/bafkreicgrrurfx5uicisd4nuqhbgn5pihlth5ssjzyl2egg32rxvjvtory@jpeg', 115 118 }; 116 119 } 117 120 ··· 124 127 'https://cdn.bsky.app/img/avatar/plain/did:plc:rlknsba2qldjkicxsmni3vyn/bafkreid4nmxspygkftep5b3m2wlcm3xvnwefkswzej7dhipojjxylkzfby@jpeg', 125 128 bio: 'This is Alice - a mock profile for testing purposes (https://semble.so/), made by @cosmik.network.', 126 129 did: did1, 130 + bannerUrl: 131 + 'https://cdn.bsky.app/img/banner/plain/did:plc:rlknsba2qldjkicxsmni3vyn/bafkreicgrrurfx5uicisd4nuqhbgn5pihlth5ssjzyl2egg32rxvjvtory@jpeg', 127 132 }; 128 133 } 129 134
+5
src/modules/atproto/tests/application/ProcessFirehoseEventUseCase.test.ts
··· 29 29 import { InMemoryFollowsRepository } from '../../../user/tests/infrastructure/InMemoryFollowsRepository'; 30 30 import { InMemoryUserRepository } from '../../../user/tests/infrastructure/InMemoryUserRepository'; 31 31 import { FakeFollowPublisher } from '../../infrastructure/publishers/FakeFollowPublisher'; 32 + import { FakeProfileService } from '../../../cards/tests/utils/FakeProfileService'; 32 33 import { Record as CardRecord } from '../../infrastructure/lexicon/types/network/cosmik/card'; 33 34 import { Record as CollectionRecord } from '../../infrastructure/lexicon/types/network/cosmik/collection'; 34 35 import { Record as CollectionLinkRecord } from '../../infrastructure/lexicon/types/network/cosmik/collectionLink'; ··· 67 68 let followsRepository: InMemoryFollowsRepository; 68 69 let userRepository: InMemoryUserRepository; 69 70 let followPublisher: FakeFollowPublisher; 71 + let profileService: FakeProfileService; 70 72 71 73 beforeEach(() => { 72 74 duplicationService = new InMemoryFirehoseEventDuplicationService(); ··· 188 190 followsRepository = InMemoryFollowsRepository.getInstance(); 189 191 userRepository = InMemoryUserRepository.getInstance(); 190 192 followPublisher = new FakeFollowPublisher(); 193 + profileService = new FakeProfileService(); 191 194 192 195 followTargetUseCase = new FollowTargetUseCase( 193 196 followsRepository, 194 197 userRepository, 195 198 collectionRepository, 196 199 followPublisher, 200 + profileService, 197 201 eventPublisher, 198 202 ); 199 203 200 204 unfollowTargetUseCase = new UnfollowTargetUseCase( 201 205 followsRepository, 202 206 followPublisher, 207 + profileService, 203 208 eventPublisher, 204 209 ); 205 210
+95
src/modules/cards/application/mappers/ProfileMapper.ts
··· 1 + import { UserProfile } from '../../domain/services/IProfileService'; 2 + import { User } from '@semble/types'; 3 + 4 + /** 5 + * ProfileMapper - Centralizes mapping from domain UserProfile to API User DTOs 6 + * 7 + * This eliminates the repetitive bio → description mapping across all use cases 8 + */ 9 + export class ProfileMapper { 10 + /** 11 + * Maps a UserProfile (domain model) to a User (API DTO) 12 + * Handles the bio → description field name conversion 13 + */ 14 + static toUser(profile: UserProfile): User { 15 + return { 16 + id: profile.id, 17 + name: profile.name, 18 + handle: profile.handle, 19 + avatarUrl: profile.avatarUrl, 20 + bannerUrl: profile.bannerUrl, 21 + description: profile.bio, 22 + isFollowing: profile.isFollowing, 23 + followsYou: profile.followsYou, 24 + followerCount: profile.followerCount, 25 + followingCount: profile.followingCount, 26 + followedCollectionsCount: profile.followedCollectionsCount, 27 + }; 28 + } 29 + 30 + /** 31 + * Maps a UserProfile to an inline profile object (without isFollowing) 32 + * Used for nested author objects in collections, cards, etc. 33 + */ 34 + static toInlineProfile(profile: UserProfile): { 35 + id: string; 36 + name: string; 37 + handle: string; 38 + avatarUrl?: string; 39 + bannerUrl?: string; 40 + description?: string; 41 + followsYou?: boolean; 42 + followerCount?: number; 43 + followingCount?: number; 44 + followedCollectionsCount?: number; 45 + } { 46 + return { 47 + id: profile.id, 48 + name: profile.name, 49 + handle: profile.handle, 50 + avatarUrl: profile.avatarUrl, 51 + bannerUrl: profile.bannerUrl, 52 + description: profile.bio, 53 + followsYou: profile.followsYou, 54 + followerCount: profile.followerCount, 55 + followingCount: profile.followingCount, 56 + followedCollectionsCount: profile.followedCollectionsCount, 57 + }; 58 + } 59 + 60 + /** 61 + * Maps a UserProfile to a minimal profile object (for card authors in collection pages) 62 + * Only includes essential display fields 63 + */ 64 + static toMinimalProfile(profile: UserProfile): { 65 + id: string; 66 + name: string; 67 + handle: string; 68 + avatarUrl?: string; 69 + bannerUrl?: string; 70 + } { 71 + return { 72 + id: profile.id, 73 + name: profile.name, 74 + handle: profile.handle, 75 + avatarUrl: profile.avatarUrl, 76 + bannerUrl: profile.bannerUrl, 77 + }; 78 + } 79 + 80 + /** 81 + * Creates a fallback profile for when profile fetch fails 82 + * Used in feed use cases that need graceful degradation 83 + */ 84 + static createFallbackProfile(userId: string): { 85 + id: string; 86 + name: string; 87 + handle: string; 88 + } { 89 + return { 90 + id: userId, 91 + name: 'Unknown User', 92 + handle: userId, 93 + }; 94 + } 95 + }
+144
src/modules/cards/application/services/BatchProfileFetcher.ts
··· 1 + import { Result, ok, err } from '../../../../shared/core/Result'; 2 + import { 3 + IProfileService, 4 + UserProfile, 5 + } from '../../domain/services/IProfileService'; 6 + import { ProfileMapper } from '../mappers/ProfileMapper'; 7 + 8 + export interface BatchProfileFetchOptions { 9 + /** 10 + * If true, skip profiles that fail to fetch and continue 11 + * If false (default), return an error if any profile fetch fails 12 + */ 13 + skipFailures?: boolean; 14 + 15 + /** 16 + * If true, include fallback "Unknown User" profiles for failed fetches 17 + * Only used when skipFailures is true 18 + */ 19 + includeFallback?: boolean; 20 + } 21 + 22 + /** 23 + * BatchProfileFetcher - Centralizes the pattern of fetching multiple profiles 24 + * and building a Map<userId, profile> 25 + * 26 + * This eliminates ~50-100 lines of repetitive code per use case 27 + */ 28 + export class BatchProfileFetcher { 29 + constructor(private profileService: IProfileService) {} 30 + 31 + /** 32 + * Fetch multiple profiles and return a Map<userId, UserProfile> 33 + * 34 + * @param userIds - Array of user IDs to fetch profiles for 35 + * @param callingUserId - Optional calling user ID for isFollowing status 36 + * @param options - Fetch options (skipFailures, includeFallback) 37 + * @returns Result<Map<userId, UserProfile>> 38 + */ 39 + async fetchProfileMap( 40 + userIds: string[], 41 + callingUserId?: string, 42 + options?: BatchProfileFetchOptions, 43 + ): Promise<Result<Map<string, UserProfile>>> { 44 + const skipFailures = options?.skipFailures ?? false; 45 + const includeFallback = options?.includeFallback ?? false; 46 + 47 + // Remove duplicates 48 + const uniqueUserIds = Array.from(new Set(userIds)); 49 + 50 + // Fetch all profiles in parallel 51 + const profilePromises = uniqueUserIds.map((userId) => 52 + this.profileService.getProfile(userId, callingUserId), 53 + ); 54 + 55 + const profileResults = await Promise.all(profilePromises); 56 + 57 + // Build the profile map 58 + const profileMap = new Map<string, UserProfile>(); 59 + const errors: string[] = []; 60 + 61 + for (let i = 0; i < uniqueUserIds.length; i++) { 62 + const userId = uniqueUserIds[i]; 63 + const profileResult = profileResults[i]; 64 + 65 + if (!userId) { 66 + errors.push('Missing user ID at index ' + i); 67 + continue; 68 + } 69 + 70 + if (!profileResult) { 71 + errors.push(`No profile result for user ${userId}`); 72 + continue; 73 + } 74 + 75 + if (profileResult.isErr()) { 76 + const errorMsg = `Failed to fetch profile for user ${userId}: ${ 77 + profileResult.error instanceof Error 78 + ? profileResult.error.message 79 + : 'Unknown error' 80 + }`; 81 + errors.push(errorMsg); 82 + 83 + if (skipFailures && includeFallback) { 84 + // Create a fallback profile 85 + const fallback = ProfileMapper.createFallbackProfile(userId); 86 + profileMap.set(userId, { 87 + id: fallback.id, 88 + name: fallback.name, 89 + handle: fallback.handle, 90 + }); 91 + } 92 + continue; 93 + } 94 + 95 + // Successfully fetched profile 96 + profileMap.set(userId, profileResult.value); 97 + } 98 + 99 + // If we have errors and we're not skipping failures, return an error 100 + if (errors.length > 0 && !skipFailures) { 101 + return err( 102 + new Error( 103 + `Failed to fetch some profiles:\n${errors.slice(0, 5).join('\n')}${ 104 + errors.length > 5 ? `\n... and ${errors.length - 5} more` : '' 105 + }`, 106 + ), 107 + ); 108 + } 109 + 110 + return ok(profileMap); 111 + } 112 + 113 + /** 114 + * Helper to extract unique user IDs from a collection of items 115 + * 116 + * @param items - Array of items 117 + * @param extractors - Functions to extract user IDs from each item 118 + * @returns Array of unique user IDs 119 + * 120 + * @example 121 + * const userIds = BatchProfileFetcher.extractUniqueIds( 122 + * notifications, 123 + * (n) => n.actorUserId, 124 + * (n) => n.cardAuthorId 125 + * ); 126 + */ 127 + static extractUniqueIds<T>( 128 + items: T[], 129 + ...extractors: ((item: T) => string | undefined)[] 130 + ): string[] { 131 + const userIds = new Set<string>(); 132 + 133 + for (const item of items) { 134 + for (const extractor of extractors) { 135 + const userId = extractor(item); 136 + if (userId) { 137 + userIds.add(userId); 138 + } 139 + } 140 + } 141 + 142 + return Array.from(userIds); 143 + } 144 + }
+138
src/modules/cards/application/services/ProfileEnricher.ts
··· 1 + import { Result, ok, err } from '../../../../shared/core/Result'; 2 + import { IProfileService } from '../../domain/services/IProfileService'; 3 + import { User } from '@semble/types'; 4 + import { BatchProfileFetcher } from './BatchProfileFetcher'; 5 + import { ProfileMapper } from '../mappers/ProfileMapper'; 6 + 7 + /** 8 + * ProfileEnricher - High-level service for enriching items with author profiles 9 + * 10 + * Provides a simple API to replace the extract → fetch → map pattern 11 + * used across all query use cases 12 + */ 13 + export class ProfileEnricher { 14 + private batchFetcher: BatchProfileFetcher; 15 + 16 + constructor(private profileService: IProfileService) { 17 + this.batchFetcher = new BatchProfileFetcher(profileService); 18 + } 19 + 20 + /** 21 + * Enrich a collection of items with author profiles 22 + * 23 + * @param items - Array of items to enrich 24 + * @param getAuthorId - Function to extract author ID from each item 25 + * @param callingUserId - Optional calling user ID for isFollowing status 26 + * @param options - Fetch options (skipFailures, includeFallback) 27 + * @returns Result with items enriched with 'author' field 28 + * 29 + * @example 30 + * const enriched = await enricher.enrichWithAuthors( 31 + * cards, 32 + * (card) => card.authorId, 33 + * callingUserId 34 + * ); 35 + */ 36 + async enrichWithAuthors<T extends Record<string, any>>( 37 + items: T[], 38 + getAuthorId: (item: T) => string, 39 + callingUserId?: string, 40 + options?: { 41 + skipFailures?: boolean; 42 + includeFallback?: boolean; 43 + mapToUser?: boolean; // If true, map to User (with isFollowing), else use inline profile 44 + }, 45 + ): Promise<Result<(T & { author: User })[]>> { 46 + // Extract unique author IDs 47 + const authorIds = Array.from(new Set(items.map(getAuthorId))); 48 + 49 + // Fetch all profiles 50 + const profileMapResult = await this.batchFetcher.fetchProfileMap( 51 + authorIds, 52 + callingUserId, 53 + { 54 + skipFailures: options?.skipFailures ?? false, 55 + includeFallback: options?.includeFallback ?? false, 56 + }, 57 + ); 58 + 59 + if (profileMapResult.isErr()) { 60 + return err(profileMapResult.error); 61 + } 62 + 63 + const profileMap = profileMapResult.value; 64 + const mapToUser = options?.mapToUser ?? true; 65 + 66 + // Enrich items with author profiles 67 + const enrichedItems: (T & { author: User })[] = []; 68 + 69 + for (const item of items) { 70 + const authorId = getAuthorId(item); 71 + const profile = profileMap.get(authorId); 72 + 73 + if (!profile) { 74 + if (options?.skipFailures) { 75 + // Skip this item if we're allowing failures 76 + continue; 77 + } 78 + return err(new Error(`Profile not found for author ${authorId}`)); 79 + } 80 + 81 + const author = mapToUser 82 + ? ProfileMapper.toUser(profile) 83 + : (ProfileMapper.toInlineProfile(profile) as User); 84 + 85 + enrichedItems.push({ 86 + ...item, 87 + author, 88 + }); 89 + } 90 + 91 + return ok(enrichedItems); 92 + } 93 + 94 + /** 95 + * Build a profile map for later enrichment 96 + * Useful when you need to enrich multiple different collections with the same profiles 97 + * 98 + * @param userIds - Array of user IDs to fetch 99 + * @param callingUserId - Optional calling user ID 100 + * @param options - Fetch options 101 + * @returns Map of userId -> User (API DTO) 102 + */ 103 + async buildProfileMap( 104 + userIds: string[], 105 + callingUserId?: string, 106 + options?: { 107 + skipFailures?: boolean; 108 + includeFallback?: boolean; 109 + mapToUser?: boolean; 110 + }, 111 + ): Promise<Result<Map<string, User>>> { 112 + const profileMapResult = await this.batchFetcher.fetchProfileMap( 113 + userIds, 114 + callingUserId, 115 + { 116 + skipFailures: options?.skipFailures ?? false, 117 + includeFallback: options?.includeFallback ?? false, 118 + }, 119 + ); 120 + 121 + if (profileMapResult.isErr()) { 122 + return err(profileMapResult.error); 123 + } 124 + 125 + const profileMap = profileMapResult.value; 126 + const mapToUser = options?.mapToUser ?? true; 127 + const userMap = new Map<string, User>(); 128 + 129 + for (const [userId, profile] of profileMap.entries()) { 130 + const user = mapToUser 131 + ? ProfileMapper.toUser(profile) 132 + : (ProfileMapper.toInlineProfile(profile) as User); 133 + userMap.set(userId, user); 134 + } 135 + 136 + return ok(userMap); 137 + } 138 + }
+19 -29
src/modules/cards/application/useCases/queries/GetCollectionPageUseCase.ts
··· 12 12 import { IProfileService } from '../../../domain/services/IProfileService'; 13 13 import { IFollowsRepository } from 'src/modules/user/domain/repositories/IFollowsRepository'; 14 14 import { FollowTargetType } from 'src/modules/user/domain/value-objects/FollowTargetType'; 15 + import { ProfileEnricher } from '../../services/ProfileEnricher'; 15 16 16 17 export interface GetCollectionPageQuery { 17 18 collectionId: string; ··· 148 149 new Set(cardsResult.items.map((card) => card.authorId)), 149 150 ); 150 151 151 - // Fetch all author profiles in parallel 152 - const authorProfileResults = await Promise.all( 153 - uniqueAuthorIds.map((authorId) => 154 - this.profileService.getProfile(authorId, query.callingUserId), 155 - ), 156 - ); 157 - 158 - // Create a map of authorId -> profile for efficient lookup 159 - const authorProfileMap = new Map< 160 - string, 152 + // Fetch all author profiles using ProfileEnricher 153 + const profileEnricher = new ProfileEnricher(this.profileService); 154 + const authorProfileMapResult = await profileEnricher.buildProfileMap( 155 + uniqueAuthorIds, 156 + query.callingUserId, 161 157 { 162 - id: string; 163 - name: string; 164 - handle: string; 165 - avatarUrl?: string; 166 - } 167 - >(); 168 - 169 - for (let i = 0; i < uniqueAuthorIds.length; i++) { 170 - const authorId = uniqueAuthorIds[i]; 171 - const profileResult = authorProfileResults[i]; 158 + skipFailures: true, // Skip cards with failed author profiles 159 + mapToUser: false, // Use inline profile (without isFollowing) 160 + }, 161 + ); 172 162 173 - if (profileResult?.isOk()) { 174 - const profile = profileResult.value; 175 - authorProfileMap.set(authorId!, { 176 - id: profile.id, 177 - name: profile.name, 178 - handle: profile.handle, 179 - avatarUrl: profile.avatarUrl, 180 - }); 181 - } 163 + if (authorProfileMapResult.isErr()) { 164 + return err( 165 + new Error( 166 + `Failed to fetch card author profiles: ${authorProfileMapResult.error.message}`, 167 + ), 168 + ); 182 169 } 170 + 171 + const authorProfileMap = authorProfileMapResult.value; 183 172 184 173 // Transform raw card data to enriched DTOs with author information 185 174 const enrichedCards: CollectionPageUrlCardDTO[] = cardsResult.items.map( ··· 224 213 name: authorProfile.name, 225 214 handle: authorProfile.handle, 226 215 avatarUrl: authorProfile.avatarUrl, 216 + bannerUrl: authorProfile.bannerUrl, 227 217 }, 228 218 urlCards: enrichedCards, 229 219 cardCount: collection.cardCount,
+12 -39
src/modules/cards/application/useCases/queries/GetCollectionsForUrlUseCase.ts
··· 9 9 import { IProfileService } from '../../../domain/services/IProfileService'; 10 10 import { ICollectionRepository } from '../../../domain/ICollectionRepository'; 11 11 import { CollectionId } from '../../../domain/value-objects/CollectionId'; 12 - import { GetCollectionsForUrlResponse, Collection } from '@semble/types'; 12 + import { GetCollectionsForUrlResponse, Collection, User } from '@semble/types'; 13 13 import { IFollowsRepository } from 'src/modules/user/domain/repositories/IFollowsRepository'; 14 14 import { FollowTargetType } from 'src/modules/user/domain/value-objects/FollowTargetType'; 15 + import { ProfileEnricher } from '../../services/ProfileEnricher'; 15 16 16 17 export interface GetCollectionsForUrlQuery { 17 18 url: string; ··· 72 73 }, 73 74 ); 74 75 75 - // Enrich with author profiles 76 + // Build profile map using ProfileEnricher utility 77 + const profileEnricher = new ProfileEnricher(this.profileService); 76 78 const uniqueAuthorIds = Array.from( 77 79 new Set(result.items.map((item) => item.authorId)), 78 80 ); 79 81 80 - const profilePromises = uniqueAuthorIds.map((authorId) => 81 - this.profileService.getProfile(authorId, query.callingUserId), 82 + const profileMapResult = await profileEnricher.buildProfileMap( 83 + uniqueAuthorIds, 84 + query.callingUserId, 85 + { mapToUser: false }, // Use inline profile (without isFollowing) 82 86 ); 83 87 84 - const profileResults = await Promise.all(profilePromises); 85 - 86 - // Create a map of profiles 87 - const profileMap = new Map< 88 - string, 89 - { 90 - id: string; 91 - name: string; 92 - handle: string; 93 - avatarUrl?: string; 94 - description?: string; 95 - } 96 - >(); 97 - 98 - for (let i = 0; i < uniqueAuthorIds.length; i++) { 99 - const profileResult = profileResults[i]; 100 - const authorId = uniqueAuthorIds[i]; 101 - if (!profileResult || !authorId) { 102 - return err(new Error('Missing profile result or author ID')); 103 - } 104 - if (profileResult.isErr()) { 105 - return err( 106 - new Error( 107 - `Failed to fetch author profile: ${profileResult.error instanceof Error ? profileResult.error.message : 'Unknown error'}`, 108 - ), 109 - ); 110 - } 111 - const profile = profileResult.value; 112 - profileMap.set(authorId, { 113 - id: profile.id, 114 - name: profile.name, 115 - handle: profile.handle, 116 - avatarUrl: profile.avatarUrl, 117 - description: profile.bio, 118 - }); 88 + if (profileMapResult.isErr()) { 89 + return err(profileMapResult.error); 119 90 } 91 + 92 + const profileMap = profileMapResult.value; 120 93 121 94 // Map items with enriched author data and full collection data 122 95 const enrichedCollections: Collection[] = await Promise.all(
+5 -7
src/modules/cards/application/useCases/queries/GetCollectionsUseCase.ts
··· 16 16 import { CollectionAccessType } from '../../../domain/Collection'; 17 17 import { IFollowsRepository } from 'src/modules/user/domain/repositories/IFollowsRepository'; 18 18 import { FollowTargetType } from 'src/modules/user/domain/value-objects/FollowTargetType'; 19 + import { ProfileMapper } from '../../mappers/ProfileMapper'; 19 20 20 21 export interface GetCollectionsQuery { 21 22 curatorId: string; ··· 101 102 } 102 103 const profile = profileResult.value; 103 104 105 + // Map profile using ProfileMapper 106 + const authorProfile = ProfileMapper.toInlineProfile(profile); 107 + 104 108 // Transform raw data to enriched DTOs 105 109 const enrichedCollections: CollectionDTO[] = result.items.map((item) => { 106 110 return { ··· 112 116 updatedAt: item.updatedAt.toISOString(), 113 117 createdAt: item.createdAt.toISOString(), 114 118 cardCount: item.cardCount, 115 - author: { 116 - id: profile.id, 117 - name: profile.name, 118 - handle: profile.handle, 119 - avatarUrl: profile.avatarUrl, 120 - description: profile.bio, 121 - }, 119 + author: authorProfile, 122 120 }; 123 121 }); 124 122
+20 -33
src/modules/cards/application/useCases/queries/GetLibrariesForCardUseCase.ts
··· 3 3 import { IProfileService } from '../../../domain/services/IProfileService'; 4 4 import { err, ok, Result } from 'src/shared/core/Result'; 5 5 import { UserProfileDTO } from '@semble/types'; 6 + import { ProfileEnricher } from '../../services/ProfileEnricher'; 6 7 7 8 export interface GetLibrariesForCardQuery { 8 9 cardId: string; ··· 44 45 query.cardId, 45 46 ); 46 47 47 - // Fetch profiles for all users 48 - const profilePromises = userIds.map((userId) => 49 - this.profileService.getProfile(userId), 48 + // Fetch profiles for all users using ProfileEnricher 49 + const profileEnricher = new ProfileEnricher(this.profileService); 50 + const profileMapResult = await profileEnricher.buildProfileMap( 51 + userIds, 52 + undefined, // No calling user 53 + { 54 + skipFailures: true, // Skip failed profiles 55 + mapToUser: false, // Use inline profile (without isFollowing) 56 + }, 50 57 ); 51 58 52 - const profileResults = await Promise.all(profilePromises); 53 - 54 - // Filter out failed profile fetches and transform to DTOs 55 - const users: UserProfileDTO[] = []; 56 - const errors: string[] = []; 57 - 58 - for (let i = 0; i < profileResults.length; i++) { 59 - const result = profileResults[i]; 60 - if (!result) { 61 - errors.push(`No profile found for user ${userIds[i]}`); 62 - continue; 63 - } 64 - if (result.isOk()) { 65 - const profile = result.value; 66 - users.push({ 67 - id: profile.id, 68 - name: profile.name, 69 - handle: profile.handle, 70 - avatarUrl: profile.avatarUrl, 71 - description: profile.bio, 72 - }); 73 - } else { 74 - errors.push( 75 - `Failed to fetch profile for user ${userIds[i]}: ${result.error.message}`, 76 - ); 77 - } 59 + if (profileMapResult.isErr()) { 60 + return err( 61 + new Error( 62 + `Failed to fetch user profiles: ${profileMapResult.error.message}`, 63 + ), 64 + ); 78 65 } 79 66 80 - // Log errors but don't fail the entire operation 81 - if (errors.length > 0) { 82 - console.warn('Some profile fetches failed:', errors); 83 - } 67 + const profileMap = profileMapResult.value; 68 + 69 + // Convert map to array 70 + const users: UserProfileDTO[] = Array.from(profileMap.values()); 84 71 85 72 return ok({ 86 73 cardId: query.cardId,
+18 -29
src/modules/cards/application/useCases/queries/GetLibrariesForUrlUseCase.ts
··· 13 13 PaginationDTO, 14 14 CardSortingDTO, 15 15 } from '@semble/types'; 16 + import { ProfileEnricher } from '../../services/ProfileEnricher'; 16 17 17 18 export interface GetLibrariesForUrlQuery { 18 19 url: string; ··· 78 79 }, 79 80 ); 80 81 81 - // Enrich with user profiles 82 + // Enrich with user profiles using ProfileEnricher 82 83 const uniqueUserIds = Array.from( 83 84 new Set(result.items.map((item) => item.userId)), 84 85 ); 85 86 86 - const profilePromises = uniqueUserIds.map((userId) => 87 - this.profileService.getProfile(userId, query.callingUserId), 87 + const profileEnricher = new ProfileEnricher(this.profileService); 88 + const profileMapResult = await profileEnricher.buildProfileMap( 89 + uniqueUserIds, 90 + query.callingUserId, 91 + { 92 + skipFailures: false, // Fail if any profile fetch fails (preserving original behavior) 93 + mapToUser: false, // Use inline profile (without isFollowing) 94 + }, 88 95 ); 89 96 90 - const profileResults = await Promise.all(profilePromises); 91 - 92 - // Create a map of profiles 93 - const profileMap = new Map<string, UserProfileDTO>(); 94 - 95 - for (let i = 0; i < uniqueUserIds.length; i++) { 96 - const profileResult = profileResults[i]; 97 - const userId = uniqueUserIds[i]; 98 - if (!profileResult || !userId) { 99 - return err(new Error('Missing profile result or user ID')); 100 - } 101 - if (profileResult.isErr()) { 102 - return err( 103 - new Error( 104 - `Failed to fetch user profile: ${profileResult.error instanceof Error ? profileResult.error.message : 'Unknown error'}`, 105 - ), 106 - ); 107 - } 108 - const profile = profileResult.value; 109 - profileMap.set(userId, { 110 - id: profile.id, 111 - name: profile.name, 112 - handle: profile.handle, 113 - avatarUrl: profile.avatarUrl, 114 - description: profile.bio, 115 - }); 97 + if (profileMapResult.isErr()) { 98 + return err( 99 + new Error( 100 + `Failed to fetch user profiles: ${profileMapResult.error.message}`, 101 + ), 102 + ); 116 103 } 104 + 105 + const profileMap = profileMapResult.value; 117 106 118 107 // Map items with enriched user data and card data 119 108 const enrichedLibraries: LibraryForUrlDTO[] = result.items.map((item) => {
+18 -38
src/modules/cards/application/useCases/queries/GetNoteCardsForUrlUseCase.ts
··· 8 8 import { URL } from '../../../domain/value-objects/URL'; 9 9 import { IProfileService } from '../../../domain/services/IProfileService'; 10 10 import { NoteCardDTO, PaginationDTO, CardSortingDTO } from '@semble/types'; 11 + import { ProfileEnricher } from '../../services/ProfileEnricher'; 11 12 12 13 export interface GetNoteCardsForUrlQuery { 13 14 url: string; ··· 68 69 }, 69 70 ); 70 71 71 - // Enrich with author profiles 72 + // Enrich with author profiles using ProfileEnricher 72 73 const uniqueAuthorIds = Array.from( 73 74 new Set(result.items.map((item) => item.authorId)), 74 75 ); 75 76 76 - const profilePromises = uniqueAuthorIds.map((authorId) => 77 - this.profileService.getProfile(authorId, query.callingUserId), 77 + const profileEnricher = new ProfileEnricher(this.profileService); 78 + const profileMapResult = await profileEnricher.buildProfileMap( 79 + uniqueAuthorIds, 80 + query.callingUserId, 81 + { 82 + skipFailures: false, // Fail if any profile fetch fails (preserving original behavior) 83 + mapToUser: false, // Use inline profile (without isFollowing) 84 + }, 78 85 ); 79 86 80 - const profileResults = await Promise.all(profilePromises); 87 + if (profileMapResult.isErr()) { 88 + return err( 89 + new Error( 90 + `Failed to fetch author profiles: ${profileMapResult.error.message}`, 91 + ), 92 + ); 93 + } 81 94 82 - // Create a map of profiles 83 - const profileMap = new Map< 84 - string, 85 - { 86 - id: string; 87 - name: string; 88 - handle: string; 89 - avatarUrl?: string; 90 - description?: string; 91 - } 92 - >(); 93 - 94 - for (let i = 0; i < uniqueAuthorIds.length; i++) { 95 - const profileResult = profileResults[i]; 96 - const authorId = uniqueAuthorIds[i]; 97 - if (!profileResult || !authorId) { 98 - return err(new Error('Missing profile result or author ID')); 99 - } 100 - if (profileResult.isErr()) { 101 - return err( 102 - new Error( 103 - `Failed to fetch author profile: ${profileResult.error instanceof Error ? profileResult.error.message : 'Unknown error'}`, 104 - ), 105 - ); 106 - } 107 - const profile = profileResult.value; 108 - profileMap.set(authorId, { 109 - id: profile.id, 110 - name: profile.name, 111 - handle: profile.handle, 112 - avatarUrl: profile.avatarUrl, 113 - description: profile.bio, 114 - }); 115 - } 95 + const profileMap = profileMapResult.value; 116 96 117 97 // Map items with enriched author data 118 98 const enrichedNotes: NoteCardDTO[] = result.items.map((item) => {
+20 -18
src/modules/cards/application/useCases/queries/GetOpenCollectionsWithContributorUseCase.ts
··· 16 16 import { CollectionAccessType } from '../../../domain/Collection'; 17 17 import { IFollowsRepository } from 'src/modules/user/domain/repositories/IFollowsRepository'; 18 18 import { FollowTargetType } from 'src/modules/user/domain/value-objects/FollowTargetType'; 19 + import { ProfileEnricher } from '../../services/ProfileEnricher'; 19 20 20 21 export interface GetOpenCollectionsWithContributorQuery { 21 22 contributorId: string; // DID or handle ··· 96 97 // Get unique author IDs from the results 97 98 const authorIds = [...new Set(result.items.map((item) => item.authorId))]; 98 99 99 - // Fetch profiles for all authors 100 - const profilePromises = authorIds.map((authorId) => 101 - this.profileService.getProfile(authorId), 100 + // Fetch profiles for all authors using ProfileEnricher 101 + const profileEnricher = new ProfileEnricher(this.profileService); 102 + const profileMapResult = await profileEnricher.buildProfileMap( 103 + authorIds, 104 + undefined, // No calling user (no isFollowing needed) 105 + { 106 + skipFailures: true, // Skip collections with failed author profiles 107 + mapToUser: false, // Use inline profile (without isFollowing) 108 + }, 102 109 ); 103 - const profileResults = await Promise.all(profilePromises); 110 + 111 + if (profileMapResult.isErr()) { 112 + return err( 113 + new Error( 114 + `Failed to fetch author profiles: ${profileMapResult.error.message}`, 115 + ), 116 + ); 117 + } 104 118 105 - // Create a map of authorId to profile for quick lookup 106 - const profileMap = new Map(); 107 - profileResults.forEach((profileResult, index) => { 108 - if (profileResult.isOk()) { 109 - profileMap.set(authorIds[index], profileResult.value); 110 - } 111 - }); 119 + const profileMap = profileMapResult.value; 112 120 113 121 // Transform raw data to enriched DTOs 114 122 const enrichedCollections: CollectionDTO[] = result.items ··· 128 136 updatedAt: item.updatedAt.toISOString(), 129 137 createdAt: item.createdAt.toISOString(), 130 138 cardCount: item.cardCount, 131 - author: { 132 - id: profile.id, 133 - name: profile.name, 134 - handle: profile.handle, 135 - avatarUrl: profile.avatarUrl, 136 - description: profile.bio, 137 - }, 139 + author: profile, 138 140 }; 139 141 }) 140 142 .filter((item): item is NonNullable<typeof item> => item !== null);
+3 -8
src/modules/cards/application/useCases/queries/GetProfileUseCase.ts
··· 3 3 import { err, ok, Result } from 'src/shared/core/Result'; 4 4 import { DIDOrHandle } from 'src/modules/atproto/domain/DIDOrHandle'; 5 5 import { IIdentityResolutionService } from 'src/modules/atproto/domain/services/IIdentityResolutionService'; 6 + import { ProfileMapper } from '../../mappers/ProfileMapper'; 6 7 7 8 export interface GetMyProfileQuery { 8 9 userId: string; ··· 86 87 87 88 const profile = profileResult.value; 88 89 89 - return ok({ 90 - id: profile.id, 91 - name: profile.name, 92 - handle: profile.handle, 93 - description: profile.bio, 94 - avatarUrl: profile.avatarUrl, 95 - isFollowing: profile.isFollowing, 96 - }); 90 + // Map profile using ProfileMapper 91 + return ok(ProfileMapper.toUser(profile)); 97 92 } catch (error) { 98 93 return err( 99 94 new Error(
+96 -50
src/modules/cards/application/useCases/queries/GetUrlCardViewUseCase.ts
··· 10 10 import { ICollectionRepository } from '../../../domain/ICollectionRepository'; 11 11 import { CollectionId } from '../../../domain/value-objects/CollectionId'; 12 12 import { UserProfileDTO, CollectionDTO } from '@semble/types'; 13 + import { ProfileEnricher } from '../../services/ProfileEnricher'; 13 14 14 15 export interface GetUrlCardViewQuery { 15 16 cardId: string; ··· 82 83 83 84 const cardAuthor = cardAuthorResult.value; 84 85 85 - // Get profiles for all users in libraries 86 + // Get profiles for all users in libraries using ProfileEnricher 87 + const profileEnricher = new ProfileEnricher(this.profileService); 86 88 const userIds = cardView.libraries.map((lib) => lib.userId); 87 - const profilePromises = userIds.map((userId) => 88 - this.profileService.getProfile(userId, query.callingUserId), 89 + const userProfilesResult = await profileEnricher.buildProfileMap( 90 + userIds, 91 + query.callingUserId, 92 + { 93 + skipFailures: true, // Skip users with failed profiles 94 + mapToUser: false, // Use inline profile (without isFollowing) 95 + }, 89 96 ); 90 97 91 - const profileResults = await Promise.all(profilePromises); 92 - 93 - // Check if any profile fetches failed 94 - const failedProfiles = profileResults.filter((result) => result.isErr()); 95 - if (failedProfiles.length > 0) { 96 - const firstError = failedProfiles[0]!.error; 98 + if (userProfilesResult.isErr()) { 97 99 return err( 98 100 new Error( 99 - `Failed to fetch user profiles: ${firstError instanceof Error ? firstError.message : 'Unknown error'}`, 101 + `Failed to fetch user profiles: ${userProfilesResult.error.message}`, 100 102 ), 101 103 ); 102 104 } 105 + 106 + const userProfileMap = userProfilesResult.value; 103 107 104 108 // Transform to result format with enriched profile data 105 - const enrichedLibraries = cardView.libraries.map((lib, index) => { 106 - const profileResult = profileResults[index]!; 107 - if (profileResult.isErr()) { 108 - throw new Error( 109 - `Failed to fetch profile for user ${lib.userId}: ${profileResult.error instanceof Error ? profileResult.error.message : 'Unknown error'}`, 110 - ); 111 - } 112 - const profile = profileResult.value; 109 + const enrichedLibraries = cardView.libraries 110 + .map((lib) => { 111 + const profile = userProfileMap.get(lib.userId); 112 + if (!profile) { 113 + return null; // Skip if profile not found 114 + } 113 115 114 - return { 115 - id: profile.id, 116 - name: profile.name, 117 - handle: profile.handle, 118 - avatarUrl: profile.avatarUrl, 119 - description: profile.bio, 120 - }; 121 - }); 116 + return { 117 + id: profile.id, 118 + name: profile.name, 119 + handle: profile.handle, 120 + avatarUrl: profile.avatarUrl, 121 + bannerUrl: profile.bannerUrl, 122 + description: profile.description, 123 + }; 124 + }) 125 + .filter((lib): lib is NonNullable<typeof lib> => lib !== null); 122 126 123 - // Enrich collections with full Collection data 124 - const enrichedCollections: CollectionDTO[] = await Promise.all( 127 + // Fetch all collections first (without author profiles) - FIX: Parallel fetch 128 + const collectionsWithData = await Promise.all( 125 129 cardView.collections.map(async (collection) => { 126 130 const collectionIdResult = CollectionId.createFromString( 127 131 collection.id, 128 132 ); 129 133 if (collectionIdResult.isErr()) { 130 - throw new Error(`Invalid collection ID: ${collection.id}`); 134 + return null; 131 135 } 132 136 const collectionResult = await this.collectionRepo.findById( 133 137 collectionIdResult.value, 134 138 ); 135 139 if (collectionResult.isErr() || !collectionResult.value) { 136 - throw new Error(`Collection not found: ${collection.id}`); 140 + return null; 137 141 } 138 142 const fullCollection = collectionResult.value; 139 143 140 - // Fetch collection author profile 141 - const collectionAuthorResult = await this.profileService.getProfile( 142 - fullCollection.authorId.value, 143 - query.callingUserId, 144 - ); 145 - if (collectionAuthorResult.isErr()) { 146 - throw new Error( 147 - `Failed to fetch collection author: ${collectionAuthorResult.error.message}`, 148 - ); 149 - } 150 - const collectionAuthor = collectionAuthorResult.value; 151 - 152 144 return { 153 145 id: collection.id, 154 - uri: fullCollection.publishedRecordId?.uri, 155 146 name: collection.name, 147 + uri: fullCollection.publishedRecordId?.uri, 156 148 description: fullCollection.description?.value, 157 149 accessType: fullCollection.accessType, 158 - author: { 159 - id: collectionAuthor.id, 160 - name: collectionAuthor.name, 161 - handle: collectionAuthor.handle, 162 - avatarUrl: collectionAuthor.avatarUrl, 163 - description: collectionAuthor.bio, 164 - }, 150 + authorId: fullCollection.authorId.value, 165 151 cardCount: fullCollection.cardCount, 166 152 createdAt: fullCollection.createdAt.toISOString(), 167 153 updatedAt: fullCollection.updatedAt.toISOString(), ··· 169 155 }), 170 156 ); 171 157 158 + const validCollections = collectionsWithData.filter( 159 + (c): c is NonNullable<typeof c> => c !== null, 160 + ); 161 + 162 + // Extract unique collection author IDs 163 + const collectionAuthorIds = [ 164 + ...new Set(validCollections.map((c) => c.authorId)), 165 + ]; 166 + 167 + // Batch fetch all collection author profiles - FIX: Batch instead of sequential 168 + const collectionAuthorProfilesResult = 169 + await profileEnricher.buildProfileMap( 170 + collectionAuthorIds, 171 + query.callingUserId, 172 + { 173 + skipFailures: true, 174 + mapToUser: false, 175 + }, 176 + ); 177 + 178 + if (collectionAuthorProfilesResult.isErr()) { 179 + return err( 180 + new Error( 181 + `Failed to fetch collection author profiles: ${collectionAuthorProfilesResult.error.message}`, 182 + ), 183 + ); 184 + } 185 + 186 + const collectionAuthorProfiles = collectionAuthorProfilesResult.value; 187 + 188 + // Build enriched collections with author profiles 189 + const enrichedCollections: CollectionDTO[] = validCollections 190 + .map((collection) => { 191 + const author = collectionAuthorProfiles.get(collection.authorId); 192 + if (!author) { 193 + return null; // Skip collections with missing author 194 + } 195 + 196 + return { 197 + id: collection.id, 198 + uri: collection.uri, 199 + name: collection.name, 200 + description: collection.description, 201 + accessType: collection.accessType, 202 + author: { 203 + id: author.id, 204 + name: author.name, 205 + handle: author.handle, 206 + avatarUrl: author.avatarUrl, 207 + bannerUrl: author.bannerUrl, 208 + description: author.description, 209 + }, 210 + cardCount: collection.cardCount, 211 + createdAt: collection.createdAt, 212 + updatedAt: collection.updatedAt, 213 + }; 214 + }) 215 + .filter((c): c is NonNullable<typeof c> => c !== null); 216 + 172 217 const result: UrlCardViewResult = { 173 218 ...cardView, 174 219 author: { ··· 176 221 name: cardAuthor.name, 177 222 handle: cardAuthor.handle, 178 223 avatarUrl: cardAuthor.avatarUrl, 224 + bannerUrl: cardAuthor.bannerUrl, 179 225 description: cardAuthor.bio, 180 226 }, 181 227 collections: enrichedCollections,
+11 -46
src/modules/cards/application/useCases/queries/GetUrlCardsUseCase.ts
··· 12 12 import { IIdentityResolutionService } from 'src/modules/atproto/domain/services/IIdentityResolutionService'; 13 13 import { IProfileService } from '../../../domain/services/IProfileService'; 14 14 import { User } from '@semble/types'; 15 + import { ProfileEnricher } from '../../services/ProfileEnricher'; 15 16 16 17 export interface GetUrlCardsQuery { 17 18 userId: string; ··· 98 99 query.callingUserId, 99 100 ); 100 101 101 - // Get unique author IDs 102 - const uniqueAuthorIds = Array.from( 103 - new Set(result.items.map((item) => item.authorId)), 104 - ); 105 - 106 - // Fetch author profiles 107 - const profilePromises = uniqueAuthorIds.map((authorId) => 108 - this.profileService.getProfile(authorId, query.callingUserId), 102 + // Enrich cards with author profiles using ProfileEnricher utility 103 + const profileEnricher = new ProfileEnricher(this.profileService); 104 + const enrichResult = await profileEnricher.enrichWithAuthors( 105 + result.items, 106 + (item) => item.authorId, 107 + query.callingUserId, 108 + { mapToUser: false }, // Use inline profile (without isFollowing) 109 109 ); 110 110 111 - const profileResults = await Promise.all(profilePromises); 112 - 113 - // Create a map of profiles 114 - const profileMap = new Map<string, User>(); 115 - 116 - for (let i = 0; i < uniqueAuthorIds.length; i++) { 117 - const profileResult = profileResults[i]; 118 - const authorId = uniqueAuthorIds[i]; 119 - if (!profileResult || !authorId) { 120 - return err(new Error('Missing profile result or author ID')); 121 - } 122 - if (profileResult.isErr()) { 123 - return err( 124 - new Error( 125 - `Failed to fetch author profile: ${profileResult.error instanceof Error ? profileResult.error.message : 'Unknown error'}`, 126 - ), 127 - ); 128 - } 129 - const profile = profileResult.value; 130 - profileMap.set(authorId, { 131 - id: profile.id, 132 - name: profile.name, 133 - handle: profile.handle, 134 - avatarUrl: profile.avatarUrl, 135 - description: profile.bio, 136 - }); 111 + if (enrichResult.isErr()) { 112 + return err(enrichResult.error); 137 113 } 138 114 139 - // Transform raw data to enriched DTOs 140 - const enrichedCards: UrlCardListItemDTO[] = result.items.map((item) => { 141 - const author = profileMap.get(item.authorId); 142 - if (!author) { 143 - throw new Error(`Profile not found for author ${item.authorId}`); 144 - } 145 - 146 - return { 147 - ...item, 148 - author, 149 - }; 150 - }); 115 + const enrichedCards = enrichResult.value; 151 116 152 117 return ok({ 153 118 cards: enrichedCards,
+20 -18
src/modules/cards/application/useCases/queries/SearchCollectionsUseCase.ts
··· 16 16 import { CollectionAccessType } from '../../../domain/Collection'; 17 17 import { IFollowsRepository } from 'src/modules/user/domain/repositories/IFollowsRepository'; 18 18 import { FollowTargetType } from 'src/modules/user/domain/value-objects/FollowTargetType'; 19 + import { ProfileEnricher } from '../../services/ProfileEnricher'; 19 20 20 21 export interface SearchCollectionsQuery { 21 22 callingUserId?: string; ··· 100 101 // Get unique author IDs from the results 101 102 const authorIds = [...new Set(result.items.map((item) => item.authorId))]; 102 103 103 - // Fetch profiles for all authors 104 - const profilePromises = authorIds.map((authorId) => 105 - this.profileService.getProfile(authorId), 104 + // Fetch profiles for all authors using ProfileEnricher 105 + const profileEnricher = new ProfileEnricher(this.profileService); 106 + const profileMapResult = await profileEnricher.buildProfileMap( 107 + authorIds, 108 + undefined, // No calling user (no isFollowing needed) 109 + { 110 + skipFailures: true, // Skip collections with failed author profiles 111 + mapToUser: false, // Use inline profile (without isFollowing) 112 + }, 106 113 ); 107 - const profileResults = await Promise.all(profilePromises); 114 + 115 + if (profileMapResult.isErr()) { 116 + return err( 117 + new Error( 118 + `Failed to fetch author profiles: ${profileMapResult.error.message}`, 119 + ), 120 + ); 121 + } 108 122 109 - // Create a map of authorId to profile for quick lookup 110 - const profileMap = new Map(); 111 - profileResults.forEach((profileResult, index) => { 112 - if (profileResult.isOk()) { 113 - profileMap.set(authorIds[index], profileResult.value); 114 - } 115 - }); 123 + const profileMap = profileMapResult.value; 116 124 117 125 // Transform raw data to enriched DTOs 118 126 const enrichedCollections: CollectionDTO[] = result.items ··· 132 140 updatedAt: item.updatedAt.toISOString(), 133 141 createdAt: item.createdAt.toISOString(), 134 142 cardCount: item.cardCount, 135 - author: { 136 - id: profile.id, 137 - name: profile.name, 138 - handle: profile.handle, 139 - avatarUrl: profile.avatarUrl, 140 - description: profile.bio, 141 - }, 143 + author: profile, 142 144 }; 143 145 }) 144 146 .filter((item): item is NonNullable<typeof item> => item !== null);
+6
src/modules/cards/domain/services/IProfileService.ts
··· 2 2 3 3 export interface IProfileService { 4 4 getProfile(userId: string, callerId?: string): Promise<Result<UserProfile>>; 5 + invalidateCounts?(userId: string): Promise<void>; 5 6 } 6 7 7 8 export interface UserProfile { ··· 9 10 name: string; 10 11 handle: string; 11 12 avatarUrl?: string; 13 + bannerUrl?: string; 12 14 bio?: string; 13 15 isFollowing?: boolean; 16 + followsYou?: boolean; 17 + followerCount?: number; 18 + followingCount?: number; 19 + followedCollectionsCount?: number; 14 20 }
+5
src/modules/cards/tests/utils/FakeProfileService.ts
··· 21 21 return ok(profile); 22 22 } 23 23 24 + async invalidateCounts(userId: string): Promise<void> { 25 + // No-op for fake implementation 26 + // In real implementation, this would invalidate the cache 27 + } 28 + 24 29 // Test helper methods 25 30 addProfile(profile: UserProfile): void { 26 31 this.profiles.set(profile.id, profile);
+107 -119
src/modules/feeds/application/useCases/queries/GetFollowingFeedUseCase.ts
··· 14 14 import { UrlType } from '../../../../cards/domain/value-objects/UrlType'; 15 15 import { GetGlobalFeedResponse, FeedItem, ActivitySource } from '@semble/types'; 16 16 import { CollectionAccessType } from '../../../../cards/domain/Collection'; 17 + import { ProfileEnricher } from '../../../../cards/application/services/ProfileEnricher'; 17 18 18 19 export interface GetFollowingFeedQuery { 19 20 callingUserId: string; ··· 101 102 ...new Set(feed.activities.map((activity) => activity.actorId.value)), 102 103 ]; 103 104 104 - // Fetch profiles for all actors 105 - const actorProfiles = new Map< 106 - string, 105 + // Fetch profiles for all actors using ProfileEnricher 106 + const profileEnricher = new ProfileEnricher(this.profileService); 107 + const actorProfilesResult = await profileEnricher.buildProfileMap( 108 + actorIds, 109 + undefined, // No calling user for actor profiles 107 110 { 108 - id: string; 109 - name: string; 110 - handle: string; 111 - avatarUrl?: string; 112 - description?: string; 113 - } 114 - >(); 115 - const profileResults = await Promise.all( 116 - actorIds.map((actorId) => this.profileService.getProfile(actorId)), 111 + skipFailures: true, 112 + includeFallback: true, 113 + mapToUser: false, // Use inline profile (without isFollowing) 114 + }, 117 115 ); 118 116 119 - profileResults.forEach((profileResult, idx) => { 120 - const actorId = actorIds[idx]; 121 - if (!actorId) { 122 - return; 123 - } 124 - if (profileResult.isOk()) { 125 - const profile = profileResult.value; 126 - actorProfiles.set(actorId, { 127 - id: profile.id, 128 - name: profile.name, 129 - handle: profile.handle, 130 - avatarUrl: profile.avatarUrl, 131 - description: profile.bio, 132 - }); 133 - } else { 134 - // If profile fetch fails, create a fallback 135 - actorProfiles.set(actorId, { 136 - id: actorId, 137 - name: 'Unknown User', 138 - handle: actorId, 139 - }); 140 - } 141 - }); 117 + if (actorProfilesResult.isErr()) { 118 + return err(AppError.UnexpectedError.create(actorProfilesResult.error)); 119 + } 120 + 121 + const actorProfiles = actorProfilesResult.value; 142 122 143 123 // Get unique card IDs for hydration 144 124 const cardIds = [ ··· 170 150 ), 171 151 ]; 172 152 173 - // Fetch card author profiles 174 - const cardAuthorProfiles = new Map< 175 - string, 153 + // Fetch card author profiles using ProfileEnricher 154 + const cardAuthorProfilesResult = await profileEnricher.buildProfileMap( 155 + cardAuthorIds, 156 + query.callingUserId, 176 157 { 177 - id: string; 178 - name: string; 179 - handle: string; 180 - avatarUrl?: string; 181 - description?: string; 182 - } 183 - >(); 184 - const cardAuthorResults = await Promise.all( 185 - cardAuthorIds.map((authorId) => 186 - this.profileService.getProfile(authorId, query.callingUserId), 187 - ), 158 + skipFailures: true, // Skip cards with failed author profiles 159 + mapToUser: false, // Use inline profile (without isFollowing) 160 + }, 188 161 ); 189 162 190 - cardAuthorResults.forEach((profileResult, idx) => { 191 - const authorId = cardAuthorIds[idx]; 192 - if (!authorId) { 193 - return; 194 - } 195 - if (profileResult.isOk()) { 196 - const profile = profileResult.value; 197 - cardAuthorProfiles.set(authorId, { 198 - id: profile.id, 199 - name: profile.name, 200 - handle: profile.handle, 201 - avatarUrl: profile.avatarUrl, 202 - description: profile.bio, 203 - }); 204 - } 205 - }); 163 + if (cardAuthorProfilesResult.isErr()) { 164 + return err( 165 + AppError.UnexpectedError.create(cardAuthorProfilesResult.error), 166 + ); 167 + } 168 + 169 + const cardAuthorProfiles = cardAuthorProfilesResult.value; 206 170 207 171 // Get collection data for activities that have collections 208 172 const collectionIds = [ ··· 216 180 ), 217 181 ]; 218 182 219 - const collectionDataMap = new Map< 220 - string, 221 - { 222 - id: string; 223 - uri?: string; 224 - name: string; 225 - description?: string; 226 - accessType: CollectionAccessType; 227 - author: { 228 - id: string; 229 - name: string; 230 - handle: string; 231 - avatarUrl?: string; 232 - description?: string; 233 - }; 234 - cardCount: number; 235 - createdAt: string; 236 - updatedAt: string; 237 - cardIds: Set<string>; // Track which cards are in this collection 238 - } 239 - >(); 240 - // Fetch all collections in parallel using Promise.all 183 + // Fetch all collections first (without author data) 241 184 const collectionResults = await Promise.all( 242 185 collectionIds.map(async (collectionId) => { 243 186 const collectionIdResult = ··· 253 196 } 254 197 255 198 const collection = collectionResult.value; 256 - 257 - // Get author profile 258 - const authorProfileResult = await this.profileService.getProfile( 259 - collection.authorId.value, 260 - query.callingUserId, 261 - ); 262 - if (authorProfileResult.isErr()) { 263 - return null; 264 - } 265 - 266 - const authorProfile = authorProfileResult.value; 267 199 const uri = collection.publishedRecordId?.uri; 268 200 269 201 // Get the card IDs in this collection ··· 277 209 name: collection.name.toString(), 278 210 description: collection.description?.toString(), 279 211 accessType: collection.accessType, 280 - author: { 281 - id: authorProfile.id, 282 - name: authorProfile.name, 283 - handle: authorProfile.handle, 284 - avatarUrl: authorProfile.avatarUrl, 285 - description: authorProfile.bio, 286 - }, 212 + authorId: collection.authorId.value, 287 213 cardCount: collection.cardCount, 288 214 createdAt: collection.createdAt.toISOString(), 289 215 updatedAt: collection.updatedAt.toISOString(), ··· 293 219 }), 294 220 ); 295 221 296 - collectionResults.forEach((result) => { 297 - if (result) { 298 - collectionDataMap.set(result.collectionId, { 299 - id: result.id, 300 - uri: result.uri, 301 - name: result.name, 302 - description: result.description, 303 - accessType: result.accessType, 304 - author: result.author, 305 - cardCount: result.cardCount, 306 - createdAt: result.createdAt, 307 - updatedAt: result.updatedAt, 308 - cardIds: result.cardIds, 309 - }); 222 + const validCollections = collectionResults.filter( 223 + (result) => result !== null, 224 + ); 225 + 226 + // Get unique collection author IDs 227 + const collectionAuthorIds = [ 228 + ...new Set(validCollections.map((c) => c.authorId)), 229 + ]; 230 + 231 + // Batch fetch collection author profiles using ProfileEnricher 232 + const collectionAuthorProfilesResult = 233 + await profileEnricher.buildProfileMap( 234 + collectionAuthorIds, 235 + query.callingUserId, 236 + { 237 + skipFailures: true, // Skip collections with failed author profiles 238 + mapToUser: false, // Use inline profile (without isFollowing) 239 + }, 240 + ); 241 + 242 + if (collectionAuthorProfilesResult.isErr()) { 243 + return err( 244 + AppError.UnexpectedError.create(collectionAuthorProfilesResult.error), 245 + ); 246 + } 247 + 248 + const collectionAuthorProfiles = collectionAuthorProfilesResult.value; 249 + 250 + // Build collection data map with enriched author data 251 + const collectionDataMap = new Map< 252 + string, 253 + { 254 + id: string; 255 + uri?: string; 256 + name: string; 257 + description?: string; 258 + accessType: CollectionAccessType; 259 + author: { 260 + id: string; 261 + name: string; 262 + handle: string; 263 + avatarUrl?: string; 264 + bannerUrl?: string; 265 + description?: string; 266 + }; 267 + cardCount: number; 268 + createdAt: string; 269 + updatedAt: string; 270 + cardIds: Set<string>; // Track which cards are in this collection 310 271 } 272 + >(); 273 + 274 + validCollections.forEach((result) => { 275 + const author = collectionAuthorProfiles.get(result.authorId); 276 + if (!author) { 277 + return; // Skip collections with missing author profiles 278 + } 279 + 280 + collectionDataMap.set(result.collectionId, { 281 + id: result.id, 282 + uri: result.uri, 283 + name: result.name, 284 + description: result.description, 285 + accessType: result.accessType, 286 + author: { 287 + id: author.id, 288 + name: author.name, 289 + handle: author.handle, 290 + avatarUrl: author.avatarUrl, 291 + bannerUrl: author.bannerUrl, 292 + description: author.description, 293 + }, 294 + cardCount: result.cardCount, 295 + createdAt: result.createdAt, 296 + updatedAt: result.updatedAt, 297 + cardIds: result.cardIds, 298 + }); 311 299 }); 312 300 313 301 // Transform activities to FeedItem
+107 -119
src/modules/feeds/application/useCases/queries/GetGemActivityFeedUseCase.ts
··· 19 19 import { UrlType } from '../../../../cards/domain/value-objects/UrlType'; 20 20 import { GetGlobalFeedResponse, FeedItem, ActivitySource } from '@semble/types'; 21 21 import { CollectionAccessType } from '../../../../cards/domain/Collection'; 22 + import { ProfileEnricher } from '../../../../cards/application/services/ProfileEnricher'; 22 23 23 24 export interface GetGemActivityFeedQuery { 24 25 callingUserId?: string; ··· 157 158 ...new Set(feed.activities.map((activity) => activity.actorId.value)), 158 159 ]; 159 160 160 - // Fetch profiles for all actors 161 - const actorProfiles = new Map< 162 - string, 161 + // Fetch profiles for all actors using ProfileEnricher 162 + const profileEnricher = new ProfileEnricher(this.profileService); 163 + const actorProfilesResult = await profileEnricher.buildProfileMap( 164 + actorIds, 165 + undefined, // No calling user for actor profiles 163 166 { 164 - id: string; 165 - name: string; 166 - handle: string; 167 - avatarUrl?: string; 168 - description?: string; 169 - } 170 - >(); 171 - const profileResults = await Promise.all( 172 - actorIds.map((actorId) => this.profileService.getProfile(actorId)), 167 + skipFailures: true, 168 + includeFallback: true, 169 + mapToUser: false, // Use inline profile (without isFollowing) 170 + }, 173 171 ); 174 172 175 - profileResults.forEach((profileResult, idx) => { 176 - const actorId = actorIds[idx]; 177 - if (!actorId) { 178 - return; 179 - } 180 - if (profileResult.isOk()) { 181 - const profile = profileResult.value; 182 - actorProfiles.set(actorId, { 183 - id: profile.id, 184 - name: profile.name, 185 - handle: profile.handle, 186 - avatarUrl: profile.avatarUrl, 187 - description: profile.bio, 188 - }); 189 - } else { 190 - // If profile fetch fails, create a fallback 191 - actorProfiles.set(actorId, { 192 - id: actorId, 193 - name: 'Unknown User', 194 - handle: actorId, 195 - }); 196 - } 197 - }); 173 + if (actorProfilesResult.isErr()) { 174 + return err(AppError.UnexpectedError.create(actorProfilesResult.error)); 175 + } 176 + 177 + const actorProfiles = actorProfilesResult.value; 198 178 199 179 // Get unique card IDs for hydration 200 180 const cardIds = [ ··· 226 206 ), 227 207 ]; 228 208 229 - // Fetch card author profiles 230 - const cardAuthorProfiles = new Map< 231 - string, 209 + // Fetch card author profiles using ProfileEnricher 210 + const cardAuthorProfilesResult = await profileEnricher.buildProfileMap( 211 + cardAuthorIds, 212 + query.callingUserId, 232 213 { 233 - id: string; 234 - name: string; 235 - handle: string; 236 - avatarUrl?: string; 237 - description?: string; 238 - } 239 - >(); 240 - const cardAuthorResults = await Promise.all( 241 - cardAuthorIds.map((authorId) => 242 - this.profileService.getProfile(authorId, query.callingUserId), 243 - ), 214 + skipFailures: true, // Skip cards with failed author profiles 215 + mapToUser: false, // Use inline profile (without isFollowing) 216 + }, 244 217 ); 245 218 246 - cardAuthorResults.forEach((profileResult, idx) => { 247 - const authorId = cardAuthorIds[idx]; 248 - if (!authorId) { 249 - return; 250 - } 251 - if (profileResult.isOk()) { 252 - const profile = profileResult.value; 253 - cardAuthorProfiles.set(authorId, { 254 - id: profile.id, 255 - name: profile.name, 256 - handle: profile.handle, 257 - avatarUrl: profile.avatarUrl, 258 - description: profile.bio, 259 - }); 260 - } 261 - }); 219 + if (cardAuthorProfilesResult.isErr()) { 220 + return err( 221 + AppError.UnexpectedError.create(cardAuthorProfilesResult.error), 222 + ); 223 + } 224 + 225 + const cardAuthorProfiles = cardAuthorProfilesResult.value; 262 226 263 227 // Get collection data for activities that have collections 264 228 const collectionIdStrings = [ ··· 272 236 ), 273 237 ]; 274 238 275 - const collectionDataMap = new Map< 276 - string, 277 - { 278 - id: string; 279 - uri?: string; 280 - name: string; 281 - description?: string; 282 - accessType: CollectionAccessType; 283 - author: { 284 - id: string; 285 - name: string; 286 - handle: string; 287 - avatarUrl?: string; 288 - description?: string; 289 - }; 290 - cardCount: number; 291 - createdAt: string; 292 - updatedAt: string; 293 - cardIds: Set<string>; // Track which cards are in this collection 294 - } 295 - >(); 296 - // Fetch all collections in parallel using Promise.all 239 + // Fetch all collections first (without author data) 297 240 const collectionResults = await Promise.all( 298 241 collectionIdStrings.map(async (collectionId) => { 299 242 const collectionIdResult = ··· 309 252 } 310 253 311 254 const collection = collectionResult.value; 312 - 313 - // Get author profile 314 - const authorProfileResult = await this.profileService.getProfile( 315 - collection.authorId.value, 316 - query.callingUserId, 317 - ); 318 - if (authorProfileResult.isErr()) { 319 - return null; 320 - } 321 - 322 - const authorProfile = authorProfileResult.value; 323 255 const uri = collection.publishedRecordId?.uri; 324 256 325 257 // Get the card IDs in this collection ··· 333 265 name: collection.name.toString(), 334 266 description: collection.description?.toString(), 335 267 accessType: collection.accessType, 336 - author: { 337 - id: authorProfile.id, 338 - name: authorProfile.name, 339 - handle: authorProfile.handle, 340 - avatarUrl: authorProfile.avatarUrl, 341 - description: authorProfile.bio, 342 - }, 268 + authorId: collection.authorId.value, 343 269 cardCount: collection.cardCount, 344 270 createdAt: collection.createdAt.toISOString(), 345 271 updatedAt: collection.updatedAt.toISOString(), ··· 349 275 }), 350 276 ); 351 277 352 - collectionResults.forEach((result) => { 353 - if (result) { 354 - collectionDataMap.set(result.collectionId, { 355 - id: result.id, 356 - uri: result.uri, 357 - name: result.name, 358 - description: result.description, 359 - accessType: result.accessType, 360 - author: result.author, 361 - cardCount: result.cardCount, 362 - createdAt: result.createdAt, 363 - updatedAt: result.updatedAt, 364 - cardIds: result.cardIds, 365 - }); 278 + const validCollections = collectionResults.filter( 279 + (result) => result !== null, 280 + ); 281 + 282 + // Get unique collection author IDs 283 + const collectionAuthorIds = [ 284 + ...new Set(validCollections.map((c) => c.authorId)), 285 + ]; 286 + 287 + // Batch fetch collection author profiles using ProfileEnricher 288 + const collectionAuthorProfilesResult = 289 + await profileEnricher.buildProfileMap( 290 + collectionAuthorIds, 291 + query.callingUserId, 292 + { 293 + skipFailures: true, // Skip collections with failed author profiles 294 + mapToUser: false, // Use inline profile (without isFollowing) 295 + }, 296 + ); 297 + 298 + if (collectionAuthorProfilesResult.isErr()) { 299 + return err( 300 + AppError.UnexpectedError.create(collectionAuthorProfilesResult.error), 301 + ); 302 + } 303 + 304 + const collectionAuthorProfiles = collectionAuthorProfilesResult.value; 305 + 306 + // Build collection data map with enriched author data 307 + const collectionDataMap = new Map< 308 + string, 309 + { 310 + id: string; 311 + uri?: string; 312 + name: string; 313 + description?: string; 314 + accessType: CollectionAccessType; 315 + author: { 316 + id: string; 317 + name: string; 318 + handle: string; 319 + avatarUrl?: string; 320 + bannerUrl?: string; 321 + description?: string; 322 + }; 323 + cardCount: number; 324 + createdAt: string; 325 + updatedAt: string; 326 + cardIds: Set<string>; // Track which cards are in this collection 366 327 } 328 + >(); 329 + 330 + validCollections.forEach((result) => { 331 + const author = collectionAuthorProfiles.get(result.authorId); 332 + if (!author) { 333 + return; // Skip collections with missing author profiles 334 + } 335 + 336 + collectionDataMap.set(result.collectionId, { 337 + id: result.id, 338 + uri: result.uri, 339 + name: result.name, 340 + description: result.description, 341 + accessType: result.accessType, 342 + author: { 343 + id: author.id, 344 + name: author.name, 345 + handle: author.handle, 346 + avatarUrl: author.avatarUrl, 347 + bannerUrl: author.bannerUrl, 348 + description: author.description, 349 + }, 350 + cardCount: result.cardCount, 351 + createdAt: result.createdAt, 352 + updatedAt: result.updatedAt, 353 + cardIds: result.cardIds, 354 + }); 367 355 }); 368 356 369 357 // Transform activities to FeedItem
+107 -119
src/modules/feeds/application/useCases/queries/GetGlobalFeedUseCase.ts
··· 16 16 import { CollectionAccessType } from '../../../../cards/domain/Collection'; 17 17 import { IFollowsRepository } from 'src/modules/user/domain/repositories/IFollowsRepository'; 18 18 import { FollowTargetType } from 'src/modules/user/domain/value-objects/FollowTargetType'; 19 + import { ProfileEnricher } from '../../../../cards/application/services/ProfileEnricher'; 19 20 20 21 export interface GetGlobalFeedQuery { 21 22 callingUserId?: string; ··· 101 102 ...new Set(feed.activities.map((activity) => activity.actorId.value)), 102 103 ]; 103 104 104 - // Fetch profiles for all actors 105 - const actorProfiles = new Map< 106 - string, 105 + // Fetch profiles for all actors using ProfileEnricher 106 + const profileEnricher = new ProfileEnricher(this.profileService); 107 + const actorProfilesResult = await profileEnricher.buildProfileMap( 108 + actorIds, 109 + undefined, // No calling user for actor profiles 107 110 { 108 - id: string; 109 - name: string; 110 - handle: string; 111 - avatarUrl?: string; 112 - description?: string; 113 - } 114 - >(); 115 - const profileResults = await Promise.all( 116 - actorIds.map((actorId) => this.profileService.getProfile(actorId)), 111 + skipFailures: true, 112 + includeFallback: true, 113 + mapToUser: false, // Use inline profile (without isFollowing) 114 + }, 117 115 ); 118 116 119 - profileResults.forEach((profileResult, idx) => { 120 - const actorId = actorIds[idx]; 121 - if (!actorId) { 122 - return; 123 - } 124 - if (profileResult.isOk()) { 125 - const profile = profileResult.value; 126 - actorProfiles.set(actorId, { 127 - id: profile.id, 128 - name: profile.name, 129 - handle: profile.handle, 130 - avatarUrl: profile.avatarUrl, 131 - description: profile.bio, 132 - }); 133 - } else { 134 - // If profile fetch fails, create a fallback 135 - actorProfiles.set(actorId, { 136 - id: actorId, 137 - name: 'Unknown User', 138 - handle: actorId, 139 - }); 140 - } 141 - }); 117 + if (actorProfilesResult.isErr()) { 118 + return err(AppError.UnexpectedError.create(actorProfilesResult.error)); 119 + } 120 + 121 + const actorProfiles = actorProfilesResult.value; 142 122 143 123 // Get unique card IDs for hydration 144 124 const cardIds = [ ··· 170 150 ), 171 151 ]; 172 152 173 - // Fetch card author profiles 174 - const cardAuthorProfiles = new Map< 175 - string, 153 + // Fetch card author profiles using ProfileEnricher 154 + const cardAuthorProfilesResult = await profileEnricher.buildProfileMap( 155 + cardAuthorIds, 156 + query.callingUserId, 176 157 { 177 - id: string; 178 - name: string; 179 - handle: string; 180 - avatarUrl?: string; 181 - description?: string; 182 - } 183 - >(); 184 - const cardAuthorResults = await Promise.all( 185 - cardAuthorIds.map((authorId) => 186 - this.profileService.getProfile(authorId, query.callingUserId), 187 - ), 158 + skipFailures: true, // Skip cards with failed author profiles 159 + mapToUser: false, // Use inline profile (without isFollowing) 160 + }, 188 161 ); 189 162 190 - cardAuthorResults.forEach((profileResult, idx) => { 191 - const authorId = cardAuthorIds[idx]; 192 - if (!authorId) { 193 - return; 194 - } 195 - if (profileResult.isOk()) { 196 - const profile = profileResult.value; 197 - cardAuthorProfiles.set(authorId, { 198 - id: profile.id, 199 - name: profile.name, 200 - handle: profile.handle, 201 - avatarUrl: profile.avatarUrl, 202 - description: profile.bio, 203 - }); 204 - } 205 - }); 163 + if (cardAuthorProfilesResult.isErr()) { 164 + return err( 165 + AppError.UnexpectedError.create(cardAuthorProfilesResult.error), 166 + ); 167 + } 168 + 169 + const cardAuthorProfiles = cardAuthorProfilesResult.value; 206 170 207 171 // Get collection data for activities that have collections 208 172 const collectionIds = [ ··· 216 180 ), 217 181 ]; 218 182 219 - const collectionDataMap = new Map< 220 - string, 221 - { 222 - id: string; 223 - uri?: string; 224 - name: string; 225 - description?: string; 226 - accessType: CollectionAccessType; 227 - author: { 228 - id: string; 229 - name: string; 230 - handle: string; 231 - avatarUrl?: string; 232 - description?: string; 233 - }; 234 - cardCount: number; 235 - createdAt: string; 236 - updatedAt: string; 237 - cardIds: Set<string>; // Track which cards are in this collection 238 - } 239 - >(); 240 - // Fetch all collections in parallel using Promise.all 183 + // Fetch all collections first (without author data) 241 184 const collectionResults = await Promise.all( 242 185 collectionIds.map(async (collectionId) => { 243 186 const collectionIdResult = ··· 253 196 } 254 197 255 198 const collection = collectionResult.value; 256 - 257 - // Get author profile 258 - const authorProfileResult = await this.profileService.getProfile( 259 - collection.authorId.value, 260 - query.callingUserId, 261 - ); 262 - if (authorProfileResult.isErr()) { 263 - return null; 264 - } 265 - 266 - const authorProfile = authorProfileResult.value; 267 199 const uri = collection.publishedRecordId?.uri; 268 200 269 201 // Get the card IDs in this collection ··· 277 209 name: collection.name.toString(), 278 210 description: collection.description?.toString(), 279 211 accessType: collection.accessType, 280 - author: { 281 - id: authorProfile.id, 282 - name: authorProfile.name, 283 - handle: authorProfile.handle, 284 - avatarUrl: authorProfile.avatarUrl, 285 - description: authorProfile.bio, 286 - }, 212 + authorId: collection.authorId.value, 287 213 cardCount: collection.cardCount, 288 214 createdAt: collection.createdAt.toISOString(), 289 215 updatedAt: collection.updatedAt.toISOString(), ··· 293 219 }), 294 220 ); 295 221 296 - collectionResults.forEach((result) => { 297 - if (result) { 298 - collectionDataMap.set(result.collectionId, { 299 - id: result.id, 300 - uri: result.uri, 301 - name: result.name, 302 - description: result.description, 303 - accessType: result.accessType, 304 - author: result.author, 305 - cardCount: result.cardCount, 306 - createdAt: result.createdAt, 307 - updatedAt: result.updatedAt, 308 - cardIds: result.cardIds, 309 - }); 222 + const validCollections = collectionResults.filter( 223 + (result) => result !== null, 224 + ); 225 + 226 + // Get unique collection author IDs 227 + const collectionAuthorIds = [ 228 + ...new Set(validCollections.map((c) => c.authorId)), 229 + ]; 230 + 231 + // Batch fetch collection author profiles using ProfileEnricher 232 + const collectionAuthorProfilesResult = 233 + await profileEnricher.buildProfileMap( 234 + collectionAuthorIds, 235 + query.callingUserId, 236 + { 237 + skipFailures: true, // Skip collections with failed author profiles 238 + mapToUser: false, // Use inline profile (without isFollowing) 239 + }, 240 + ); 241 + 242 + if (collectionAuthorProfilesResult.isErr()) { 243 + return err( 244 + AppError.UnexpectedError.create(collectionAuthorProfilesResult.error), 245 + ); 246 + } 247 + 248 + const collectionAuthorProfiles = collectionAuthorProfilesResult.value; 249 + 250 + // Build collection data map with enriched author data 251 + const collectionDataMap = new Map< 252 + string, 253 + { 254 + id: string; 255 + uri?: string; 256 + name: string; 257 + description?: string; 258 + accessType: CollectionAccessType; 259 + author: { 260 + id: string; 261 + name: string; 262 + handle: string; 263 + avatarUrl?: string; 264 + bannerUrl?: string; 265 + description?: string; 266 + }; 267 + cardCount: number; 268 + createdAt: string; 269 + updatedAt: string; 270 + cardIds: Set<string>; // Track which cards are in this collection 310 271 } 272 + >(); 273 + 274 + validCollections.forEach((result) => { 275 + const author = collectionAuthorProfiles.get(result.authorId); 276 + if (!author) { 277 + return; // Skip collections with missing author profiles 278 + } 279 + 280 + collectionDataMap.set(result.collectionId, { 281 + id: result.id, 282 + uri: result.uri, 283 + name: result.name, 284 + description: result.description, 285 + accessType: result.accessType, 286 + author: { 287 + id: author.id, 288 + name: author.name, 289 + handle: author.handle, 290 + avatarUrl: author.avatarUrl, 291 + bannerUrl: author.bannerUrl, 292 + description: author.description, 293 + }, 294 + cardCount: result.cardCount, 295 + createdAt: result.createdAt, 296 + updatedAt: result.updatedAt, 297 + cardIds: result.cardIds, 298 + }); 311 299 }); 312 300 313 301 // Add follow status for collections if callingUserId is provided
+25 -16
src/modules/notifications/application/useCases/queries/GetMyNotificationsUseCase.ts
··· 7 7 import { IProfileService } from '../../../../cards/domain/services/IProfileService'; 8 8 import { NotificationItem } from '@semble/types'; 9 9 import { CollectionAccessType } from '../../../../cards/domain/Collection'; 10 + import { ProfileEnricher } from '../../../../cards/application/services/ProfileEnricher'; 10 11 11 12 export interface GetMyNotificationsDTO { 12 13 userId: string; ··· 100 101 }); 101 102 }); 102 103 103 - // Bulk fetch all profiles 104 - const profilePromises = Array.from(userIds).map((id) => 105 - this.profileService.getProfile(id, request.userId), 104 + // Bulk fetch all profiles using ProfileEnricher 105 + const profileEnricher = new ProfileEnricher(this.profileService); 106 + const profileMapResult = await profileEnricher.buildProfileMap( 107 + Array.from(userIds), 108 + request.userId, 109 + { 110 + skipFailures: true, // Skip profiles that fail to load 111 + mapToUser: true, // Use full User DTO with isFollowing 112 + }, 106 113 ); 107 - const profileResults = await Promise.all(profilePromises); 108 114 109 - // Build profile lookup map 110 - const profileMap = new Map<string, any>(); 111 - profileResults.forEach((result) => { 112 - if (result.isOk()) { 113 - profileMap.set(result.value.id, result.value); 114 - } 115 - }); 115 + if (profileMapResult.isErr()) { 116 + return err(AppError.UnexpectedError.create(profileMapResult.error)); 117 + } 118 + 119 + const profileMap = profileMapResult.value; 116 120 117 121 // Transform enriched notifications to DTOs 118 122 const notificationItems: NotificationItemDTO[] = []; ··· 147 151 name: collectionAuthorProfile.name, 148 152 handle: collectionAuthorProfile.handle, 149 153 avatarUrl: collectionAuthorProfile.avatarUrl, 150 - description: collectionAuthorProfile.bio, 154 + bannerUrl: collectionAuthorProfile.bannerUrl, 155 + description: collectionAuthorProfile.description, 151 156 }, 152 157 description: collection.description, 153 158 accessType: collection.accessType as CollectionAccessType, ··· 168 173 name: actorProfile.name, 169 174 handle: actorProfile.handle, 170 175 avatarUrl: actorProfile.avatarUrl, 171 - description: actorProfile.bio, 176 + bannerUrl: actorProfile.bannerUrl, 177 + description: actorProfile.description, 172 178 isFollowing: actorProfile.isFollowing, 173 179 }, 174 180 createdAt: notification.createdAt.toISOString(), ··· 213 219 name: collectionAuthorProfile.name, 214 220 handle: collectionAuthorProfile.handle, 215 221 avatarUrl: collectionAuthorProfile.avatarUrl, 216 - description: collectionAuthorProfile.bio, 222 + bannerUrl: collectionAuthorProfile.bannerUrl, 223 + description: collectionAuthorProfile.description, 217 224 }, 218 225 description: collection.description, 219 226 accessType: collection.accessType as CollectionAccessType, ··· 234 241 name: actorProfile.name, 235 242 handle: actorProfile.handle, 236 243 avatarUrl: actorProfile.avatarUrl, 237 - description: actorProfile.bio, 244 + bannerUrl: actorProfile.bannerUrl, 245 + description: actorProfile.description, 238 246 isFollowing: actorProfile.isFollowing, 239 247 }, 240 248 card: { ··· 265 273 name: cardAuthorProfile.name, 266 274 handle: cardAuthorProfile.handle, 267 275 avatarUrl: cardAuthorProfile.avatarUrl, 268 - description: cardAuthorProfile.bio, 276 + bannerUrl: cardAuthorProfile.bannerUrl, 277 + description: cardAuthorProfile.description, 269 278 }, 270 279 note: notification.cardNote, 271 280 },
+13 -1
src/modules/user/application/useCases/commands/FollowTargetUseCase.ts
··· 13 13 import { CollectionId } from '../../../../cards/domain/value-objects/CollectionId'; 14 14 import { PublishedRecordId } from '../../../../cards/domain/value-objects/PublishedRecordId'; 15 15 import { AuthenticationError } from '../../../../../shared/core/AuthenticationError'; 16 + import { IProfileService } from '../../../../cards/domain/services/IProfileService'; 16 17 17 18 export interface FollowTargetDTO { 18 19 followerId: string; // DID ··· 40 41 private userRepository: IUserRepository, 41 42 private collectionRepository: ICollectionRepository, 42 43 private followPublisher: IFollowPublisher, 44 + private profileService: IProfileService, 43 45 eventPublisher: IEventPublisher, 44 46 ) { 45 47 super(eventPublisher); ··· 209 211 // Don't fail the operation 210 212 } 211 213 212 - // 12. Return success 214 + // 12. Invalidate profile counts cache 215 + // Invalidate follower's counts (followingCount increased) 216 + if (this.profileService.invalidateCounts) { 217 + await this.profileService.invalidateCounts(request.followerId); 218 + } 219 + // Invalidate target's counts (followerCount increased, only for USER targets) 220 + if (targetType.value === 'USER' && this.profileService.invalidateCounts) { 221 + await this.profileService.invalidateCounts(request.targetId); 222 + } 223 + 224 + // 13. Return success 213 225 return ok({ 214 226 followId: follow.followId.toString(), 215 227 });
+13 -1
src/modules/user/application/useCases/commands/UnfollowTargetUseCase.ts
··· 7 7 import { IFollowPublisher } from '../../ports/IFollowPublisher'; 8 8 import { DID } from '../../../domain/value-objects/DID'; 9 9 import { FollowTargetType } from '../../../domain/value-objects/FollowTargetType'; 10 + import { IProfileService } from '../../../../cards/domain/services/IProfileService'; 10 11 11 12 export interface UnfollowTargetDTO { 12 13 followerId: string; // DID ··· 28 29 constructor( 29 30 private followsRepository: IFollowsRepository, 30 31 private followPublisher: IFollowPublisher, 32 + private profileService: IProfileService, 31 33 eventPublisher: IEventPublisher, 32 34 ) { 33 35 super(eventPublisher); ··· 120 122 // Don't fail the operation 121 123 } 122 124 123 - // 9. Return success 125 + // 9. Invalidate profile counts cache 126 + // Invalidate follower's counts (followingCount decreased) 127 + if (this.profileService.invalidateCounts) { 128 + await this.profileService.invalidateCounts(request.followerId); 129 + } 130 + // Invalidate target's counts (followerCount decreased, only for USER targets) 131 + if (targetType.value === 'USER' && this.profileService.invalidateCounts) { 132 + await this.profileService.invalidateCounts(request.targetId); 133 + } 134 + 135 + // 10. Return success 124 136 return ok(undefined); 125 137 } catch (error) { 126 138 return err(AppError.UnexpectedError.create(error));
+18 -30
src/modules/user/application/useCases/queries/GetCollectionFollowersUseCase.ts
··· 6 6 import { FollowTargetType } from '../../../domain/value-objects/FollowTargetType'; 7 7 import { CollectionId } from 'src/modules/cards/domain/value-objects/CollectionId'; 8 8 import { User } from '@semble/types'; 9 + import { ProfileEnricher } from 'src/modules/cards/application/services/ProfileEnricher'; 9 10 10 11 export interface GetCollectionFollowersQuery { 11 12 collectionId: string; // Collection UUID ··· 103 104 new Set(paginatedFollows.map((follow) => follow.followerId.value)), 104 105 ); 105 106 106 - // Fetch profiles for all followers 107 - const profilePromises = uniqueFollowerIds.map((followerId) => 108 - this.profileService.getProfile(followerId, query.callingUserId), 107 + // Fetch profiles for all followers using ProfileEnricher 108 + const profileEnricher = new ProfileEnricher(this.profileService); 109 + const profileMapResult = await profileEnricher.buildProfileMap( 110 + uniqueFollowerIds, 111 + query.callingUserId, 112 + { 113 + skipFailures: false, // Fail if any profile fetch fails (preserving original behavior) 114 + mapToUser: true, // Use full User DTO with isFollowing 115 + }, 109 116 ); 110 117 111 - const profileResults = await Promise.all(profilePromises); 112 - 113 - // Create a map of profiles 114 - const profileMap = new Map<string, User>(); 118 + if (profileMapResult.isErr()) { 119 + return err( 120 + new Error( 121 + `Failed to fetch user profiles: ${profileMapResult.error.message}`, 122 + ), 123 + ); 124 + } 115 125 116 - for (let i = 0; i < uniqueFollowerIds.length; i++) { 117 - const profileResult = profileResults[i]; 118 - const followerId = uniqueFollowerIds[i]; 119 - if (!profileResult || !followerId) { 120 - return err(new Error('Missing profile result or follower ID')); 121 - } 122 - if (profileResult.isErr()) { 123 - return err( 124 - new Error( 125 - `Failed to fetch user profile: ${profileResult.error instanceof Error ? profileResult.error.message : 'Unknown error'}`, 126 - ), 127 - ); 128 - } 129 - const profile = profileResult.value; 130 - profileMap.set(followerId, { 131 - id: profile.id, 132 - name: profile.name, 133 - handle: profile.handle, 134 - avatarUrl: profile.avatarUrl, 135 - description: profile.bio, 136 - isFollowing: profile.isFollowing, 137 - }); 138 - } 126 + const profileMap = profileMapResult.value; 139 127 140 128 // Build users array in the order of follows (chronological) 141 129 const users: User[] = paginatedFollows
+18 -30
src/modules/user/application/useCases/queries/GetFollowersUseCase.ts
··· 6 6 import { DIDOrHandle } from 'src/modules/atproto/domain/DIDOrHandle'; 7 7 import { FollowTargetType } from '../../../domain/value-objects/FollowTargetType'; 8 8 import { User } from '@semble/types'; 9 + import { ProfileEnricher } from 'src/modules/cards/application/services/ProfileEnricher'; 9 10 10 11 export interface GetFollowersQuery { 11 12 userId: string; // DID or handle ··· 91 92 new Set(paginatedFollows.map((follow) => follow.followerId.value)), 92 93 ); 93 94 94 - // Fetch profiles for all followers 95 - const profilePromises = uniqueFollowerIds.map((followerId) => 96 - this.profileService.getProfile(followerId, query.callingUserId), 95 + // Fetch profiles for all followers using ProfileEnricher 96 + const profileEnricher = new ProfileEnricher(this.profileService); 97 + const profileMapResult = await profileEnricher.buildProfileMap( 98 + uniqueFollowerIds, 99 + query.callingUserId, 100 + { 101 + skipFailures: false, // Fail if any profile fetch fails (preserving original behavior) 102 + mapToUser: true, // Use full User DTO with isFollowing 103 + }, 97 104 ); 98 105 99 - const profileResults = await Promise.all(profilePromises); 100 - 101 - // Create a map of profiles 102 - const profileMap = new Map<string, User>(); 106 + if (profileMapResult.isErr()) { 107 + return err( 108 + new Error( 109 + `Failed to fetch user profiles: ${profileMapResult.error.message}`, 110 + ), 111 + ); 112 + } 103 113 104 - for (let i = 0; i < uniqueFollowerIds.length; i++) { 105 - const profileResult = profileResults[i]; 106 - const followerId = uniqueFollowerIds[i]; 107 - if (!profileResult || !followerId) { 108 - return err(new Error('Missing profile result or follower ID')); 109 - } 110 - if (profileResult.isErr()) { 111 - return err( 112 - new Error( 113 - `Failed to fetch user profile: ${profileResult.error instanceof Error ? profileResult.error.message : 'Unknown error'}`, 114 - ), 115 - ); 116 - } 117 - const profile = profileResult.value; 118 - profileMap.set(followerId, { 119 - id: profile.id, 120 - name: profile.name, 121 - handle: profile.handle, 122 - avatarUrl: profile.avatarUrl, 123 - description: profile.bio, 124 - isFollowing: profile.isFollowing, 125 - }); 126 - } 114 + const profileMap = profileMapResult.value; 127 115 128 116 // Build users array in the order of follows (chronological) 129 117 const users: User[] = paginatedFollows
+18 -30
src/modules/user/application/useCases/queries/GetFollowingUsersUseCase.ts
··· 6 6 import { DIDOrHandle } from 'src/modules/atproto/domain/DIDOrHandle'; 7 7 import { FollowTargetType } from '../../../domain/value-objects/FollowTargetType'; 8 8 import { User } from '@semble/types'; 9 + import { ProfileEnricher } from 'src/modules/cards/application/services/ProfileEnricher'; 9 10 10 11 export interface GetFollowingUsersQuery { 11 12 userId: string; // DID or handle ··· 89 90 new Set(follows.map((follow) => follow.targetId)), 90 91 ); 91 92 92 - // Fetch profiles for all followed users 93 - const profilePromises = uniqueTargetIds.map((targetId) => 94 - this.profileService.getProfile(targetId, query.callingUserId), 93 + // Fetch profiles for all followed users using ProfileEnricher 94 + const profileEnricher = new ProfileEnricher(this.profileService); 95 + const profileMapResult = await profileEnricher.buildProfileMap( 96 + uniqueTargetIds, 97 + query.callingUserId, 98 + { 99 + skipFailures: false, // Fail if any profile fetch fails (preserving original behavior) 100 + mapToUser: true, // Use full User DTO with isFollowing 101 + }, 95 102 ); 96 103 97 - const profileResults = await Promise.all(profilePromises); 98 - 99 - // Create a map of profiles 100 - const profileMap = new Map<string, User>(); 104 + if (profileMapResult.isErr()) { 105 + return err( 106 + new Error( 107 + `Failed to fetch user profiles: ${profileMapResult.error.message}`, 108 + ), 109 + ); 110 + } 101 111 102 - for (let i = 0; i < uniqueTargetIds.length; i++) { 103 - const profileResult = profileResults[i]; 104 - const targetId = uniqueTargetIds[i]; 105 - if (!profileResult || !targetId) { 106 - return err(new Error('Missing profile result or target ID')); 107 - } 108 - if (profileResult.isErr()) { 109 - return err( 110 - new Error( 111 - `Failed to fetch user profile: ${profileResult.error instanceof Error ? profileResult.error.message : 'Unknown error'}`, 112 - ), 113 - ); 114 - } 115 - const profile = profileResult.value; 116 - profileMap.set(targetId, { 117 - id: profile.id, 118 - name: profile.name, 119 - handle: profile.handle, 120 - avatarUrl: profile.avatarUrl, 121 - description: profile.bio, 122 - isFollowing: profile.isFollowing, 123 - }); 124 - } 112 + const profileMap = profileMapResult.value; 125 113 126 114 // Build users array in the order of follows (chronological) 127 115 const users: User[] = follows
+6 -3
src/packages/semble-pds-client/package.json
··· 1 1 { 2 2 "name": "@cosmik.network/semble-pds-client", 3 - "version": "0.0.6", 3 + "version": "0.0.9", 4 4 "description": "A lightweight PDS client and AtpAgent wrapper for creating and reading Semble records (Cards, Collections, CollectionLinks) directly to your PDS", 5 5 "main": "dist/index.js", 6 6 "types": "dist/index.d.ts", ··· 17 17 "url-metadata": "^5.2.3" 18 18 }, 19 19 "devDependencies": { 20 - "typescript": "^5.8.3", 21 - "@types/node": "^20.10.4" 20 + "@types/jest": "^29.5.14", 21 + "@types/node": "^20.10.4", 22 + "jest": "^29.7.0", 23 + "ts-jest": "^29.4.6", 24 + "typescript": "^5.8.3" 22 25 }, 23 26 "keywords": [ 24 27 "atproto",
+74
src/packages/semble-pds-client/src/cards/CardManager.test.ts
··· 1 + import { CardManager } from './CardManager'; 2 + 3 + describe('CardManager', () => { 4 + let cardManager: CardManager; 5 + let mockAgent: any; 6 + 7 + beforeEach(() => { 8 + mockAgent = { 9 + session: { did: 'did:plc:test123' }, 10 + com: { 11 + atproto: { 12 + repo: { 13 + getRecord: jest.fn(), 14 + putRecord: jest.fn(), 15 + createRecord: jest.fn(), 16 + }, 17 + }, 18 + }, 19 + }; 20 + cardManager = new CardManager(mockAgent, 'app.semble', 'app.semble.card'); 21 + }); 22 + 23 + it('should preserve all fields when updating note', async () => { 24 + const existingRecord = { 25 + data: { 26 + value: { 27 + $type: 'app.semble.card', 28 + type: 'NOTE', 29 + url: 'https://example.com', 30 + content: { 31 + $type: 'app.semble.card#noteContent', 32 + text: 'Old text', 33 + }, 34 + parentCard: { 35 + uri: 'at://did:plc:parent/app.semble.card/123', 36 + cid: 'bafycid123', 37 + }, 38 + createdAt: '2024-01-01T00:00:00.000Z', 39 + }, 40 + }, 41 + }; 42 + 43 + mockAgent.com.atproto.repo.getRecord.mockResolvedValue(existingRecord); 44 + 45 + await cardManager.updateNote( 46 + { uri: 'at://did:plc:test123/app.semble.card/456', cid: 'bafycid456' }, 47 + 'New text', 48 + ); 49 + 50 + const putRecordCall = mockAgent.com.atproto.repo.putRecord.mock.calls[0][0]; 51 + 52 + // Check all fields are preserved 53 + expect(putRecordCall.repo).toBe('did:plc:test123'); 54 + expect(putRecordCall.collection).toBe('app.semble.card'); 55 + expect(putRecordCall.rkey).toBe('456'); 56 + 57 + // Check record structure 58 + expect(putRecordCall.record.$type).toBe('app.semble.card'); 59 + expect(putRecordCall.record.type).toBe('NOTE'); 60 + expect(putRecordCall.record.url).toBe('https://example.com'); // ✅ Preserved 61 + expect(putRecordCall.record.content.text).toBe('New text'); // ✅ Updated 62 + expect(putRecordCall.record.content.$type).toBe( 63 + 'app.semble.card#noteContent', 64 + ); 65 + expect(putRecordCall.record.parentCard).toEqual({ 66 + // ✅ Preserved 67 + uri: 'at://did:plc:parent/app.semble.card/123', 68 + cid: 'bafycid123', 69 + }); 70 + expect(putRecordCall.record.createdAt).toBe('2024-01-01T00:00:00.000Z'); // ✅ Preserved 71 + expect(putRecordCall.record.updatedAt).toBeDefined(); // ✅ Added 72 + expect(typeof putRecordCall.record.updatedAt).toBe('string'); 73 + }); 74 + });
+30 -18
src/packages/semble-pds-client/src/cards/CardManager.ts
··· 7 7 ListQueryParams, 8 8 GetCardsResult, 9 9 } from '../types'; 10 + import { createAgentForDID } from '../utils/didResolver'; 10 11 import { MetadataFetcher } from '../metadata/MetadataFetcher'; 11 12 12 13 export class CardManager { ··· 141 142 142 143 const rkey = this.extractRkey(noteRef.uri); 143 144 145 + const existing = await this.agent.com.atproto.repo.getRecord({ 146 + repo: this.agent.session.did, 147 + collection: this.cardCollection, 148 + rkey, 149 + }); 150 + 144 151 const record = { 145 - $type: this.cardCollection, 146 - type: 'NOTE', 152 + ...existing.data.value, 147 153 content: { 148 154 $type: `${this.baseNsid}.card#noteContent`, 149 155 text: updatedText, 150 156 }, 151 - createdAt: new Date().toISOString(), 157 + updatedAt: new Date().toISOString(), 152 158 }; 153 159 154 160 await this.agent.com.atproto.repo.putRecord({ ··· 220 226 did: string, 221 227 params?: ListQueryParams, 222 228 ): Promise<GetCardsResult> { 223 - const response = await this.agent.com.atproto.repo.listRecords({ 224 - repo: did, 225 - collection: this.cardCollection, 226 - limit: params?.limit, 227 - cursor: params?.cursor, 228 - reverse: params?.reverse, 229 - }); 229 + try { 230 + const userAgent = await createAgentForDID(did); 231 + const response = await userAgent.com.atproto.repo.listRecords({ 232 + repo: did, 233 + collection: this.cardCollection, 234 + limit: params?.limit, 235 + cursor: params?.cursor, 236 + reverse: params?.reverse, 237 + }); 230 238 231 - return { 232 - cursor: response.data.cursor, 233 - records: response.data.records.map((record) => ({ 234 - uri: record.uri, 235 - cid: record.cid, 236 - value: record.value as CardRecord['value'], 237 - })), 238 - }; 239 + return { 240 + cursor: response.data.cursor, 241 + records: response.data.records.map((record) => ({ 242 + uri: record.uri, 243 + cid: record.cid, 244 + value: record.value as CardRecord['value'], 245 + })), 246 + }; 247 + } catch (error) { 248 + console.error('Error fetching cards for user:', error); 249 + throw error; 250 + } 239 251 } 240 252 241 253 private extractRkey(uri: string): string {
+22 -16
src/packages/semble-pds-client/src/collections/CollectionManager.ts
··· 1 1 import { AtpAgent } from '@atproto/api'; 2 + import { createAgentForDID } from '../utils/didResolver'; 2 3 import { 3 4 StrongRef, 4 5 CreateCollectionOptions, ··· 130 131 did: string, 131 132 params?: ListQueryParams, 132 133 ): Promise<GetCollectionsResult> { 133 - const response = await this.agent.com.atproto.repo.listRecords({ 134 - repo: did, 135 - collection: this.collectionCollection, 136 - limit: params?.limit, 137 - cursor: params?.cursor, 138 - reverse: params?.reverse, 139 - }); 140 - 141 - return { 142 - cursor: response.data.cursor, 143 - records: response.data.records.map((record) => ({ 144 - uri: record.uri, 145 - cid: record.cid, 146 - value: record.value as CollectionRecord['value'], 147 - })), 148 - }; 134 + try { 135 + const userAgent = await createAgentForDID(did); 136 + const response = await userAgent.com.atproto.repo.listRecords({ 137 + repo: did, 138 + collection: this.collectionCollection, 139 + limit: params?.limit, 140 + cursor: params?.cursor, 141 + reverse: params?.reverse, 142 + }); 143 + return { 144 + cursor: response.data.cursor, 145 + records: response.data.records.map((record) => ({ 146 + uri: record.uri, 147 + cid: record.cid, 148 + value: record.value as CollectionRecord['value'], 149 + })), 150 + }; 151 + } catch (error) { 152 + console.error('Error fetching collections for user:', error); 153 + throw error; 154 + } 149 155 } 150 156 151 157 private extractRkey(uri: string): string {
+60
src/packages/semble-pds-client/src/utils/didResolver.ts
··· 1 + import { AtpAgent } from '@atproto/api'; 2 + import { DidResolver } from '@atproto/identity'; 3 + 4 + export interface DIDDocument { 5 + id: string; 6 + alsoKnownAs?: string[]; 7 + verificationMethod?: any[]; 8 + service?: Array<{ 9 + id: string; 10 + type: string; 11 + serviceEndpoint: string; 12 + }>; 13 + } 14 + 15 + const didResolver = new DidResolver({}); 16 + 17 + /** 18 + * Resolve a DID to find the user's PDS URL 19 + */ 20 + export async function resolveDIDToPDS(did: string): Promise<string> { 21 + try { 22 + // Use the official @atproto/identity resolver 23 + const didDoc = await didResolver.resolve(did); 24 + 25 + if (!didDoc) { 26 + throw new Error(`Could not resolve DID: ${did}`); 27 + } 28 + 29 + // Extract PDS service endpoint 30 + const pdsService = didDoc.service?.find((s) => s.id === '#atproto_pds'); 31 + 32 + if (!pdsService?.serviceEndpoint) { 33 + throw new Error(`No PDS found for DID: ${did}`); 34 + } 35 + 36 + // Handle both string and object formats 37 + const pdsUrl = 38 + typeof pdsService.serviceEndpoint === 'string' 39 + ? pdsService.serviceEndpoint 40 + : pdsService.serviceEndpoint.uri || 41 + (pdsService.serviceEndpoint as any).url; 42 + 43 + if (!pdsUrl) { 44 + throw new Error(`Invalid PDS endpoint format for DID: ${did}`); 45 + } 46 + 47 + return pdsUrl; 48 + } catch (error) { 49 + console.error('Error resolving DID:', error); 50 + throw error; 51 + } 52 + } 53 + 54 + /** 55 + * Create an AtpAgent for a specific user's PDS 56 + */ 57 + export async function createAgentForDID(did: string): Promise<AtpAgent> { 58 + const pdsUrl = await resolveDIDToPDS(did); 59 + return new AtpAgent({ service: pdsUrl }); 60 + }
+1 -1
src/packages/semble-pds-client/tsconfig.json
··· 14 14 "sourceMap": true 15 15 }, 16 16 "include": ["src/**/*"], 17 - "exclude": ["node_modules", "dist"] 17 + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"] 18 18 }
+4
src/shared/infrastructure/http/factories/UseCaseFactory.ts
··· 197 197 repositories.userRepository, 198 198 repositories.collectionRepository, 199 199 services.followPublisher, 200 + services.profileService, 200 201 services.eventPublisher, 201 202 ), 202 203 unfollowTargetUseCase: new UnfollowTargetUseCase( 203 204 repositories.followsRepository, 204 205 services.followPublisher, 206 + services.profileService, 205 207 services.eventPublisher, 206 208 ), 207 209 getFollowingUsersUseCase: new GetFollowingUsersUseCase( ··· 496 498 repositories.userRepository, 497 499 repositories.collectionRepository, 498 500 services.followPublisher, 501 + services.profileService, 499 502 services.eventPublisher, 500 503 ); 501 504 502 505 const unfollowTargetUseCase = new UnfollowTargetUseCase( 503 506 repositories.followsRepository, 504 507 services.followPublisher, 508 + services.profileService, 505 509 services.eventPublisher, 506 510 ); 507 511
+13
src/types/src/api/common.ts
··· 4 4 name: string; 5 5 handle: string; 6 6 avatarUrl?: string; 7 + bannerUrl?: string; 7 8 description?: string; 8 9 isFollowing?: boolean; // Whether the calling user follows this user 10 + followsYou?: boolean; // Whether this user follows the calling user 11 + followerCount?: number; // Number of users following this user 12 + followingCount?: number; // Number of users this user follows 13 + followedCollectionsCount?: number; // Number of collections this user follows 9 14 } 15 + 16 + // Type alias for inline profile objects (without isFollowing) 17 + // Used for nested author objects in collections, cards, etc. 18 + export type UserProfileDTO = Omit<User, 'isFollowing'>; 19 + 20 + // Type alias for minimal profile objects (without description) 21 + // Used for card authors in collection pages and other compact displays 22 + export type MinimalUserProfile = Omit<UserProfileDTO, 'description'>; 10 23 11 24 // Base pagination interface 12 25 export interface Pagination {
-1
src/types/src/api/internal.ts
··· 26 26 export type { LoginWithAppPasswordRequest as LoginWithAppPasswordDTO } from './requests'; 27 27 28 28 // Type aliases for backend DTO naming conventions 29 - export type { User as UserProfileDTO } from './common'; 30 29 export type { Collection as CollectionDTO } from './responses'; 31 30 export type { UrlCard as UrlCardDTO } from './responses'; 32 31 export type { Pagination as PaginationDTO } from './common';
+11
src/webapp/app/(dashboard)/profile/[handle]/(withoutHeader)/collections/[rkey]/(content)/page.tsx
··· 1 + import CollectionContainer from '@/features/collections/containers/collectionContainer/CollectionContainer'; 2 + 3 + interface Props { 4 + params: Promise<{ rkey: string; handle: string }>; 5 + } 6 + 7 + export default async function Page(props: Props) { 8 + const { rkey, handle } = await props.params; 9 + 10 + return <CollectionContainer handle={handle} rkey={rkey} />; 11 + }
+61
src/webapp/app/(dashboard)/profile/[handle]/(withoutHeader)/collections/[rkey]/followers/layout.tsx
··· 1 + import BackButton from '@/components/navigation/backButton/BackButton'; 2 + import Header from '@/components/navigation/header/Header'; 3 + import { getCollectionPageByAtUri } from '@/features/collections/lib/dal'; 4 + import { truncateText } from '@/lib/utils/text'; 5 + import type { Metadata } from 'next'; 6 + import { Fragment } from 'react'; 7 + 8 + interface Props { 9 + params: Promise<{ rkey: string; handle: string }>; 10 + children: React.ReactNode; 11 + } 12 + 13 + export async function generateMetadata({ params }: Props): Promise<Metadata> { 14 + const { rkey, handle } = await params; 15 + 16 + const collection = await getCollectionPageByAtUri({ 17 + recordKey: rkey, 18 + handle: handle, 19 + }); 20 + 21 + return { 22 + title: `${collection.name} (by ${collection.author.name})`, 23 + description: 24 + collection.description ?? 25 + `View ${collection.author.name}'s collection on Semble`, 26 + authors: [ 27 + { 28 + name: collection.author.name, 29 + url: `${process.env.APP_URL}/profile/${handle}`, 30 + }, 31 + ], 32 + alternates: { 33 + types: { 34 + '': `${collection.uri}`, 35 + }, 36 + }, 37 + other: { 38 + 'atprotocol:creator': `at://${collection.author.id}`, 39 + }, 40 + }; 41 + } 42 + 43 + export default async function Layout(props: Props) { 44 + const { rkey, handle } = await props.params; 45 + 46 + const collection = await getCollectionPageByAtUri({ 47 + recordKey: rkey, 48 + handle, 49 + }); 50 + 51 + return ( 52 + <Fragment> 53 + <Header> 54 + <BackButton href={`/profile/${handle}/collections/${rkey}`}> 55 + {truncateText(collection.name, 20)} 56 + </BackButton> 57 + </Header> 58 + {props.children} 59 + </Fragment> 60 + ); 61 + }
-8
src/webapp/app/(dashboard)/profile/[handle]/(withoutHeader)/collections/[rkey]/followers/page.tsx
··· 1 1 import CollectionFollowersContainer from '@/features/follows/containers/collectionFollowersContainer/CollectionFollowersContainer'; 2 2 import { getCollectionPageByAtUri } from '@/features/collections/lib/dal'; 3 - import BackButton from '@/components/navigation/backButton/BackButton'; 4 - import Header from '@/components/navigation/header/Header'; 5 3 import { Fragment } from 'react'; 6 - import { truncateText } from '@/lib/utils/text'; 7 4 8 5 interface Props { 9 6 params: Promise<{ rkey: string; handle: string }>; ··· 20 17 21 18 return ( 22 19 <Fragment> 23 - <Header> 24 - <BackButton href={`/profile/${handle}/collections/${rkey}`}> 25 - {truncateText(collection.name, 20)} 26 - </BackButton> 27 - </Header> 28 20 <CollectionFollowersContainer 29 21 collectionId={collection.id} 30 22 collectionName={collection.name}
+16 -1
src/webapp/app/(dashboard)/profile/[handle]/(withoutHeader)/collections/[rkey]/layout.tsx src/webapp/app/(dashboard)/profile/[handle]/(withoutHeader)/collections/[rkey]/(content)/layout.tsx
··· 1 + import BackButton from '@/components/navigation/backButton/BackButton'; 2 + import Header from '@/components/navigation/header/Header'; 1 3 import { getCollectionPageByAtUri } from '@/features/collections/lib/dal'; 4 + import { truncateText } from '@/lib/utils/text'; 2 5 import type { Metadata } from 'next'; 6 + import { Fragment } from 'react'; 3 7 4 8 interface Props { 5 9 params: Promise<{ rkey: string; handle: string }>; ··· 37 41 } 38 42 39 43 export default async function Layout(props: Props) { 40 - return <>{props.children}</>; 44 + const { handle } = await props.params; 45 + 46 + return ( 47 + <Fragment> 48 + <Header> 49 + <BackButton href={`/profile/${handle}/collections`}> 50 + {`@${truncateText(handle, 20)}`} 51 + </BackButton> 52 + </Header> 53 + {props.children} 54 + </Fragment> 55 + ); 41 56 }
src/webapp/app/(dashboard)/profile/[handle]/(withoutHeader)/collections/[rkey]/loading.tsx src/webapp/app/(dashboard)/profile/[handle]/(withoutHeader)/collections/[rkey]/(content)/loading.tsx
-24
src/webapp/app/(dashboard)/profile/[handle]/(withoutHeader)/collections/[rkey]/page.tsx
··· 1 - import CollectionContainer from '@/features/collections/containers/collectionContainer/CollectionContainer'; 2 - import BackButton from '@/components/navigation/backButton/BackButton'; 3 - import Header from '@/components/navigation/header/Header'; 4 - import { Fragment } from 'react'; 5 - import { truncateText } from '@/lib/utils/text'; 6 - 7 - interface Props { 8 - params: Promise<{ rkey: string; handle: string }>; 9 - } 10 - 11 - export default async function Page(props: Props) { 12 - const { rkey, handle } = await props.params; 13 - 14 - return ( 15 - <Fragment> 16 - <Header> 17 - <BackButton href={`/profile/${handle}/collections`}> 18 - {`@${truncateText(handle, 20)}`} 19 - </BackButton> 20 - </Header> 21 - <CollectionContainer handle={handle} rkey={rkey} /> 22 - </Fragment> 23 - ); 24 - }
+12 -7
src/webapp/app/(dashboard)/url/page.tsx
··· 2 2 import { getDomain } from '@/lib/utils/link'; 3 3 import { getUrlMetadata } from '@/features/cards/lib/dal'; 4 4 import { redirect } from 'next/navigation'; 5 + import SemblePageClient from '@/features/semble/containers/sembleContainer/SemblePageClient'; 5 6 import SembleContainer from '@/features/semble/containers/sembleContainer/SembleContainer'; 6 - import { Fragment, Suspense } from 'react'; 7 + import { Suspense } from 'react'; 7 8 import SembleContainerSkeleton from '@/features/semble/containers/sembleContainer/Skeleton.SembleContainer'; 8 9 9 10 interface Props { 10 - searchParams: Promise<{ id: string | undefined }>; 11 + searchParams: Promise<{ id: string | undefined; viaCardId?: string }>; 11 12 } 12 13 13 14 export async function generateMetadata({ 14 15 searchParams, 15 16 }: { 16 - searchParams: Promise<{ id: string | undefined }>; 17 + searchParams: Promise<{ id: string | undefined; viaCardId?: string }>; 17 18 }): Promise<Metadata> { 18 19 const { id: url } = await searchParams; 19 20 ··· 42 43 } 43 44 44 45 export default async function Page(props: Props) { 45 - const { id: url } = await props.searchParams; 46 + const searchParams = await props.searchParams; 47 + const url = searchParams.id; 48 + const viaCardId = searchParams.viaCardId; 46 49 47 50 if (!url) { 48 51 redirect('/'); 49 52 } 50 53 51 54 return ( 52 - <Suspense fallback={<SembleContainerSkeleton />} key={url + 'container'}> 53 - <SembleContainer url={url} /> 54 - </Suspense> 55 + <SemblePageClient viaCardId={viaCardId}> 56 + <Suspense fallback={<SembleContainerSkeleton />} key={url + 'container'}> 57 + <SembleContainer url={url} viaCardId={viaCardId} /> 58 + </Suspense> 59 + </SemblePageClient> 55 60 ); 56 61 }
+2
src/webapp/app/manifest.ts
··· 35 35 method: 'GET', 36 36 params: { 37 37 url: 'addUrl', 38 + text: 'addText', 39 + title: 'addTitle', 38 40 }, 39 41 }, 40 42 };
+36 -12
src/webapp/features/cards/components/addCardActions/AddCardActions.tsx
··· 3 3 import { Dispatch, SetStateAction, useState } from 'react'; 4 4 import useRemoveCardFromLibrary from '../../lib/mutations/useRemoveCardFromLibrary'; 5 5 import { notifications } from '@mantine/notifications'; 6 - import { Button, Card, Group, Stack, Text, Textarea } from '@mantine/core'; 6 + import { 7 + Button, 8 + Card, 9 + Flex, 10 + Group, 11 + Input, 12 + Stack, 13 + Text, 14 + Textarea, 15 + VisuallyHidden, 16 + } from '@mantine/core'; 7 17 8 18 interface Props { 9 19 note?: string; ··· 16 26 const [showDeleteWarning, setShowDeleteWarning] = useState(false); 17 27 const [noteMode, setNoteMode] = useState(false); 18 28 const [note, setNote] = useState(props.note); 29 + const MAX_NOTE_LENGTH = 500; 19 30 20 31 const removeNote = useRemoveCardFromLibrary(); 21 32 ··· 45 56 style={{ cursor: 'pointer' }} 46 57 > 47 58 <Stack gap={'xs'}> 48 - <Textarea 49 - id="note" 50 - label="Your note" 51 - placeholder="Add a note about this card" 52 - variant="filled" 53 - size="md" 54 - rows={3} 55 - maxLength={500} 56 - value={note} 57 - onChange={(e) => setNote(e.currentTarget.value)} 58 - /> 59 + <Stack gap={0}> 60 + <Flex justify="space-between"> 61 + <Input.Label size="md" htmlFor="note"> 62 + Your note 63 + </Input.Label> 64 + <Text c={'gray'} aria-hidden> 65 + {note?.length ?? 0} / {MAX_NOTE_LENGTH} 66 + </Text> 67 + </Flex> 68 + 69 + <Textarea 70 + id="note" 71 + placeholder="Add a note about this card" 72 + variant="filled" 73 + size="md" 74 + rows={3} 75 + maxLength={500} 76 + value={note} 77 + onChange={(e) => setNote(e.currentTarget.value)} 78 + /> 79 + <VisuallyHidden id="note-char-remaining" aria-live="polite"> 80 + {`${MAX_NOTE_LENGTH - (note?.length ?? 0)} characters remaining`} 81 + </VisuallyHidden> 82 + </Stack> 59 83 <Group gap={'xs'} grow> 60 84 <Button 61 85 variant="light"
+31 -11
src/webapp/features/cards/components/addCardDrawer/AddCardDrawer.tsx
··· 2 2 Button, 3 3 Container, 4 4 Drawer, 5 + Flex, 5 6 Group, 7 + Input, 6 8 ScrollArea, 7 9 Stack, 8 10 Text, 9 11 Textarea, 10 12 TextInput, 11 13 ThemeIcon, 14 + VisuallyHidden, 12 15 } from '@mantine/core'; 13 16 import { useForm } from '@mantine/form'; 14 17 import { notifications } from '@mantine/notifications'; ··· 57 60 collections: selectedCollections, 58 61 }, 59 62 }); 63 + 64 + const MAX_NOTE_LENGTH = 500; 65 + const { note } = form.getValues(); 60 66 61 67 useEffect(() => { 62 68 if (props.initialUrl) { ··· 127 133 {...form.getInputProps('url')} 128 134 /> 129 135 130 - <Textarea 131 - id="note" 132 - label="Note" 133 - placeholder="Add a note about this card" 134 - variant="filled" 135 - size="md" 136 - rows={3} 137 - maxLength={500} 138 - key={form.key('note')} 139 - {...form.getInputProps('note')} 140 - /> 136 + <Stack gap={0}> 137 + <Flex justify="space-between"> 138 + <Input.Label size="md" htmlFor="note"> 139 + Note 140 + </Input.Label> 141 + <Text c={'gray'} aria-hidden> 142 + {form.getValues().note.length} / {MAX_NOTE_LENGTH} 143 + </Text> 144 + </Flex> 145 + 146 + <Textarea 147 + id="note" 148 + placeholder="Add a note about this card" 149 + variant="filled" 150 + size="md" 151 + rows={3} 152 + maxLength={MAX_NOTE_LENGTH} 153 + aria-describedby="note-char-remaining" 154 + key={form.key('note')} 155 + {...form.getInputProps('note')} 156 + /> 157 + <VisuallyHidden id="note-char-remaining" aria-live="polite"> 158 + {`${MAX_NOTE_LENGTH - form.getValues().note.length} characters remaining`} 159 + </VisuallyHidden> 160 + </Stack> 141 161 </Stack> 142 162 143 163 <Stack gap={5}>
+12 -4
src/webapp/features/cards/components/urlCard/UrlCard.tsx
··· 34 34 const handleNavigateToSemblePage = (e: MouseEvent<HTMLElement>) => { 35 35 e.stopPropagation(); 36 36 37 - const targetUrl = 38 - isCollectionPage(props.url) || isProfilePage(props.url) 39 - ? props.url 40 - : `/url?id=${props.cardContent.url}`; 37 + let targetUrl: string; 38 + 39 + if (isCollectionPage(props.url) || isProfilePage(props.url)) { 40 + targetUrl = props.url; 41 + } else { 42 + // Build URL with viaCardId first, then id last (since id contains a URL that might have query params) 43 + if (props.viaCardId) { 44 + targetUrl = `/url?viaCardId=${props.id}&id=${props.cardContent.url}`; 45 + } else { 46 + targetUrl = `/url?id=${props.cardContent.url}`; 47 + } 48 + } 41 49 42 50 // Open in new tab if Cmd+Click (Mac), Ctrl+Click (Windows/Linux), or middle click 43 51 if (e.metaKey || e.ctrlKey || e.button === 1) {
+2 -1
src/webapp/features/collections/components/collectionActions/CollectionActions.tsx
··· 46 46 </Button> 47 47 )} 48 48 49 - {featureFlags?.following && isAuthenticated && !isAuthor && ( 49 + {isAuthenticated && !isAuthor && ( 50 50 <FollowButton 51 51 targetId={props.collection.id} 52 52 targetType="COLLECTION" 53 + targetHandle={props.collection.author?.handle} 53 54 size="sm" 54 55 initialIsFollowing={props.collection.isFollowing} 55 56 />
+6 -1
src/webapp/features/composer/components/composerDrawer/ComposerDrawer.tsx
··· 12 12 const isNavOpen = isDesktop ? desktopOpened : mobileOpened; 13 13 const shouldShowFab = !isNavOpen; 14 14 const [opened, setOpened] = useState(false); 15 - const addUrl = useSearchParams().get('addUrl'); 15 + 16 + // share_target support. on android could be any of these. 17 + const shareUrl = useSearchParams().get('addUrl'); 18 + const shareText = useSearchParams().get('addText'); 19 + const shareTitle = useSearchParams().get('addTitle'); 20 + const addUrl = shareUrl || shareText || shareTitle; 16 21 17 22 useEffect(() => { 18 23 if (addUrl) {
+6 -2
src/webapp/features/feeds/containers/myFeedContainer/Error.MyFeedContainer.tsx
··· 1 - import { Alert } from '@mantine/core'; 1 + import { Alert, Container } from '@mantine/core'; 2 2 3 3 export default function MyFeedContainerError() { 4 - return <Alert color="red" title="Could not load feed" />; 4 + return ( 5 + <Container p="xs" size="xl"> 6 + <Alert color="red" title="Could not load feed" /> 7 + </Container> 8 + ); 5 9 }
+14
src/webapp/features/follows/components/followButton/FollowButton.tsx
··· 4 4 import { useState, useEffect } from 'react'; 5 5 import useFollowTarget from '../../lib/mutations/useFollowTarget'; 6 6 import useUnfollowTarget from '../../lib/mutations/useUnfollowTarget'; 7 + import { useFeatureFlags } from '@/lib/clientFeatureFlags'; 8 + import { isApprovedHandle } from '@/lib/approvedHandles'; 7 9 8 10 interface Props { 9 11 targetId: string; 10 12 targetType: 'USER' | 'COLLECTION'; 13 + targetHandle?: string; // Handle of the user or collection author 11 14 initialIsFollowing?: boolean; 12 15 variant?: 'filled' | 'light' | 'outline' | 'subtle'; 13 16 size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; ··· 17 20 export default function FollowButton({ 18 21 targetId, 19 22 targetType, 23 + targetHandle, 20 24 initialIsFollowing = false, 21 25 variant = 'filled', 22 26 size = 'sm', 23 27 followText = 'Follow', 24 28 }: Props) { 29 + const { data: featureFlags } = useFeatureFlags(); 25 30 const [isFollowing, setIsFollowing] = useState(initialIsFollowing); 26 31 const followMutation = useFollowTarget(); 27 32 const unfollowMutation = useUnfollowTarget(); ··· 30 35 useEffect(() => { 31 36 setIsFollowing(initialIsFollowing); 32 37 }, [initialIsFollowing]); 38 + 39 + // Check if following feature is enabled for current user and target user is approved 40 + const isFollowingEnabled = 41 + featureFlags?.following && isApprovedHandle(targetHandle); 42 + 43 + // Return early only AFTER all hooks have been called 44 + if (!isFollowingEnabled) { 45 + return null; 46 + } 33 47 34 48 const isLoading = followMutation.isPending || unfollowMutation.isPending; 35 49
+31 -13
src/webapp/features/notes/components/noteCardModal/NoteCardModalContent.tsx
··· 11 11 Image, 12 12 Textarea, 13 13 Button, 14 + Flex, 15 + Input, 16 + VisuallyHidden, 14 17 } from '@mantine/core'; 15 18 import { UrlCard, User } from '@semble/types'; 16 19 import Link from 'next/link'; ··· 33 36 const [note, setNote] = useState(isMyCard ? props.note?.text : ''); 34 37 const [editMode, setEditMode] = useState(false); 35 38 const [showDeleteWarning, setShowDeleteWarning] = useState(false); 39 + const MAX_NOTE_LENGTH = 500; 36 40 37 41 const removeNote = useRemoveCardFromLibrary(); 38 42 const updateNote = useUpdateNote(); ··· 86 90 if (editMode) { 87 91 return ( 88 92 <Stack gap={'xs'}> 89 - <Textarea 90 - id="note" 91 - label="Your note" 92 - placeholder="Add a note about this card" 93 - variant="filled" 94 - size="md" 95 - autosize 96 - minRows={3} 97 - maxRows={8} 98 - maxLength={500} 99 - value={note} 100 - onChange={(e) => setNote(e.currentTarget.value)} 101 - /> 93 + <Stack gap={0}> 94 + <Flex justify="space-between"> 95 + <Input.Label size="md" htmlFor="note"> 96 + Your note 97 + </Input.Label> 98 + <Text c={'gray'} aria-hidden> 99 + {note?.length ?? 0} / {MAX_NOTE_LENGTH} 100 + </Text> 101 + </Flex> 102 + 103 + <Textarea 104 + id="note" 105 + placeholder="Add a note about this card" 106 + variant="filled" 107 + size="md" 108 + autosize 109 + minRows={3} 110 + maxRows={8} 111 + maxLength={MAX_NOTE_LENGTH} 112 + value={note} 113 + onChange={(e) => setNote(e.currentTarget.value)} 114 + /> 115 + <VisuallyHidden id="note-char-remaining" aria-live="polite"> 116 + {`${MAX_NOTE_LENGTH - (note?.length ?? 0)} characters remaining`} 117 + </VisuallyHidden> 118 + </Stack> 119 + 102 120 <Group gap={'xs'} grow> 103 121 <Button 104 122 variant="light"
+1
src/webapp/features/notifications/components/notificationItem/NotificationItem.tsx
··· 47 47 <FollowButton 48 48 targetId={props.item.user.id} 49 49 targetType="USER" 50 + targetHandle={props.item.user.handle} 50 51 variant="light" 51 52 size="sm" 52 53 initialIsFollowing={props.item.user.isFollowing}
+2 -1
src/webapp/features/platforms/bluesky/container/blueskySembleHeader/BlueskySembleHeader.tsx
··· 9 9 10 10 interface Props { 11 11 url: string; 12 + viaCardId?: string; 12 13 } 13 14 14 15 export default async function BlueskySembleHeader(props: Props) { ··· 20 21 21 22 <Stack align="center"> 22 23 <Suspense fallback={<SembleActionsContainerSkeleton />}> 23 - <SembleActionsContainer url={props.url} /> 24 + <SembleActionsContainer url={props.url} viaCardId={props.viaCardId} /> 24 25 </Suspense> 25 26 </Stack> 26 27
+1
src/webapp/features/profile/components/profileStats/ProfileStats.tsx
··· 61 61 <FollowButton 62 62 targetId={identifier} 63 63 targetType="USER" 64 + targetHandle={handle} 64 65 initialIsFollowing={isFollowing} 65 66 /> 66 67 )}
+5 -1
src/webapp/features/semble/components/SembleHeader/SembleHeader.tsx
··· 8 8 9 9 interface Props { 10 10 url: string; 11 + viaCardId?: string; 11 12 } 12 13 13 14 export default async function SembleHeader(props: Props) { ··· 16 17 <UrlMetadataHeader url={props.url}> 17 18 <Stack align="center"> 18 19 <Suspense fallback={<SembleActionsContainerSkeleton />}> 19 - <SembleActionsContainer url={props.url} /> 20 + <SembleActionsContainer 21 + url={props.url} 22 + viaCardId={props.viaCardId} 23 + /> 20 24 </Suspense> 21 25 </Stack> 22 26 </UrlMetadataHeader>
+2 -1
src/webapp/features/semble/containers/sembleActionsContainer/SembleActionsContainer.tsx
··· 4 4 5 5 interface Props { 6 6 url: string; 7 + viaCardId?: string; 7 8 } 8 9 9 10 export default async function SembleActionsContainer(props: Props) { ··· 13 14 return <GuestSembleActions url={props.url} />; 14 15 } 15 16 16 - return <SembleActions url={props.url} />; 17 + return <SembleActions url={props.url} viaCardId={props.viaCardId} />; 17 18 }
+6 -2
src/webapp/features/semble/containers/sembleContainer/SembleContainer.tsx
··· 10 10 11 11 interface Props { 12 12 url: string; 13 + viaCardId?: string; 13 14 } 14 15 15 16 export default function SembleContainer(props: Props) { ··· 27 28 fallback={<BlueskySembleHeaderSkeleton />} 28 29 key={props.url + 'bluesky header'} 29 30 > 30 - <BlueskySembleHeader url={props.url} /> 31 + <BlueskySembleHeader 32 + url={props.url} 33 + viaCardId={props.viaCardId} 34 + /> 31 35 </Suspense> 32 36 </Box> 33 37 ) : ( ··· 35 39 fallback={<SembleHeaderSkeleton />} 36 40 key={props.url + 'semble header'} 37 41 > 38 - <SembleHeader url={props.url} /> 42 + <SembleHeader url={props.url} viaCardId={props.viaCardId} /> 39 43 </Suspense> 40 44 )} 41 45 <SembleTabs url={props.url} />
+34
src/webapp/features/semble/containers/sembleContainer/SemblePageClient.tsx
··· 1 + 'use client'; 2 + 3 + import { useState, useEffect } from 'react'; 4 + 5 + interface Props { 6 + viaCardId?: string; 7 + children: React.ReactNode; 8 + } 9 + 10 + /** 11 + * Client wrapper that cleans viaCardId from URL after server captures it. 12 + * Uses children prop pattern to maintain server component status for nested components. 13 + */ 14 + export default function SemblePageClient(props: Props) { 15 + const [cleanedUrl, setCleanedUrl] = useState(false); 16 + 17 + // Clean URL on mount if viaCardId exists 18 + useEffect(() => { 19 + if (props.viaCardId && !cleanedUrl) { 20 + // Manually build query string to avoid re-encoding the URL 21 + const params = new URLSearchParams(window.location.search); 22 + const queryParts: string[] = []; 23 + params.forEach((value, key) => { 24 + if (key !== 'viaCardId') { 25 + queryParts.push(`${key}=${value}`); 26 + } 27 + }); 28 + window.history.replaceState(null, '', `?${queryParts.join('&')}`); 29 + setCleanedUrl(true); 30 + } 31 + }, [props.viaCardId, cleanedUrl]); 32 + 33 + return <>{props.children}</>; 34 + }
+32
src/webapp/features/semble/containers/sembleContainer/UrlParamCleaner.tsx
··· 1 + 'use client'; 2 + 3 + import { useEffect } from 'react'; 4 + import { useSearchParams, useRouter } from 'next/navigation'; 5 + 6 + interface Props { 7 + paramToRemove: string; 8 + } 9 + 10 + /** 11 + * Client component that removes a query parameter from the URL without scrolling 12 + */ 13 + export default function UrlParamCleaner(props: Props) { 14 + const searchParams = useSearchParams(); 15 + const router = useRouter(); 16 + 17 + useEffect(() => { 18 + const paramValue = searchParams.get(props.paramToRemove); 19 + if (paramValue) { 20 + // Manually build query string to avoid re-encoding the URL 21 + const params: string[] = []; 22 + searchParams.forEach((value, key) => { 23 + if (key !== props.paramToRemove) { 24 + params.push(`${key}=${value}`); 25 + } 26 + }); 27 + router.replace(`?${params.join('&')}`, { scroll: false }); 28 + } 29 + }, [searchParams, router, props.paramToRemove]); 30 + 31 + return null; 32 + }
+21
src/webapp/lib/approvedHandles.ts
··· 1 + const APPROVED_HANDLES = new Set([ 2 + 'wesleyfinck.org', 3 + 'ronentk.me', 4 + 'pouriade.com', 5 + 'bmann.ca', 6 + 'tynanpurdy.com', 7 + 'erlend.sh', 8 + 'tgoerke.bsky.social', 9 + 'psingletary.com', 10 + 'hilarybaumann.com', 11 + 'cosmik.network', 12 + 'semble.so', 13 + 'atproto.science', 14 + 'chrisshank.com', 15 + 'jasmine-pyz.bsky.social', 16 + ]); 17 + 18 + export function isApprovedHandle(handle: string | undefined | null): boolean { 19 + if (!handle) return false; 20 + return APPROVED_HANDLES.has(handle); 21 + }
+1 -3
src/webapp/lib/serverFeatureFlags.ts
··· 8 8 process.env.VERCEL_ENV !== 'production' || 9 9 (user?.handle && INTERNAL_HANDLES.includes(user.handle)); 10 10 11 - const showFollowing = process.env.VERCEL_ENV !== 'production'; 12 - 13 11 return { 14 12 cardSearch: show, 15 13 urlTypeFilter: show, 16 14 leafletMentions: show, 17 15 animatedLandingTitle: show, 18 16 openCollections: true, 19 - following: showFollowing, 17 + following: show, 20 18 }; 21 19 }