OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge commits / d8fa216

0.10.5: threads, interaction icons, and the six acts that could not cross
⛔ MEASURED, NOT ASSUMED. All six "unaddressable" interactions are likes of BLUESKY posts — did:plc:4732wedq…, e2jevhuu…, y5caotnr…. None is on our host, none is fronted by us, and Bluesky content has no ActivityPub identity by protocol. So projectLike is RIGHT to refuse, and minting a URI on a host we do not control is what hyper-social #71 §9 declines. The projection is unchanged — nothing new goes on the wire. But the act is ours and the page is ours, so it is shown and linked to bsky.app, where the post actually lives. Naming a LOCATION is not minting an IDENTITY. `listInteractions` returns them instead of only counting them: a count told an operator six acts existed and nothing about what they were, which is why it stayed an abstraction. Threading: a post carries its replies, likes and boosts instead of each arriving as its own row. ⛔ Nothing is dropped by grouping — an interaction on a post outside this page's window stays top-level, because "it was there, just not shown" is the shape of every defect here so far. Counts ARE the lists' own lengths; a separately maintained count is a disagreement waiting. Icons open. `<details>` with no JavaScript names WHO liked or boosted, because a count nobody can check is not evidence, and this page exists to be checked. 1206/1206 pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
d8fa21669ef8f78370b22e683e9a0a833fe5b799
by plankatron <fleeky@gmail.com> · 2026-09-15T16:11:48.000Z

Changed files (9) +358 -103

