Skip to content

Commit 9d4153d

Browse files
committed
feat(threads): add phase 1 of the threads feature
Part of status-im/status-app#21257 Implements a first-class thread model for community chat channels. Threads are anchored to an existing parent message and inherit the channel's permissions and encryption. The feature is gated behind a `Threads` feature flag (off by default). --- ### Schema & protocol - **`chat_message.proto`** — added optional `thread_id` field (field 21) to `ChatMessage`. Old clients silently ignore the field; new clients write to and read from it transparently. - **`communities.proto`** — added `create_thread_all_members_enabled` boolean to `CommunityAdminSettings`, controlling who may start a new thread. - **DB migrations** - `1764000001` — `ALTER TABLE user_messages ADD COLUMN thread_id TEXT` with indexes on `(local_chat_id, thread_id)` and `(thread_id)`. - `1764000002` — `CREATE TABLE threads (thread_id, chat_id, parent_message_id, name)` with an index on `(chat_id, name)`. --- ### Persistence (`protocol/message_persistence.go`) - New `Thread` struct with JSON tags (`threadId`, `chatId`, `parentMessageId`, `name`). - `SaveMessages` now auto-creates or updates thread metadata whenever a message with a non-empty `thread_id` is saved. If the parent message is not yet known the thread name is stored as an empty string; when the parent arrives, `updateThreadNameFromParentIfNeeded` back-fills the name. - Thread name is normalised to a 40-character trimmed excerpt of the parent message text. Unknown/placeholder names are **empty string** — the client controls the display label. - New persistence helpers: `UpsertThread`, `ThreadByID`, `ThreadsByChatID`, `MessagesByThreadID`, `threadNameFromParentByID`. --- ### Community & request layer - `AllowsAllMembersToCreateThread()` helper on `Community`. - `CreateCommunityRequest` / `EditCommunityRequest` now forward `CreateThreadAllMembersEnabled`. --- ### Messenger (`protocol/messenger.go`) - `sendChatMessage` enforces the community thread-creation permission gate: only admins may start a new thread unless `AllowsAllMembersToCreateThread` is set. - **`CreateThread(chatID, parentMessageID)`** — explicit thread creation API. Requires the parent message to already exist locally; returns `"parent message not found"` otherwise. Applies the same permission gate as `sendChatMessage`. - **`ThreadsByChatID(chatID)`** — lists all threads for a channel. - **`MessagesByThreadID(chatID, threadID, cursor, limit)`** — paginated thread message history. - `addThreadsToResponse` enriches every `MessengerResponse` (send path and retrieve/signal path) with the thread metadata for any thread-tagged messages in the response. --- ### MessengerResponse (`protocol/messenger_response.go`) - New private `threads map[string]*Thread` field. - `AddThread` / `AddThreads` / `Threads()` accessors. - `threads` included in `MarshalJSON` output (`"threads"` key), `IsEmpty`, and `Merge`. --- ### RPC API (`services/ext/api.go`) Three new `PublicAPI` methods exposed over JSON-RPC under the `wakuext` namespace: | Method | Description | |--------|-------------| | `wakuext_createThread` | Create thread from an existing parent message | | `wakuext_chatThreads` | List all threads for a chat | `wakuext_sendChatMessage` now accepts an optional `threadId` field. --- ### Tests added - `TestSaveMessagesCreatesThreadWithEmptyNameWhenParentMissing` - `TestSaveMessagesUpdatesEmptyThreadNameWhenParentArrives` - `TestMessagesByThreadID` - `TestDeleteParentMessageKeepsThreadMetadata` - `TestCreateThreadFromExistingParent` - `TestCreateThreadFailsWhenParentMessageMissing` - `TestMessengerResponseMergeThreads` - `TestMessengerResponseMarshalJSONIncludesThreads` --- ### API reference `cmd/status-backend/API_REFERENCE.md` updated with request/response examples and error tables for all three new RPC methods.
1 parent a1f739a commit 9d4153d

22 files changed

Lines changed: 1110 additions & 62 deletions

