Overview
Reactions (emojis, upvotes, etc.) will be added as a new signed blob type, mirroring the existing comment pattern. Reactions support four target types: documents, comments, blocks, and block-level text fragments.
1. Design Decisions
Storage Model: Individual Signed Blobs
Each reaction action is its own content-addressed signed blob. This follows the same pattern as comments and gives verifiability, p2p sync, and consistent tooling.
Add = create a new
Reactionblob (signed by the user)Remove = create a tombstone
Reactionblob with the same TSID (empty emoji +deletedflag in extra_attrs, same as comment deletion)
Target Granularity: Four Levels
A reaction can target any of these:
Emoji Model: Free-Form
Any Unicode emoji codepoint is valid. No predefined set.
Mutations: Explicit Add/Remove
Separate AddReaction and RemoveReaction RPCs. A user can have multiple distinct emoji reactions on the same target (e.g. both ๐ and โค๏ธ on the same comment).
Visibility: Inherited from Target
Same as comments โ reactions on a private document are visible only to the document owner and their writers (via blob_visibility propagation).
Aggregation: In ActivitySummary
Reaction counts per document/space will be tracked alongside comment counts in ActivitySummary, document_generations, and spaces tables.
Resource Union
Reaction will be added to the Resource union in resources.proto, alongside Document, Comment, and Contact.
2. Proto Definitions
2a. New File: proto/documents/v3alpha/reactions.proto
syntax = "proto3";
package com.seed.documents.v3alpha;
import "google/protobuf/empty.proto";
import "google/protobuf/timestamp.proto";
option go_package = "seed/backend/genproto/documents/v3alpha;documents";
service Reactions {
rpc AddReaction(AddReactionRequest) returns (Reaction);
rpc RemoveReaction(RemoveReactionRequest) returns (google.protobuf.Empty);
rpc ListReactions(ListReactionsRequest) returns (ListReactionsResponse);
rpc GetReactionAggregates(GetReactionAggregatesRequest) returns (GetReactionAggregatesResponse);
}
message Reaction {
string id = 1;
string author = 2;
string emoji = 3;
oneof target {
DocumentTarget document_target = 4;
CommentTarget comment_target = 5;
BlockTarget block_target = 6;
BlockFragmentTarget block_fragment_target = 7;
}
string target_version = 8;
google.protobuf.Timestamp create_time = 9;
string version = 10;
string visibility = 11;
}
message DocumentTarget {
string account = 1;
string path = 2;
}
message CommentTarget {
string comment_id = 1;
}
message BlockTarget {
string account = 1;
string path = 2;
string block_id = 3;
}
message BlockFragmentTarget {
string account = 1;
string path = 2;
string block_id = 3;
int32 start = 4;
int32 end = 5;
}
message AddReactionRequest {
string target_account = 1;
string target_path = 2;
string target_comment_id = 3;
string target_block_id = 4;
optional int32 target_fragment_start = 5;
optional int32 target_fragment_end = 6;
string emoji = 10;
string target_version = 11;
string signing_key_name = 12;
string capability = 13;
}
message RemoveReactionRequest {
string id = 1;
string signing_key_name = 2;
}
message ListReactionsRequest {
string target_account = 1;
string target_path = 2;
optional string target_comment_id = 3;
optional string target_block_id = 4;
optional int32 target_fragment_start = 5;
optional int32 target_fragment_end = 6;
int32 page_size = 10;
string page_token = 11;
}
message ListReactionsResponse {
repeated Reaction reactions = 1;
string next_page_token = 2;
map<string, int32> emoji_counts = 3;
}
message GetReactionAggregatesRequest {
string target_account = 1;
string target_path = 2;
optional string target_comment_id = 3;
optional string target_block_id = 4;
optional int32 target_fragment_start = 5;
optional int32 target_fragment_end = 6;
}
message GetReactionAggregatesResponse {
map<string, int32> emoji_counts = 1;
int32 total_reactions = 2;
}2b. Modify proto/documents/v3alpha/documents.proto
Add two fields to ActivitySummary (after is_unread):
int32 reaction_count = 6;
google.protobuf.Timestamp latest_reaction_time = 7;2c. Modify proto/documents/v3alpha/resources.proto
Add to the Resource.oneof kind (using field number 5, since 4 is taken by version):
Reaction reaction = 5;Add the import:
import "documents/v3alpha/reactions.proto";3. Backend Domain Model
3a. New File: backend/blob/blob_reaction.go
const TypeReaction Type = "Reaction"
type ReactionTargetType string
const (
ReactionTargetDocument ReactionTargetType = "document"
ReactionTargetComment ReactionTargetType = "comment"
ReactionTargetBlock ReactionTargetType = "block"
ReactionTargetFragment ReactionTargetType = "fragment"
)
type ReactionTarget struct {
Type ReactionTargetType
Account string
Path string
CommentID string
BlockID string
FragmentStart int32
FragmentEnd int32
}
type Reaction struct {
BaseBlob
ID TSID
Space_ core.Principal // only set when signer != space owner
Path string
Version []cid.Cid // target document version at reaction time
Emoji string
Target ReactionTarget
Visibility Visibility
}Key methods:
NewReaction(kp, id, space, path, version, emoji, target, visibility, ts) (*EncodedReaction, error)โ signs and encodesTSID() TSIDโ implementsReplacementBlobSpace() core.Principalโ convenience for whenSpace_is empty
CBOR Registration: Register Reaction{} and ReactionTarget{} types via cbornode.RegisterCborType.
Decoder: Follow the same pattern as comments โ validate signature against raw map first, then decode into Reaction struct.
3b. Indexer Logic (indexReaction)
Parse target IRI from space + path
Detect tombstones:
isTombstone := v.Emoji == ""Build
structuralBlobwith:Type: TypeReactionExtraAttrs:{emoji, tsid, target_type, target_comment_id?, target_block_id?, target_fragment_start?, target_fragment_end?, visibility?, deleted?}
Set visibility spaces for private reactions (signer + target space)
Link to document resource via
resource_links(type:"reaction/target")Call
ictx.SaveBlob(sb)Update reaction stats using pattern from comments:
Compute
reactionCountDelta()(same logic ascommentCountDelta)For each matching
documentGeneration: updateLastReaction,LastReactionTime,ReactionCountUpdate
spaceReactionStatson thespacestable
No FTS indexing needed (reactions aren't searchable text)
3c. reactionCountDelta Function
Same logic as commentCountDelta in blob_comment.go:
New TSID โ +1
Same TSID (edit) โ 0
First tombstone for live TSID โ -1
Live after tombstone (out-of-order P2P) โ +1
Query uses extra_attrs->>'tsid' and extra_attrs->>'deleted' on structural_blobs WHERE type = 'Reaction'.
3d. spaceReactionStats Struct
Mirrors spaceCommentStats:
type spaceReactionStats struct {
shouldUpdate bool
ID string
LastReaction int64
LastReactionTime int64
ReactionCount int64
}
With load() and save() methods using qLoadSpaceReactionStats, qInsertSpaceReactionStats, qUpdateSpaceReactionStats.
3e. Registration in blob_reaction.go (init)
func init() {
matcher := makeCBORTypeMatch(TypeReaction)
registerIndexer(TypeReaction, decodeFunc, indexReaction)
}
4. Schema Changes
4a. backend/storage/schema.sql
spaces table โ add after comment_count:
last_reaction INTEGER REFERENCES blobs (id) ON UPDATE CASCADE ON DELETE CASCADE,
last_reaction_time INTEGER NOT NULL DEFAULT (0),
reaction_count INTEGER NOT NULL DEFAULT (0),document_generations table โ add after comment_count:
last_reaction INTEGER REFERENCES blobs (id) ON UPDATE CASCADE ON DELETE CASCADE,
last_reaction_time INTEGER NOT NULL DEFAULT (0),
reaction_count INTEGER NOT NULL DEFAULT (0),New indexes (foreign key requirement):
CREATE INDEX spaces_by_last_reaction ON spaces (last_reaction) WHERE last_reaction IS NOT NULL;
CREATE INDEX document_generations_by_last_reaction ON document_generations (last_reaction) WHERE last_reaction IS NOT NULL;4b. backend/storage/storage_migrations.go
New migration (added at top of descending list):
{Version: "2026-05-14.000000", Run: func(_ *Store, conn *sqlite.Conn) error {
return sqlitex.ExecScript(conn, sqlfmt(`
ALTER TABLE document_generations ADD COLUMN last_reaction INTEGER REFERENCES blobs (id) ON UPDATE CASCADE ON DELETE CASCADE;
ALTER TABLE document_generations ADD COLUMN last_reaction_time INTEGER NOT NULL DEFAULT (0);
ALTER TABLE document_generations ADD COLUMN reaction_count INTEGER NOT NULL DEFAULT (0);
ALTER TABLE spaces ADD COLUMN last_reaction INTEGER REFERENCES blobs (id) ON UPDATE CASCADE ON DELETE CASCADE;
ALTER TABLE spaces ADD COLUMN last_reaction_time INTEGER NOT NULL DEFAULT (0);
ALTER TABLE spaces ADD COLUMN reaction_count INTEGER NOT NULL DEFAULT (0);
CREATE INDEX IF NOT EXISTS document_generations_by_last_reaction ON document_generations (last_reaction) WHERE last_reaction IS NOT NULL;
CREATE INDEX IF NOT EXISTS spaces_by_last_reaction ON spaces (last_reaction) WHERE last_reaction IS NOT NULL;
`))
}},4c. backend/storage/schema.gen.go
Auto-regenerated by ./dev gen //backend/.... Will get new column constants for the six new columns.
5. Modify backend/blob/blob_ref.go โ documentGeneration Fields
Add three new fields to the documentGeneration struct:
LastReaction int64
LastReactionTime int64
ReactionCount int64Update fromRow(): Read new columns after CommentCount.
Update save(): Write reaction columns in the sqlitex.Exec call with lastReaction (nullable via maybe.Value), dg.LastReactionTime, dg.ReactionCount.
Update SQL queries to include the three new columns:
qLoadDocumentGenerationqLoadGenerationsForResourceqInsertDocumentGeneration(add columns + values)qUpdateDocumentGeneration(add SET clauses, renumber params after 6)
6. Backend API Layer
6a. New File: backend/api/documents/v3alpha/reactions.go
AddReaction (follows CreateComment pattern):
Validate signing key, emoji, target version
Parse target type from request fields (document / comment / block / fragment)
Get document visibility from target
Call
blob.NewReaction(kp, "", space, path, versionHeads, emoji, target, visibility, ts)Call
srv.idx.Put(ctx, eb)Return proto via
reactionToProto()
RemoveReaction (follows DeleteComment pattern):
Decode reaction record ID
Verify signing key matches reaction author
Load original reaction to get target info
Create tombstone:
NewReaction(kp, rid.TSID, space, path, version, "", originalTarget, visibility, ts)Store and index
ListReactions:
Query
structural_blobs WHERE type = 'Reaction'for the target IRIUse
ROW_NUMBER() OVER (PARTITION BY tsid ORDER BY ts DESC)to dedupe versionsFilter out deleted reactions
Restrict visibility if
cfg.PublicOnlyAggregate emoji counts in-app
GetReactionAggregates:
SQL:
SELECT emoji, COUNT(*) FROM (...) WHERE rn = 1 AND deleted IS NULL GROUP BY emoji ORDER BY cnt DESCReturn emojiโcount map + total
DB mapper (reactionDBMapper): Same pattern as commentDBMapper โ decompress blob data, decode CBOR into *blob.Reaction.
SQL queries:
qIterReactions/qIterReactionsPublicOnlyโ list reactions for a resourceqGetReactionByTSIDโ lookup by author + TSIDqGetReactionByCIDโ lookup by CIDqReactionAggregates/qReactionAggregatesPublicOnlyโ GROUP BY emoji counts
6b. Modify backend/api/documents/v3alpha/documents.go
Register ReactionsServer:
documents.RegisterReactionsServer(rpc, srv)Update baseAccountQuery() โ add to SELECT:
"spaces.last_reaction",
"spaces.last_reaction_time",
"spaces.reaction_count",Update accountFromRow() โ read new columns and populate:
ActivitySummary: &documents.ActivitySummary{
...
ReactionCount: int32(reactionCount),
LatestReactionTime: latestReactionTime,
}Update baseListDocumentsQuery() โ add to SELECT:
"dg.last_reaction",
"dg.last_reaction_time",
"dg.reaction_count",Update documentInfoFromRow() โ read new columns and populate ActivitySummary similarly.
Note: Only reaction_count and latest_reaction_time are in ActivitySummary. There is no latest_reaction_id field in the proto summary (unlike comments which have latest_comment_id). This keeps the reaction aggregation more lightweight.
7. Change Summary (Files Touched)
8. Testing Strategy
Unit tests:
NewReaction()encoding/decoding, tombstone detection, target serialization,reactionCountDeltaedge casesIntegration tests: Add + List + Remove cycle, verifying count toggles correctly
Schema tests: Verify new columns exist via
schema_test.goAPI tests: Following existing comment test patterns
9. Open Question
For block fragment targets (text selections within a block):
Raw offsets (
start,endints): Simpler to implement, but fragile โ if the block text changes, the offsets may no longer be meaningful.Annotation reference (reference an existing
Annotationon the block): More stable, but requires the annotation to exist first.
The plan above uses raw offsets. This should be confirmed before implementation.
Do you like what you are reading?. Subscribe to receive updates.
Unsubscribe anytime