.pgh-lock.json +93 -93
……@@ -1,9 +1,9 @@
11{
22 "name": "ap-bridge",
3 "version": "0.10.3",
3 "version": "0.10.4",
44 "runtime": "bare",
5 "rootTreeHash": "28f51ee7726afd01990551b3289e297cfb7e7c1fa420c3e4cf2250becad116ac",
6 "publishedAt": "2026-09-15T16:01:47.807Z",
5 "rootTreeHash": "7930780a86080f807aba0b3ff22692f5a81538930bc5a45d89c3aa970586ae73",
6 "publishedAt": "2026-09-15T16:04:24.007Z",
77 "deps": [
88 {
99 "name": "@hyperswarm/secret-stream",
……@@ -4004,8 +4004,8 @@
40044004 "tree": "38f44af48b2d59b46f1b5db31b81d02f3ca8b8bad6bc0fb9fffff698241f5e37"
40054005 }
40064006 ],
4007 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4008 "acceptedAt": "2026-09-15T16:01:47.824Z"
4007 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4008 "acceptedAt": "2026-09-15T16:04:24.026Z"
40094009 },
40104010 {
40114011 "type": "VERSION_CONFLICT",
……@@ -4019,8 +4019,8 @@
40194019 "tree": "ee91b6bd77101e3404ca571b2e5adf0f47029d174398a05f50294fc207e39d99"
40204020 }
40214021 ],
4022 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4023 "acceptedAt": "2026-09-15T16:01:47.824Z"
4022 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4023 "acceptedAt": "2026-09-15T16:04:24.026Z"
40244024 },
40254025 {
40264026 "type": "VERSION_CONFLICT",
……@@ -4034,8 +4034,8 @@
40344034 "tree": "3bb78d8f06263a549bd1077f37a7df257b1d557e44608631435af625f05a9c11"
40354035 }
40364036 ],
4037 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4038 "acceptedAt": "2026-09-15T16:01:47.824Z"
4037 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4038 "acceptedAt": "2026-09-15T16:04:24.026Z"
40394039 },
40404040 {
40414041 "type": "VERSION_CONFLICT",
……@@ -4054,8 +4054,8 @@
40544054 "tree": "58c0bf8698af19464d0ee8354a77ed1ac96d3a5fa18aa4e794686cfbbf080e5d"
40554055 }
40564056 ],
4057 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4058 "acceptedAt": "2026-09-15T16:01:47.824Z"
4057 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4058 "acceptedAt": "2026-09-15T16:04:24.026Z"
40594059 },
40604060 {
40614061 "type": "VERSION_CONFLICT",
……@@ -4069,8 +4069,8 @@
40694069 "tree": "da4c6b2654e9d66f10b0faa989311cf182878c24ab51b1c41458cfbf37ad29cd"
40704070 }
40714071 ],
4072 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4073 "acceptedAt": "2026-09-15T16:01:47.824Z"
4072 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4073 "acceptedAt": "2026-09-15T16:04:24.026Z"
40744074 },
40754075 {
40764076 "type": "VERSION_CONFLICT",
……@@ -4084,8 +4084,8 @@
40844084 "tree": "40be8f559a13b54ebf0689e63e6c70f0d218035359e17c497ed7bc8a85c3cc95"
40854085 }
40864086 ],
4087 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4088 "acceptedAt": "2026-09-15T16:01:47.824Z"
4087 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4088 "acceptedAt": "2026-09-15T16:04:24.026Z"
40894089 },
40904090 {
40914091 "type": "VERSION_CONFLICT",
……@@ -4099,8 +4099,8 @@
40994099 "tree": "3eae85efce82299769898c8151b5ed0a94e5a5ebdbcdee2105b815ca733a61cb"
41004100 }
41014101 ],
4102 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4103 "acceptedAt": "2026-09-15T16:01:47.824Z"
4102 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4103 "acceptedAt": "2026-09-15T16:04:24.026Z"
41044104 },
41054105 {
41064106 "type": "VERSION_CONFLICT",
……@@ -4114,8 +4114,8 @@
41144114 "tree": "8d9e01d443a127781773598d2e77c3f372334fad09e20623a819dabe49b22e1a"
41154115 }
41164116 ],
4117 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4118 "acceptedAt": "2026-09-15T16:01:47.824Z"
4117 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4118 "acceptedAt": "2026-09-15T16:04:24.026Z"
41194119 },
41204120 {
41214121 "type": "VERSION_CONFLICT",
……@@ -4129,8 +4129,8 @@
41294129 "tree": "dc1ad6c999fe90d946ba0ef34698ea9af451b639be2ad1c5b61eabb08337a6ca"
41304130 }
41314131 ],
4132 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4133 "acceptedAt": "2026-09-15T16:01:47.824Z"
4132 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4133 "acceptedAt": "2026-09-15T16:04:24.026Z"
41344134 },
41354135 {
41364136 "type": "VERSION_CONFLICT",
……@@ -4144,8 +4144,8 @@
41444144 "tree": "88b4779570e5113aa21b204989ac018d1628b387681f7e53428a7c99114fb701"
41454145 }
41464146 ],
4147 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4148 "acceptedAt": "2026-09-15T16:01:47.824Z"
4147 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4148 "acceptedAt": "2026-09-15T16:04:24.026Z"
41494149 },
41504150 {
41514151 "type": "VERSION_CONFLICT",
……@@ -4159,8 +4159,8 @@
41594159 "tree": "720b867ec4300a3caa2325eb1449499f9940bfb88341ecceb5992fa0fdf5f37d"
41604160 }
41614161 ],
4162 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4163 "acceptedAt": "2026-09-15T16:01:47.824Z"
4162 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4163 "acceptedAt": "2026-09-15T16:04:24.026Z"
41644164 },
41654165 {
41664166 "type": "VERSION_CONFLICT",
……@@ -4174,8 +4174,8 @@
41744174 "tree": "b697fe174482a708b49d0764cd4786291845f0034a31e0699381ca7e7b269d04"
41754175 }
41764176 ],
4177 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4178 "acceptedAt": "2026-09-15T16:01:47.824Z"
4177 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4178 "acceptedAt": "2026-09-15T16:04:24.026Z"
41794179 },
41804180 {
41814181 "type": "VERSION_CONFLICT",
……@@ -4189,8 +4189,8 @@
41894189 "tree": "767f07242277ae80bd395de5a4969fcdbf956c216158ae517fb2b562558ac1a4"
41904190 }
41914191 ],
4192 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4193 "acceptedAt": "2026-09-15T16:01:47.824Z"
4192 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4193 "acceptedAt": "2026-09-15T16:04:24.026Z"
41944194 },
41954195 {
41964196 "type": "VERSION_CONFLICT",
……@@ -4204,8 +4204,8 @@
42044204 "tree": "de7deed21f587ffc8bde4971abe00fca77bb6c71c4e7effb31ad34ee330815df"
42054205 }
42064206 ],
4207 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4208 "acceptedAt": "2026-09-15T16:01:47.824Z"
4207 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4208 "acceptedAt": "2026-09-15T16:04:24.026Z"
42094209 },
42104210 {
42114211 "type": "VERSION_CONFLICT",
……@@ -4219,8 +4219,8 @@
42194219 "tree": "2df42517ebf73cd9c11f2f652f75e87401fd4ed7b856c0adc15c33baf1d4f1ad"
42204220 }
42214221 ],
4222 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4223 "acceptedAt": "2026-09-15T16:01:47.824Z"
4222 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4223 "acceptedAt": "2026-09-15T16:04:24.026Z"
42244224 },
42254225 {
42264226 "type": "VERSION_CONFLICT",
……@@ -4234,8 +4234,8 @@
42344234 "tree": "325aca171cbff560487177584078c8066133e91176639ca47c98d70e195aa92e"
42354235 }
42364236 ],
4237 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4238 "acceptedAt": "2026-09-15T16:01:47.824Z"
4237 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4238 "acceptedAt": "2026-09-15T16:04:24.026Z"
42394239 },
42404240 {
42414241 "type": "VERSION_CONFLICT",
……@@ -4264,8 +4264,8 @@
42644264 "tree": "89a40400106bf4fef95fb5c44a66ee29aada6f76c7740bfba09d8aeeb841f39c"
42654265 }
42664266 ],
4267 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4268 "acceptedAt": "2026-09-15T16:01:47.824Z"
4267 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4268 "acceptedAt": "2026-09-15T16:04:24.026Z"
42694269 },
42704270 {
42714271 "type": "VERSION_CONFLICT",
……@@ -4279,8 +4279,8 @@
42794279 "tree": "1bd0eb382789c62cd4622d47bffb60984fdd766260c79f73a2fc7a136f3edbe9"
42804280 }
42814281 ],
4282 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4283 "acceptedAt": "2026-09-15T16:01:47.824Z"
4282 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4283 "acceptedAt": "2026-09-15T16:04:24.026Z"
42844284 },
42854285 {
42864286 "type": "VERSION_CONFLICT",
……@@ -4294,8 +4294,8 @@
42944294 "tree": "4526fe9dc5468264c7171ec16e9303726674b7b44ca0b3e09cb8b9a6a1be6169"
42954295 }
42964296 ],
4297 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4298 "acceptedAt": "2026-09-15T16:01:47.824Z"
4297 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4298 "acceptedAt": "2026-09-15T16:04:24.026Z"
42994299 },
43004300 {
43014301 "type": "VERSION_CONFLICT",
……@@ -4309,8 +4309,8 @@
43094309 "tree": "32b291cd756e142da18a6ed01c351e024fcedc3e2b1ecfbfb85f3106ada4419b"
43104310 }
43114311 ],
4312 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4313 "acceptedAt": "2026-09-15T16:01:47.824Z"
4312 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4313 "acceptedAt": "2026-09-15T16:04:24.026Z"
43144314 },
43154315 {
43164316 "type": "VERSION_CONFLICT",
……@@ -4324,8 +4324,8 @@
43244324 "tree": "4a8cd3b0093092b33088617dc0415e8179882d7f2f13b7649962ca20b08beeed"
43254325 }
43264326 ],
4327 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4328 "acceptedAt": "2026-09-15T16:01:47.824Z"
4327 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4328 "acceptedAt": "2026-09-15T16:04:24.026Z"
43294329 },
43304330 {
43314331 "type": "VERSION_CONFLICT",
……@@ -4339,8 +4339,8 @@
43394339 "tree": "4f63f165ee6a581f689b40c4a40df4df01ab6fba17d46bd29e40ef5bbdd9884f"
43404340 }
43414341 ],
4342 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4343 "acceptedAt": "2026-09-15T16:01:47.824Z"
4342 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4343 "acceptedAt": "2026-09-15T16:04:24.026Z"
43444344 },
43454345 {
43464346 "type": "VERSION_CONFLICT",
……@@ -4354,8 +4354,8 @@
43544354 "tree": "735109c736c0bd3413ab47414fd8b6ea9a99c401fe8e6d608459c79293806754"
43554355 }
43564356 ],
4357 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4358 "acceptedAt": "2026-09-15T16:01:47.824Z"
4357 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4358 "acceptedAt": "2026-09-15T16:04:24.026Z"
43594359 },
43604360 {
43614361 "type": "VERSION_CONFLICT",
……@@ -4369,8 +4369,8 @@
43694369 "tree": "b32b75bf1b2acdd18ed0cc370d01d477fba42ba09fd1f2f78af3ebad925e72d5"
43704370 }
43714371 ],
4372 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4373 "acceptedAt": "2026-09-15T16:01:47.824Z"
4372 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4373 "acceptedAt": "2026-09-15T16:04:24.026Z"
43744374 },
43754375 {
43764376 "type": "VERSION_CONFLICT",
……@@ -4389,8 +4389,8 @@
43894389 "tree": "189397178121e9a18430943345dab820691648a324229071ceb980bbb77a7393"
43904390 }
43914391 ],
4392 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4393 "acceptedAt": "2026-09-15T16:01:47.824Z"
4392 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4393 "acceptedAt": "2026-09-15T16:04:24.026Z"
43944394 },
43954395 {
43964396 "type": "VERSION_CONFLICT",
……@@ -4404,8 +4404,8 @@
44044404 "tree": "3bedca5287a9041fc2fd5ec5ace4df1fa86240e6afec0a105ba67398bafc09a5"
44054405 }
44064406 ],
4407 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4408 "acceptedAt": "2026-09-15T16:01:47.824Z"
4407 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4408 "acceptedAt": "2026-09-15T16:04:24.026Z"
44094409 },
44104410 {
44114411 "type": "VERSION_CONFLICT",
……@@ -4419,8 +4419,8 @@
44194419 "tree": "b9ca9b272008bd67672a1b252390e8b527095c46b46cf6b6725a7a187eb038b6"
44204420 }
44214421 ],
4422 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4423 "acceptedAt": "2026-09-15T16:01:47.824Z"
4422 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4423 "acceptedAt": "2026-09-15T16:04:24.026Z"
44244424 },
44254425 {
44264426 "type": "VERSION_CONFLICT",
……@@ -4434,8 +4434,8 @@
44344434 "tree": "7793645d7d4248d6d6533e3cbcd4bf40bed4f38c678b0ca0606c15640ba5e7b9"
44354435 }
44364436 ],
4437 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4438 "acceptedAt": "2026-09-15T16:01:47.824Z"
4437 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4438 "acceptedAt": "2026-09-15T16:04:24.026Z"
44394439 },
44404440 {
44414441 "type": "VERSION_CONFLICT",
……@@ -4449,8 +4449,8 @@
44494449 "tree": "79447513d4ac0357c8d60bdc7b81b3c5143d761b26700340cba266c4d1cb77b0"
44504450 }
44514451 ],
4452 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4453 "acceptedAt": "2026-09-15T16:01:47.824Z"
4452 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4453 "acceptedAt": "2026-09-15T16:04:24.026Z"
44544454 },
44554455 {
44564456 "type": "VERSION_CONFLICT",
……@@ -4464,8 +4464,8 @@
44644464 "tree": "279d4e0447eb79fd75816864ba7f41f2df03080c9da69ea4603fe2900c8d9e53"
44654465 }
44664466 ],
4467 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4468 "acceptedAt": "2026-09-15T16:01:47.824Z"
4467 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4468 "acceptedAt": "2026-09-15T16:04:24.026Z"
44694469 },
44704470 {
44714471 "type": "VERSION_CONFLICT",
……@@ -4479,8 +4479,8 @@
44794479 "tree": "ed4e0b480417610e195ff0b59e5b9a453abbf2db6d6ce72d7cd522269f835e9a"
44804480 }
44814481 ],
4482 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4483 "acceptedAt": "2026-09-15T16:01:47.824Z"
4482 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4483 "acceptedAt": "2026-09-15T16:04:24.026Z"
44844484 },
44854485 {
44864486 "type": "VERSION_CONFLICT",
……@@ -4499,8 +4499,8 @@
44994499 "tree": "a9e07d54e526c838dfe5e27df33936682aefb218f61a45b6c15289243999f648"
45004500 }
45014501 ],
4502 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4503 "acceptedAt": "2026-09-15T16:01:47.824Z"
4502 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4503 "acceptedAt": "2026-09-15T16:04:24.026Z"
45044504 },
45054505 {
45064506 "type": "VERSION_CONFLICT",
……@@ -4514,8 +4514,8 @@
45144514 "tree": "9d84c86441fe138b227c4fd01d1e948aa83bf8d13cfb1aa957838feea05fbb57"
45154515 }
45164516 ],
4517 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4518 "acceptedAt": "2026-09-15T16:01:47.824Z"
4517 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4518 "acceptedAt": "2026-09-15T16:04:24.026Z"
45194519 },
45204520 {
45214521 "type": "VERSION_CONFLICT",
……@@ -4529,8 +4529,8 @@
45294529 "tree": "4a3afbe2fc7a882e24454eb2e7e7cfe6816811ee5d73ae0f92e8b9ea9cfb8c78"
45304530 }
45314531 ],
4532 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4533 "acceptedAt": "2026-09-15T16:01:47.824Z"
4532 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4533 "acceptedAt": "2026-09-15T16:04:24.026Z"
45344534 },
45354535 {
45364536 "type": "VERSION_CONFLICT",
……@@ -4544,8 +4544,8 @@
45444544 "tree": "1e5c5810d8971a3c27fde7fb0e4d633dcabbe2cf98d8df86b473f8d67eead8b9"
45454545 }
45464546 ],
4547 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4548 "acceptedAt": "2026-09-15T16:01:47.824Z"
4547 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4548 "acceptedAt": "2026-09-15T16:04:24.026Z"
45494549 },
45504550 {
45514551 "type": "VERSION_CONFLICT",
……@@ -4559,8 +4559,8 @@
45594559 "tree": "e3478f50e85ba0ac288efed83e84c992483904897dc39ccf243d20a4ea25d972"
45604560 }
45614561 ],
4562 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4563 "acceptedAt": "2026-09-15T16:01:47.824Z"
4562 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4563 "acceptedAt": "2026-09-15T16:04:24.026Z"
45644564 },
45654565 {
45664566 "type": "VERSION_CONFLICT",
……@@ -4574,8 +4574,8 @@
45744574 "tree": "c05d8bf7eab5024ea98140f78c84d12fb494b4b7b10c7eedeb50b2be842fa519"
45754575 }
45764576 ],
4577 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4578 "acceptedAt": "2026-09-15T16:01:47.824Z"
4577 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4578 "acceptedAt": "2026-09-15T16:04:24.026Z"
45794579 },
45804580 {
45814581 "type": "VERSION_CONFLICT",
……@@ -4589,8 +4589,8 @@
45894589 "tree": "0697bb338bbcec517353cffcdb2345a8232b9b90cc627a549a306f0323576390"
45904590 }
45914591 ],
4592 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4593 "acceptedAt": "2026-09-15T16:01:47.824Z"
4592 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4593 "acceptedAt": "2026-09-15T16:04:24.026Z"
45944594 },
45954595 {
45964596 "type": "VERSION_CONFLICT",
……@@ -4604,8 +4604,8 @@
46044604 "tree": "a366a73e74f8d80f106465058083b62bb70b833c721047124e99c89880e5c706"
46054605 }
46064606 ],
4607 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4608 "acceptedAt": "2026-09-15T16:01:47.824Z"
4607 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4608 "acceptedAt": "2026-09-15T16:04:24.026Z"
46094609 },
46104610 {
46114611 "type": "VERSION_CONFLICT",
……@@ -4619,8 +4619,8 @@
46194619 "tree": "7912e790c9e87e9829a6daa037c5f5da0cc4355214f51d334f96e57678dfddeb"
46204620 }
46214621 ],
4622 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4623 "acceptedAt": "2026-09-15T16:01:47.824Z"
4622 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4623 "acceptedAt": "2026-09-15T16:04:24.026Z"
46244624 },
46254625 {
46264626 "type": "VERSION_CONFLICT",
……@@ -4634,8 +4634,8 @@
46344634 "tree": "003ac841449fcdda1abc3c87fee291a5d9596d245925a7298bd8ef5f8e423eae"
46354635 }
46364636 ],
4637 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4638 "acceptedAt": "2026-09-15T16:01:47.824Z"
4637 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4638 "acceptedAt": "2026-09-15T16:04:24.026Z"
46394639 },
46404640 {
46414641 "type": "VERSION_CONFLICT",
……@@ -4654,8 +4654,8 @@
46544654 "tree": "7b3f314cf92558edbc90295c573885276a6add925318998c829c03d335edc765"
46554655 }
46564656 ],
4657 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4658 "acceptedAt": "2026-09-15T16:01:47.824Z"
4657 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4658 "acceptedAt": "2026-09-15T16:04:24.026Z"
46594659 },
46604660 {
46614661 "type": "VERSION_CONFLICT",
……@@ -4669,8 +4669,8 @@
46694669 "tree": "c486fe73805bf013e9c5de91b5780d1aa5d2b2295306f411f7863757aaa05e60"
46704670 }
46714671 ],
4672 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4673 "acceptedAt": "2026-09-15T16:01:47.824Z"
4672 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4673 "acceptedAt": "2026-09-15T16:04:24.026Z"
46744674 },
46754675 {
46764676 "type": "VERSION_CONFLICT",
……@@ -4684,8 +4684,8 @@
46844684 "tree": "10193a86e2c6f80c2d4377379a0aae4a959f8a40fd9e852b85fee081bfe6675a"
46854685 }
46864686 ],
4687 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4688 "acceptedAt": "2026-09-15T16:01:47.824Z"
4687 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4688 "acceptedAt": "2026-09-15T16:04:24.026Z"
46894689 },
46904690 {
46914691 "type": "VERSION_CONFLICT",
……@@ -4699,8 +4699,8 @@
46994699 "tree": "20b1ecd2937f3e35102892288234b86a8ec6deb3711016d96ff3509474ac465b"
47004700 }
47014701 ],
4702 "reason": "0.10.3 feed images + interaction visibility; conflicts are the same 45 version-splits/docs/packaging, 0 code-differs",
4703 "acceptedAt": "2026-09-15T16:01:47.824Z"
4702 "reason": "0.10.4: same 45 version-splits/docs/packaging conflicts, 0 code-differs",
4703 "acceptedAt": "2026-09-15T16:04:24.026Z"
47044704 }
47054705 ]
47064706}
lib/feed.js +85 -2
……@@ -56,6 +56,24 @@ export function handleOf (actorUri) {
5656// remote server choose this page's layout.
5757const textOf = (html) => String(html == null ? '' : html).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
5858
59/**
60 * An at:// subject as a place a reader can actually go.
61 *
62 * ⛔ NOT AN IDENTITY, A LOCATION. Bluesky content has no ActivityPub URI and we will not mint one on a
63 * host we do not control (hyper-social #71 §9). But `at://<did>/app.bsky.feed.post/<rkey>` names a post
64 * that exists somewhere a person can read it, and saying so is honest where inventing an id is not.
65 *
66 * ⚠ Returns null rather than guessing. A link that goes nowhere is worse than no link.
67 */
68export function bskyUrl (atUri) {
69 if (typeof atUri !== 'string' || !atUri.startsWith('at://')) return null
70 const parts = atUri.slice('at://'.length).split('/')
71 if (parts.length !== 3) return null
72 const [did, collection, rkey] = parts
73 if (!did.startsWith('did:') || collection !== 'app.bsky.feed.post' || !rkey) return null
74 return 'https://bsky.app/profile/' + did + '/post/' + rkey
75}
76
5977/**
6078 * Whose item is this, when nothing on it says so?
6179 *
……@@ -105,7 +123,7 @@ const pickAttachments = (list) => (Array.isArray(list) ? list : [])
105123 * own OUR interactions, from RecordSource.listInteractions()
106124 * recipient which peer's page this is; null means the primary's flat paths
107125 */
108export function buildFeed ({ self = {}, notes = [], inbound = [], own = [], recipient = null, host = null, isPrimary = true } = {}) {
126export function buildFeed ({ self = {}, notes = [], inbound = [], own = [], unprojectable = [], recipient = null, host = null, isPrimary = true } = {}) {
109127 const rows = []
110128
111129 for (const n of notes || []) {
……@@ -154,6 +172,30 @@ export function buildFeed ({ self = {}, notes = [], inbound = [], own = [], reci
154172 })
155173 }
156174
175 // ⛔ OUR ACTS THAT CANNOT CROSS. Measured on the live bridge: every unaddressable interaction is a
176 // like of a Bluesky post, which has no ActivityPub identity by protocol. The projection stays
177 // refused — nothing new goes on the wire — but the act is ours and this page is ours, so it is shown
178 // and linked to where the post actually lives. Silently omitting it is how "6 could not be
179 // addressed" stayed an abstraction for as long as it did.
180 for (const u of unprojectable || []) {
181 if (!u) continue
182 const kind = u.kind === 'announce' ? 'boost' : u.kind === 'like' ? 'like' : null
183 if (!kind) continue
184 rows.push({
185 id: 'unprojectable:' + (u.kind || '') + ':' + (u.rkey || ''),
186 kind,
187 verb: kind === 'like' ? 'you liked a post' : 'you boosted a post',
188 author: { handle: self.handle || null, name: self.name || null, url: null, avatar: self.avatar || null },
189 at: u.at || '',
190 text: '',
191 url: bskyUrl(u.subject),
192 subject: bskyUrl(u.subject),
193 attachments: [],
194 network: 'bsky',
195 source: 'own'
196 })
197 }
198
157199 for (const i of inbound || []) {
158200 if (!i) continue
159201 // ⛔ FAILS CLOSED, TWICE. `public !== true` catches both a DM and an item stored before the field
……@@ -208,4 +250,45 @@ export function buildFeed ({ self = {}, notes = [], inbound = [], own = [], reci
208250 return rows
209251}
210252
211export default { buildFeed, handleOf }
253export default { buildFeed, threadFeed, handleOf, bskyUrl }
254
255/**
256 * A flat feed, grouped into posts with what happened to them attached.
257 *
258 * ⛔ NOTHING IS DROPPED BY GROUPING. An interaction whose subject is not a post on this page — older
259 * than the window, or never ours — stays TOP-LEVEL. A grouping that silently swallowed it would make
260 * this page a worse record than the flat list it replaces, and "it was there, just not shown" is the
261 * shape of every interaction defect this project has had.
262 *
263 * ⚠ COUNTS ARE THE LISTS' OWN LENGTHS. A count maintained separately from the thing it counts is a
264 * disagreement waiting to happen, and the icons are the first place anyone would believe it.
265 */
266export function threadFeed (rows) {
267 const posts = []
268 const byUrl = new Map()
269
270 for (const r of rows || []) {
271 if (r.kind !== 'post') continue
272 const node = { ...r, replies: [], likes: [], boosts: [], counts: { replies: 0, likes: 0, boosts: 0 } }
273 posts.push(node)
274 if (r.url) byUrl.set(r.url, node)
275 }
276
277 const out = [...posts]
278 for (const r of rows || []) {
279 if (r.kind === 'post') continue
280 const parent = r.subject ? byUrl.get(r.subject) : null
281 if (!parent) { out.push(r); continue }
282 if (r.kind === 'reply') parent.replies.push(r)
283 else if (r.kind === 'like') parent.likes.push(r)
284 else if (r.kind === 'boost') parent.boosts.push(r)
285 else { out.push(r); continue }
286 }
287
288 for (const p of posts) {
289 p.counts = { replies: p.replies.length, likes: p.likes.length, boosts: p.boosts.length }
290 }
291
292 out.sort((a, b) => String(b.at || '').localeCompare(String(a.at || '')))
293 return out
294}
lib/public-timeline.js +52 -2
……@@ -45,11 +45,21 @@ a{color:#1d9bf0;text-decoration:none}a:hover{text-decoration:underline}
4545.media{margin-top:8px;display:flex;flex-wrap:wrap;gap:6px}
4646.media img{max-width:100%;max-height:420px;border-radius:14px;border:1px solid #eff3f4;display:block}
4747.att{font-size:13px;color:#536471;border:1px solid #eff3f4;border-radius:999px;padding:3px 10px}
48.actions{display:flex;gap:18px;margin-top:8px;font-size:13px;color:#536471;align-items:center}
49.act{cursor:default}.act.dim{opacity:.45}
50details.act{cursor:pointer}details.act summary{list-style:none;display:inline-block}
51details.act summary::-webkit-details-marker{display:none}
52details.act[open] summary{color:#1d9bf0}
53.who-list{margin:6px 0 0;padding:0 0 0 2px;list-style:none;font-size:13px}
54.who-list li{padding:1px 0}
55.replies{margin:8px 0 0 -52px;border-top:1px solid #eff3f4}
56.replies .row{padding-left:52px;border-bottom:none;padding-bottom:4px}
4857footer{color:#536471;font-size:13px;padding:16px;text-align:center}
4958@media(prefers-color-scheme:dark){body{background:#000;color:#e7e9ea}
5059header{background:rgba(0,0,0,.85);border-color:#2f3336}.row{border-color:#2f3336}
5160.row:hover{background:#080808}.handle,.time,.verb,.none,.pill,footer{color:#71767b}
52.sub-link{border-color:#2f3336;color:#71767b}.pill,.media img,.att{border-color:#2f3336}}`
61.sub-link{border-color:#2f3336;color:#71767b}.pill,.media img,.att,.replies{border-color:#2f3336}
62.actions,.who-list{color:#71767b}}`
5363
5464const when = (at) => {
5565 const d = new Date(at)
……@@ -115,7 +125,47 @@ function renderRow (r, host) {
115125 ? '<a class="sub-link" href="' + esc(r.url) + '" rel="nofollow noopener ugc">open ↗</a>'
116126 : ''
117127 const media = renderAttachments(r.attachments, host)
118 return '<article class="row">' + avatar(a) + '<div class="main">' + head + verb + body + media + back + link + '</div></article>'
128 return '<article class="row">' + avatar(a) + '<div class="main">' + head + verb + body + media + back + link +
129 renderActions(r) + renderReplies(r, host) + '</div></article>'
130}
131
132// The interaction bar under a post.
133//
134// ⛔ WHO, NOT JUST HOW MANY. A count says an interaction happened; the names say whether it happened
135// from the person you think. Both live here because a count that cannot be opened is a number nobody
136// can check, and this page exists to be checked.
137//
138// ⚠ NO JAVASCRIPT. `<details>` opens on click in every browser with nothing loaded, which is the same
139// posture the rest of this stack takes about pages that must work on a handset over a slow link.
140function people (list) {
141 return '<ul class="who-list">' + list.map((r) => {
142 const h = (r.author && r.author.handle) || 'someone'
143 const u = r.author && r.author.url
144 return '<li>' + (u ? '<a href="' + esc(u) + '" rel="nofollow noopener ugc">' + esc(h) + '</a>' : esc(h)) + '</li>'
145 }).join('') + '</ul>'
146}
147
148function renderActions (r) {
149 if (!r.counts) return ''
150 const { replies = 0, likes = 0, boosts = 0 } = r.counts
151 const bit = (icon, n, list) => {
152 if (!n) return '<span class="act dim">' + icon + ' 0</span>'
153 if (!list || !list.length) return '<span class="act">' + icon + ' ' + n + '</span>'
154 return '<details class="act"><summary>' + icon + ' ' + n + '</summary>' + people(list) + '</details>'
155 }
156 return '<div class="actions">' +
157 bit('💬', replies, null) +
158 bit('🔁', boosts, r.boosts) +
159 bit('♥', likes, r.likes) +
160 '</div>'
161}
162
163// Replies sit UNDER the post they answer, oldest first — a thread reads forwards where a timeline
164// reads backwards.
165function renderReplies (r, host) {
166 if (!r.replies || !r.replies.length) return ''
167 const kids = [...r.replies].sort((a, b) => String(a.at || '').localeCompare(String(b.at || '')))
168 return '<div class="replies">' + kids.map((k) => renderRow(k, host)).join('') + '</div>'
119169}
120170
121171export function publicTimeline ({ host = '', rows = [], offset = 0, limit = 50, total = 0, who = null, unaddressable = 0 } = {}) {
lib/records.js +11 -2
……@@ -92,6 +92,11 @@ export class RecordSource {
9292 ]
9393
9494 const interactions = []
95 // ⛔ THE REFUSED ONES, NAMED RATHER THAN COUNTED. A count told an operator that six acts existed
96 // and nothing about what they were; measured, all six are likes of Bluesky posts, which have no
97 // ActivityPub identity by protocol. The projection stays refused — this list is for OUR surfaces,
98 // which can honestly link to where the post lives.
99 const unprojectable = []
95100 let unaddressable = 0
96101 for (const { kind, collection, project } of kinds) {
97102 let res
……@@ -115,10 +120,14 @@ export class RecordSource {
115120 // at the bottom of a timeline for ever.
116121 const value = r.value || r.record || {}
117122 if (activity) interactions.push({ kind, rkey, key: kind + ':' + rkey, at: value.createdAt || null, activity })
118 else unaddressable++
123 else {
124 unaddressable++
125 const subject = value.subject && typeof value.subject === 'object' ? value.subject.uri : null
126 unprojectable.push({ kind, rkey, at: value.createdAt || null, subject: subject || null })
127 }
119128 }
120129 }
121 return { interactions, unaddressable }
130 return { interactions, unaddressable, unprojectable }
122131 }
123132
124133 // The actor's avatar/banner/displayName. Cached briefly: it is read on every actor fetch and
lib/router.js +8 -2
……@@ -339,11 +339,13 @@ async function feedPage (query, ctx) {
339339 // left. `unaddressable` is carried through rather than dropped: "0 interactions" and "12 we cannot
340340 // address" are different states and RecordSource already counts the difference.
341341 let own = []
342 let unprojectable = []
342343 let unaddressable = 0
343344 if (ctx.records && typeof ctx.records.listInteractions === 'function') {
344345 try {
345346 const r = await ctx.records.listInteractions({ limit: 100 })
346347 own = r.interactions || []
348 unprojectable = r.unprojectable || []
347349 unaddressable = r.unaddressable || 0
348350 } catch (err) {
349351 ctx.log?.({ msg: 'feed: own interactions unavailable', error: err.message })
……@@ -352,12 +354,13 @@ async function feedPage (query, ctx) {
352354
353355 const recipient = (ctx.user && ctx.user.username) || (ctx.config && ctx.config.username) || null
354356 const inboundItems = await ctx.inbound.list({ limit: 100000, offset: 0 })
355 const { buildFeed } = await import('./feed.js')
356 const rows = buildFeed({
357 const { buildFeed, threadFeed } = await import('./feed.js')
358 const flat = buildFeed({
357359 self: { handle: recipient ? '@' + recipient + '@' + ctx.config.host : null, name: ctx.config.displayName || null },
358360 notes,
359361 inbound: inboundItems,
360362 own,
363 unprojectable,
361364 recipient,
362365 // For recovering an item stored before `recipient` existed: the object URI still says whose it
363366 // was, and the bare `/ap/objects/` form is the PRIMARY's — so only the primary may claim one.
……@@ -365,6 +368,9 @@ async function feedPage (query, ctx) {
365368 isPrimary: !ctx.user || !ctx.users || ctx.users.primary === ctx.user
366369 })
367370
371 // ⛔ THREADED, THEN PAGED. Paging the flat rows first would cut a post away from its own replies.
372 const rows = threadFeed(flat)
373
368374 const { publicTimeline } = await import('./public-timeline.js')
369375 return html(200, publicTimeline({
370376 host: ctx.config.host,
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "ap-bridge",
3 "version": "0.10.4",
3 "version": "0.10.5",
44 "description": "ActivityPub membrane \u2014 a second projection of the self-custody atproto peer",
55 "type": "module",
66 "main": "index.js",
test/feed.test.js +28 -0
……@@ -203,3 +203,31 @@ test('⛔ an object on somebody else\'s host recovers nothing and stays unattrib
203203 })
204204 t.is(rows.length, 0, 'we cannot read another server\'s paths, and will not pretend to')
205205})
206
207// ── interactions we cannot federate, but can still show ────────────────────────────────────────────
208//
209// ⛔ MEASURED, NOT ASSUMED. All 6 of the live bridge's unaddressable interactions are likes of BLUESKY
210// posts: did:plc:4732wedq…, did:plc:e2jevhuu…, did:plc:y5caotnr…. None is on our host, none is fronted
211// by us, and Bluesky content has no ActivityPub identity at all — so `projectLike` is right to refuse
212// and minting a URI on a host we do not control is what hyper-social #71 §9 explicitly declines.
213//
214// ⚠ THE AP PROJECTION IS UNCHANGED. Nothing new goes on the wire. This is OUR page showing OUR act,
215// linked to where the post actually lives, which is the only truthful way to render it.
216
217test('★★ an unprojectable like still appears, linked to where the post really is', (t) => {
218 const rows = buildFeed({
219 self: { handle: '@p2p@ap.7rnx.net' },
220 unprojectable: [{ kind: 'like', rkey: 'k1', at: '2026-09-15T10:00:00Z', subject: 'at://did:plc:y5caotnrle4kxlfez4azaf33/app.bsky.feed.post/3mrx7jvraws23' }]
221 })
222 t.is(rows.length, 1)
223 t.is(rows[0].verb, 'you liked a post')
224 t.is(rows[0].subject, 'https://bsky.app/profile/did:plc:y5caotnrle4kxlfez4azaf33/post/3mrx7jvraws23',
225 'named where it lives, not at a URI we would have had to invent')
226 t.is(rows[0].network, 'bsky', 'and the row says which network, so the link is not a surprise')
227})
228
229test('⛔ a subject we cannot even locate is still not invented', (t) => {
230 const rows = buildFeed({ self: {}, unprojectable: [{ kind: 'like', rkey: 'k2', at: 'z', subject: 'garbage' }] })
231 t.is(rows.length, 1, 'the act happened and is shown')
232 t.is(rows[0].subject, null, 'but nothing is fabricated to point at')
233})
test/router.test.js +5 -1
……@@ -420,7 +420,11 @@ test('★★ /timeline serves one feed: our own posts and the interactions sent
420420 const res = await handleRequest({ method: 'GET', path: '/timeline', headers: {} }, ctx)
421421 t.is(res.status, 200)
422422 t.ok(/my post/.test(res.body), 'our own post is on our own feed')
423 t.ok(/liked your post/.test(res.body), 'and so is the interaction — the reason to look at this page')
423 // ⚠ The like acted on a post that IS on this page, so it renders as that post's like count with the
424 // liker named inside it — not as a standalone "liked your post" row. Both are the interaction being
425 // visible; this is the threaded form.
426 t.ok(/♥ 1/.test(res.body), 'the interaction shows as a count on the post it acted on')
427 t.ok(/@alice@mastodon.social/.test(res.body), 'and opening it names who — a count nobody can check is not evidence')
424428 t.absent(/secret/.test(res.body), 'a DM arrived at the same inbox and is not published')
425429})
426430
test/thread.test.js +75 -0
……@@ -0,0 +1,75 @@
1// Threading — a post with what happened to it attached, instead of a flat stream of arrivals.
2//
3// ⛔ NOTHING IS DROPPED BY GROUPING. A reply to a post that is not on this page (older than the
4// window, or never ours) stays TOP-LEVEL. Grouping that silently swallowed it would make the page a
5// worse record than the flat list it replaces, and "it was there, just not shown" is the exact shape
6// of every interaction defect this project has had.
7
8import test from 'brittle'
9import { buildFeed, threadFeed } from '../lib/feed.js'
10
11const OURS = 'https://ap.7rnx.net/ap/objects/1'
12const OTHER = 'https://ap.7rnx.net/ap/objects/999'
13
14const post = (id, at) => ({ id, type: 'Note', content: '<p>hello</p>', published: at })
15const act = (type, over = {}) => ({
16 id: type + Math.random(), type, actor: 'https://m.example/users/alice',
17 public: true, published: '2026-09-15T10:00:00Z', objectId: OURS, ...over
18})
19
20test('replies, likes and boosts attach to the post they acted on', (t) => {
21 const rows = buildFeed({
22 self: { handle: '@p2p@ap.7rnx.net' },
23 notes: [post(OURS, '2026-09-15T09:00:00Z')],
24 inbound: [
25 act('Create', { inReplyTo: OURS, content: '<p>nice</p>' }),
26 act('Like'),
27 act('Announce')
28 ]
29 })
30 const thread = threadFeed(rows)
31 t.is(thread.length, 1, 'one top-level post, not four rows')
32 t.is(thread[0].replies.length, 1)
33 t.is(thread[0].likes.length, 1)
34 t.is(thread[0].boosts.length, 1)
35 t.is(thread[0].replies[0].text, 'nice')
36 t.is(thread[0].likes[0].author.handle, '@alice@m.example', 'and we know WHO liked it')
37})
38
39test('⛔ an interaction on a post that is not on this page stays visible', (t) => {
40 const rows = buildFeed({
41 self: {},
42 notes: [post(OURS, '2026-09-15T09:00:00Z')],
43 inbound: [act('Like', { objectId: OTHER })]
44 })
45 const thread = threadFeed(rows)
46 t.is(thread.length, 2, 'the post, and the orphaned like — nothing is swallowed')
47 t.ok(thread.some((r) => r.kind === 'like'), 'it is still a row of its own')
48})
49
50test('a follow has no subject and is always top-level', (t) => {
51 const thread = threadFeed(buildFeed({ self: {}, inbound: [act('Follow', { objectId: null })] }))
52 t.is(thread.length, 1)
53 t.is(thread[0].kind, 'follow')
54})
55
56test('counts are what the icons show, and they match the lists', (t) => {
57 const rows = buildFeed({
58 self: {},
59 notes: [post(OURS, '2026-09-15T09:00:00Z')],
60 inbound: [act('Like'), act('Like', { actor: 'https://m.example/users/bob' }), act('Create', { inReplyTo: OURS })]
61 })
62 const [p] = threadFeed(rows)
63 t.is(p.counts.likes, 2)
64 t.is(p.counts.replies, 1)
65 t.is(p.counts.boosts, 0)
66 t.is(p.counts.likes, p.likes.length, 'a count that can disagree with its own list is a lie waiting')
67})
68
69test('our own posts keep their order, newest first', (t) => {
70 const rows = buildFeed({
71 self: {},
72 notes: [post('a', '2026-09-15T09:00:00Z'), post('b', '2026-09-15T11:00:00Z')]
73 })
74 t.alike(threadFeed(rows).map((r) => r.id), ['b', 'a'])
75})