Summary
Reaction writes already work over peer on v2. Reaction reads do not — there is no peer variant of list / summary / list-by-identity on the unified v2 surface, so a client can only ever see its own reactions on another identity's post. The PeerReactionSenderService that does the work already exists.
Found while building the native KMP feed (homebase-id/chat-kmp#802).
What breaks without it
Tap "who reacted" on a post by an identity you follow and you see only yourself, while the post's own reaction count (which comes from the file header and is correct) says otherwise.
Root cause on the client: DriveFileGroupReactionProvider builds every reaction URL from creds.domain — our identity — so on a followed post it reads our local feed-drive copy, which only ever holds reactions we sent:
homebase-api/.../drives/files/reactions/DriveFileGroupReactionProvider.kt:198, :222, :249 — all apiUrl(creds.domain, endpoint)
homebase-core/.../feed/services/PostReactionService.kt:195-219 — listReactors / reactorsFor
- The feed currently ships an honest degradation: authoritative counts from the header, plus a "partial list" footnote (
FeedTimelineViewModel.kt:270)
The web client handles this by branching on locality — dotyoucore-js/.../core/ReactionData/ReactionService.ts:26, 57, 92 (const isLocal = odinId === dotYouClient.getHostIdentity()) and calling '/transit/reactions' + '/list' (line 116) otherwise.
Scope: reads only — writes are done, please don't rebuild them
| Op |
Over peer today? |
Path |
| add |
✅ works |
client posts to its own host with transitOptions.recipients → GroupReactionService.AddReactionAsync (GroupReactionService.cs:65-77) enqueues OutboxItemType.AddRemoteReaction → IPeerGroupReactionHttpClient → PeerPerimeterGroupReactionController [HttpPost("add")] |
| delete |
✅ works |
same path, GroupReactionService.cs:105-115 |
| list reactors |
❌ missing |
own host only |
| summary / counts |
❌ missing |
own host only |
| list-by-identity |
❌ missing |
own host only |
Proposed routes
GET /api/v2/peer/{peerIdentity}/drives/{driveId}/files/by-gtid/{gtid}/reactions?cursor=&maxRecords=
GET /api/v2/peer/{peerIdentity}/drives/{driveId}/files/by-gtid/{gtid}/reactions/summary
GET /api/v2/peer/{peerIdentity}/drives/{driveId}/files/by-gtid/{gtid}/reactions/by-identity?identity=
Why keyed by globalTransitId: a followed identity's post lands on the feed drive as a reference — it has no fileId on the author's drive and no uniqueId at all (measured: uniqueId is NULL on 100% of feed-drive rows). The gtid is the only handle the client holds. Conveniently the peer service is already gtid-keyed: GetRemoteReactionsRequest.File and AddRemoteReactionRequest.File are both GlobalTransitIdFileIdentifier. The PeerByGtid route template already exists (UnifiedApiRouteConstants.cs:31) and V2DrivePeerQueryByGtidController already binds {gtid} this way.
Policy: OwnerOrApp.
What already exists
| Piece |
Location |
| Does the work |
src/services/Odin.Services/Peer/Outgoing/Drive/Reactions/PeerReactionSenderService.cs — GetReactionsAsync (:52), GetReactionCountsAsync (:77), GetReactionsByIdentityAndFileAsync (:101) |
| Actions to copy |
src/apps/Odin.Hosting/Controllers/Base/Transit/PeerReactionContentSenderControllerBase.cs:35, :72, :83 |
| Perimeter half (live) |
PeerPerimeterReactionContentController.cs — list (:33), summary (:56), listbyidentity (:63) → PeerIncomingReactionService |
One thing worth double-checking
The client writes group reactions, but this peer path is the direct reaction service. They appear to share storage — GroupReactionService takes ReactionContentService as a ctor dependency (GroupReactionService.cs:25) and delegates every read and write to it (:64, :105, :131, :145, :155), and PeerIncomingReactionService is constructed from the same ReactionContentService (PeerIncomingReactionService.cs:17) and calls reactionContentService.GetReactionsAsync (:69). So the peer read should return exactly the rows the group route wrote — but please confirm this holds for fileSystemType handling before assuming it's a clean lift.
Response shape request
The peer path currently returns GetReactionsPerimeterResponse { List<PerimeterReaction>, int? Cursor } where PerimeterReaction { OdinId, ReactionContent, Created, GlobalTransitIdFileIdentifier }.
The local v2 group route returns GetReactionsResponse { List<Reaction>, int? Cursor }, which the client decodes as GroupReactionItem { reactionContent, odinId, fileId, created }.
Please emit the local shape from the new v2 peer routes so the client can reuse its existing decoder; fileId can be omitted or zeroed.
Why v1 doesn't work for this client
POST /api/apps/v1/transit/reactions/{list,summary,listbyidentity} — AppPeerReactionContentSenderController (src/apps/Odin.Hosting/Controllers/ClientToken/App/Transit/AppTransitReactionContentSenderController.cs:11-16) carries [AuthorizeValidAppToken] (line 13) → 401 for the v2 bearer token.
Note on a doc that may get cited
dotyoucore-js/docs/api/v2/reactions.md lists [GET] /api/apps/v2/peer/files/reactions and an unified-reactions family. These are not implemented — docs/api/v2/index.md:1 labels the folder "First draft of a potential new api v2 structure", it proposes the /api/apps/v2/… prefix rather than the shipped /api/v2/…, and grepping apps/v2 / unified-reactions across src and Tests returns zero hits. Wish-list, not spec.
Verification
Confirmed against HEAD 84d50011e: src/apps/Odin.Hosting/UnifiedV2/Drive/Reactions/ contains only V2DriveGroupReactionController.cs and V2DriveReactionController.cs, both local-drive only.
Summary
Reaction writes already work over peer on v2. Reaction reads do not — there is no peer variant of list / summary / list-by-identity on the unified v2 surface, so a client can only ever see its own reactions on another identity's post. The
PeerReactionSenderServicethat does the work already exists.Found while building the native KMP feed (
homebase-id/chat-kmp#802).What breaks without it
Tap "who reacted" on a post by an identity you follow and you see only yourself, while the post's own reaction count (which comes from the file header and is correct) says otherwise.
Root cause on the client:
DriveFileGroupReactionProviderbuilds every reaction URL fromcreds.domain— our identity — so on a followed post it reads our local feed-drive copy, which only ever holds reactions we sent:homebase-api/.../drives/files/reactions/DriveFileGroupReactionProvider.kt:198, :222, :249— allapiUrl(creds.domain, endpoint)homebase-core/.../feed/services/PostReactionService.kt:195-219—listReactors/reactorsForFeedTimelineViewModel.kt:270)The web client handles this by branching on locality —
dotyoucore-js/.../core/ReactionData/ReactionService.ts:26, 57, 92(const isLocal = odinId === dotYouClient.getHostIdentity()) and calling'/transit/reactions' + '/list'(line 116) otherwise.Scope: reads only — writes are done, please don't rebuild them
transitOptions.recipients→GroupReactionService.AddReactionAsync(GroupReactionService.cs:65-77) enqueuesOutboxItemType.AddRemoteReaction→IPeerGroupReactionHttpClient→PeerPerimeterGroupReactionController[HttpPost("add")]GroupReactionService.cs:105-115Proposed routes
Why keyed by globalTransitId: a followed identity's post lands on the feed drive as a reference — it has no fileId on the author's drive and no uniqueId at all (measured:
uniqueIdis NULL on 100% of feed-drive rows). The gtid is the only handle the client holds. Conveniently the peer service is already gtid-keyed:GetRemoteReactionsRequest.FileandAddRemoteReactionRequest.Fileare bothGlobalTransitIdFileIdentifier. ThePeerByGtidroute template already exists (UnifiedApiRouteConstants.cs:31) andV2DrivePeerQueryByGtidControlleralready binds{gtid}this way.Policy:
OwnerOrApp.What already exists
src/services/Odin.Services/Peer/Outgoing/Drive/Reactions/PeerReactionSenderService.cs—GetReactionsAsync(:52),GetReactionCountsAsync(:77),GetReactionsByIdentityAndFileAsync(:101)src/apps/Odin.Hosting/Controllers/Base/Transit/PeerReactionContentSenderControllerBase.cs:35, :72, :83PeerPerimeterReactionContentController.cs—list(:33),summary(:56),listbyidentity(:63) →PeerIncomingReactionServiceOne thing worth double-checking
The client writes group reactions, but this peer path is the direct reaction service. They appear to share storage —
GroupReactionServicetakesReactionContentServiceas a ctor dependency (GroupReactionService.cs:25) and delegates every read and write to it (:64, :105, :131, :145, :155), andPeerIncomingReactionServiceis constructed from the sameReactionContentService(PeerIncomingReactionService.cs:17) and callsreactionContentService.GetReactionsAsync(:69). So the peer read should return exactly the rows the group route wrote — but please confirm this holds forfileSystemTypehandling before assuming it's a clean lift.Response shape request
The peer path currently returns
GetReactionsPerimeterResponse { List<PerimeterReaction>, int? Cursor }wherePerimeterReaction { OdinId, ReactionContent, Created, GlobalTransitIdFileIdentifier }.The local v2 group route returns
GetReactionsResponse { List<Reaction>, int? Cursor }, which the client decodes asGroupReactionItem { reactionContent, odinId, fileId, created }.Please emit the local shape from the new v2 peer routes so the client can reuse its existing decoder;
fileIdcan be omitted or zeroed.Why v1 doesn't work for this client
POST /api/apps/v1/transit/reactions/{list,summary,listbyidentity}—AppPeerReactionContentSenderController(src/apps/Odin.Hosting/Controllers/ClientToken/App/Transit/AppTransitReactionContentSenderController.cs:11-16) carries[AuthorizeValidAppToken](line 13) → 401 for the v2 bearer token.Note on a doc that may get cited
dotyoucore-js/docs/api/v2/reactions.mdlists[GET] /api/apps/v2/peer/files/reactionsand anunified-reactionsfamily. These are not implemented —docs/api/v2/index.md:1labels the folder "First draft of a potential new api v2 structure", it proposes the/api/apps/v2/…prefix rather than the shipped/api/v2/…, and greppingapps/v2/unified-reactionsacrosssrcandTestsreturns zero hits. Wish-list, not spec.Verification
Confirmed against HEAD
84d50011e:src/apps/Odin.Hosting/UnifiedV2/Drive/Reactions/contains onlyV2DriveGroupReactionController.csandV2DriveReactionController.cs, both local-drive only.