cmd/status-backend/API_REFERENCE.md

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,10 +319,15 @@ Send a text message to a chat/channel.
319319
[{
320320
"chatId": "<chatId>",
321321
"text": "Hello!",
322-
"contentType": 1
322+
"contentType": 1,
323+
"threadId": "<parentMessageId-optional>"
323324
}]
324325
```
325326

327+
- `threadId` is optional.
328+
- Use `threadId` to send a message into an existing thread.
329+
- If `threadId` points to a parent that is not yet known locally, the backend still creates thread metadata and backfills the name when the parent arrives.
330+
326331
**Chat ID formats:**
327332

328333
- **Community channel:** concatenation of community ID and chat UUID without separator:
@@ -346,6 +351,93 @@ Read message history for a chat.
346351

347352
---
348353

354+
### wakuext_createThread
355+
356+
Explicitly create a thread from an existing parent message. The parent message must already be stored locally (i.e. it was previously received or sent).
357+
358+
For community chats, the caller must be a privileged member (admin/owner) unless the community has "create threads for all members" enabled.
359+
360+
**Params:** `[chatId, parentMessageId]`
361+
- `chatId`: string — channel/chat identifier.
362+
- `parentMessageId`: string — ID of the message that becomes the thread root.
363+
364+
**Result:**
365+
```json
366+
{
367+
"threads": [
368+
{
369+
"threadId": "0x-parent-message-id",
370+
"chatId": "0xcommunity...<chatUUID>",
371+
"parentMessageId": "0x-parent-message-id",
372+
"name": "First 40 chars of parent text (or empty)"
373+
}
374+
]
375+
}
376+
```
377+
378+
**Errors:**
379+
- `"threads feature is disabled"` — feature flag is off.
380+
- `"chatID and parentMessageID are required"` — missing params.
381+
- `"thread already exists for this message"` — thread already created for this parent.
382+
- `"parent message not found"` — parent message doesn't exist locally.
383+
- `"only admins can create threads in this community"` — permission denied.
384+
385+
---
386+
387+
### wakuext_chatThreads
388+
389+
List threads for a chat.
390+
391+
**Params:** `[chatId]`
392+
- `chatId`: string (communityId + chatUUID)
393+
394+
**Result:**
395+
```json
396+
{
397+
"threads": [
398+
{
399+
"threadId": "0x-parent-message-id",
400+
"chatId": "0xcommunity...<chatUUID>",
401+
"parentMessageId": "0x-parent-message-id",
402+
"name": "Parent message text (or empty until known)"
403+
}
404+
]
405+
}
406+
```
407+
408+
Notes:
409+
- `name` may be empty when the parent message has not been persisted yet.
410+
- Unknown/placeholder names are intentionally client-defined; backend returns empty string until resolved.
411+
412+
---
413+
414+
### wakuext_threadMessages
415+
416+
Read message history for a single thread.
417+
418+
**Params:** `[chatId, threadId, cursor, limit]`
419+
- `chatId`: string (communityId + chatUUID)
420+
- `threadId`: string (thread identifier; currently parent message ID)
421+
- `cursor`: string (empty string for first page)
422+
- `limit`: number
423+
424+
**Result:**
425+
```json
426+
{
427+
"messages": [
428+
{
429+
"id": "0x...",
430+
"chatId": "0xcommunity...<chatUUID>",
431+
"threadId": "0x-parent-message-id",
432+
"text": "reply text"
433+
}
434+
],
435+
"cursor": "<next-cursor-or-empty>"
436+
}
437+
```
438+
439+
---
440+
349441
### wakuext_leaveCommunity
350442

351443
Leave a community.

protocol/activity_center_persistence_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ func (s *ActivityCenterPersistenceTestSuite) Test_DeleteActivityCenterNotificati
152152
err = p.SaveChat(*chat)
153153
s.Require().NoError(err)
154154

155-
chatMessages, _, err := p.MessageByChatID(chat.ID, "", 2)
155+
chatMessages, _, err := p.MessageByChatID(chat.ID, "", "", 2)
156156
s.Require().NoError(err)
157157
s.Require().Len(chatMessages, 2)
158158

@@ -268,7 +268,7 @@ func (s *ActivityCenterPersistenceTestSuite) Test_DeleteActivityCenterNotificati
268268
err = p.SaveChat(*chat)
269269
s.Require().NoError(err)
270270

271-
chatMessages, _, err := p.MessageByChatID(chat.ID, "", 2)
271+
chatMessages, _, err := p.MessageByChatID(chat.ID, "", "", 2)
272272
s.Require().NoError(err)
273273
s.Require().Len(chatMessages, 2)
274274

protocol/common/feature_flags.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,7 @@ type FeatureFlags struct {
1414

1515
// EnableMercuryoProvider indicates whether we should enable the Mercuryo provider in the Wallet
1616
EnableMercuryoProvider bool
17+
18+
// Threads indicates whether thread-specific behavior is enabled.
19+
Threads bool
1720
}

protocol/common/message.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ func (m *Message) MarshalJSON() ([]byte, error) {
245245
BridgeMessage *protobuf.BridgeMessage `json:"bridgeMessage,omitempty"`
246246
PaymentRequests []*protobuf.PaymentRequest `json:"paymentRequests,omitempty"`
247247
PinnedBy string `json:"pinnedBy,omitempty"`
248+
ThreadID string `json:"threadId,omitempty"`
248249
}
249250
item := MessageStructType{
250251
ID: m.ID,
@@ -287,6 +288,7 @@ func (m *Message) MarshalJSON() ([]byte, error) {
287288
ContactVerificationState: m.ContactVerificationState,
288289
PaymentRequests: m.PaymentRequests,
289290
PinnedBy: m.PinnedBy,
291+
ThreadID: m.GetThreadId(),
290292
}
291293

292294
if sticker := m.GetSticker(); sticker != nil {
@@ -336,6 +338,7 @@ func (m *Message) UnmarshalJSON(data []byte) error {
336338
EnsName string `json:"ensName"`
337339
DisplayName string `json:"displayName"`
338340
ChatID string `json:"chatId"`
341+
ThreadID string `json:"threadId"`
339342
Sticker *protobuf.StickerMessage `json:"sticker"`
340343
AudioDurationMs uint64 `json:"audioDurationMs"`
341344
ParsedText json.RawMessage `json:"parsedText"`
@@ -383,6 +386,9 @@ func (m *Message) UnmarshalJSON(data []byte) error {
383386
m.From = aux.From
384387
m.Deleted = aux.Deleted
385388
m.DeletedForMe = aux.DeletedForMe
389+
if aux.ThreadID != "" {
390+
m.ThreadId = &aux.ThreadID
391+
}
386392
return nil
387393
}
388394

protocol/communities/community.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,8 @@ func New(config Config, timesource common.TimeSource, encryptor DescriptionEncry
140140
}
141141

142142
type CommunityAdminSettings struct {
143-
PinMessageAllMembersEnabled bool `json:"pinMessageAllMembersEnabled"`
143+
PinMessageAllMembersEnabled bool `json:"pinMessageAllMembersEnabled"`
144+
CreateThreadAllMembersEnabled bool `json:"createThreadAllMembersEnabled"`
144145
}
145146

146147
type CommunityChat struct {
@@ -283,11 +284,13 @@ func (o *Community) MarshalPublicAPIJSON() ([]byte, error) {
283284
}
284285

285286
communityItem.CommunityAdminSettings = CommunityAdminSettings{
286-
PinMessageAllMembersEnabled: false,
287+
PinMessageAllMembersEnabled: false,
288+
CreateThreadAllMembersEnabled: false,
287289
}
288290

289291
if o.config.CommunityDescription.AdminSettings != nil {
290292
communityItem.CommunityAdminSettings.PinMessageAllMembersEnabled = o.config.CommunityDescription.AdminSettings.PinMessageAllMembersEnabled
293+
communityItem.CommunityAdminSettings.CreateThreadAllMembersEnabled = o.config.CommunityDescription.AdminSettings.CreateThreadAllMembersEnabled
291294
}
292295
}
293296
return json.Marshal(communityItem)
@@ -444,11 +447,13 @@ func (o *Community) MarshalJSON() ([]byte, error) {
444447
}
445448

446449
communityItem.CommunityAdminSettings = CommunityAdminSettings{
447-
PinMessageAllMembersEnabled: false,
450+
PinMessageAllMembersEnabled: false,
451+
CreateThreadAllMembersEnabled: false,
448452
}
449453

450454
if o.config.CommunityDescription.AdminSettings != nil {
451455
communityItem.CommunityAdminSettings.PinMessageAllMembersEnabled = o.config.CommunityDescription.AdminSettings.PinMessageAllMembersEnabled
456+
communityItem.CommunityAdminSettings.CreateThreadAllMembersEnabled = o.config.CommunityDescription.AdminSettings.CreateThreadAllMembersEnabled
452457
}
453458
}
454459
return json.Marshal(communityItem)
@@ -1164,6 +1169,7 @@ func (o *Community) Edit(description *protobuf.CommunityDescription) {
11641169
}
11651170
o.config.CommunityDescription.Permissions = description.Permissions
11661171
o.config.CommunityDescription.AdminSettings.PinMessageAllMembersEnabled = description.AdminSettings.PinMessageAllMembersEnabled
1172+
o.config.CommunityDescription.AdminSettings.CreateThreadAllMembersEnabled = description.AdminSettings.CreateThreadAllMembersEnabled
11671173
}
11681174

11691175
func (o *Community) EditPermissionAccess(permissionAccess protobuf.CommunityPermissions_Access) {
@@ -2396,6 +2402,10 @@ func (o *Community) AllowsAllMembersToPinMessage() bool {
23962402
return o.config.CommunityDescription.AdminSettings != nil && o.config.CommunityDescription.AdminSettings.PinMessageAllMembersEnabled
23972403
}
23982404

2405+
func (o *Community) AllowsAllMembersToCreateThread() bool {
2406+
return o.config.CommunityDescription.AdminSettings != nil && o.config.CommunityDescription.AdminSettings.CreateThreadAllMembersEnabled
2407+
}
2408+
23992409
func (o *Community) CreateDeepCopy() *Community {
24002410
return &Community{
24012411
encryptor: o.encryptor,

protocol/communities_messenger_token_permissions_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -502,7 +502,7 @@ func (s *MessengerCommunitiesTokenPermissionsSuite) TestBecomeMemberPermissions(
502502
s.Require().Len(response.Messages(), 1)
503503
s.Require().Equal(msg.Text, response.Messages()[0].Text)
504504

505-
bobMessages, _, err := s.bob.MessageByChatID(msg.ChatId, "", 10)
505+
bobMessages, _, err := s.bob.MessageByChatID(msg.ChatId, "", "", 10)
506506
s.Require().NoError(err)
507507
s.Require().Len(bobMessages, 1)
508508
s.Require().Equal(messages[0], bobMessages[0].Text)
@@ -591,12 +591,12 @@ func (s *MessengerCommunitiesTokenPermissionsSuite) TestBecomeMemberPermissions(
591591
s.bob,
592592
func(r *MessengerResponse) bool {
593593
// Bob should have all 3 messages
594-
bobMessages, _, err = s.bob.MessageByChatID(msg.ChatId, "", 10)
594+
bobMessages, _, err = s.bob.MessageByChatID(msg.ChatId, "", "", 10)
595595
return err == nil && len(bobMessages) == 3
596596
},
597597
"not all 3 messages received",
598598
)
599-
bobMessages, _, err = s.bob.MessageByChatID(msg.ChatId, "", 10)
599+
bobMessages, _, err = s.bob.MessageByChatID(msg.ChatId, "", "", 10)
600600
for _, m := range bobMessages {
601601
fmt.Printf("ID: %s\n", m.ID)
602602
}

0 commit comments

Comments
 (0)