Skip to content

M4-20 Thread view, input and message actions

No Date Ruling Reason
D-M4-20-0 2026-10-18 Base int/m1-wave40 at 35d6a9c, branch feat/M4-20-thread-view in its own worktree; not merged locally and not pushed, the PR lane lands it. The brief relies on no core rule or ruling that main has and the base lacks (D-W-100, D-W-114, D-W-115, SEC-OUT-1 to SEC-OUT-3, SEC-T13 and the actions of 05 are on the base), so nothing is ported; the base names the chapters in German (01-produkt.md, 04-sync-protokoll.md, 15-client-architektur.md). Differences to the brief's names, following the merged code: the outbox view is OutboxEntryView with OutboxStateLabel; the executors register through the registerExecutors hook of SyncEngine.create; the app's executors are AppMessageOps in sync/message_ops_app.dart and the thread from a message is a payload form of ThreadOps.createThread; the composer widget is composer/thread_composer.dart next to the brief's files, the REST reads are thread/thread_reads.dart, the rows are built by thread/history_model.dart, the D2 layout by thread/history_area.dart. Instructions of the launcher; merged names win over the brief (D-W-100, D-W-114).
D-M4-20-1 2026-10-18 Read state: while the newest confirmed message of a thread without a gap after it is in view and the window has the focus (AppLifecycle in the foreground and Flutter's state resumed, not inactive), the history writes putReadState with its thread_seq through the outbox, at most once per newest message; Esc in the history writes it regardless of the scroll position. After "Mark as unread" the view does not mark read again on its own until a newer message arrives; Esc compares with the stored position only. The app's putReadState executor stores the answer, and markThreadUnread takes the stored version when it is sent, so an own putReadState ahead of it in the lane does not make its If-Match stale; a second edit of a message joins the first that is not confirmed (Outbox.edit). The focus of the gateway is the set of threads whose history shows (VisibleThreads), idle while the window is in the background. Brief Design note 2; 04 14 (the server keeps the maximum, so a repeated write is harmless but not needed).
D-M4-20-2 2026-10-18 Drafts: every change of the field updates the one putThreadDraft entry that has not been attempted (04 13.3: in place) or enqueues one, so one write is in flight and one waits with the newest text, without a timer; sending drops the waiting write (clear_draft deletes the draft on the server). The composer marks its draft as edited (DraftEditingRegistry, the DraftEditing port of M4-14) while its field has the focus and the person changed the text, and takes a stored draft of another device only otherwise, and only while no own draft write is unconfirmed (its answer after a send must not bring the sent text back); the base version is taken over only with the text. A composer that opens again takes the text of an unconfirmed draft write from the outbox. The app registers its own putThreadDraft executor, which hands the DraftPutResult to CatchUpListener.applyDraftPutResult, so overwrote_newer shows the notice. Brief Design note 3; 04 14.
D-M4-20-3 2026-10-18 Edit conflict (412 version_conflict): the failed entry shows "Compare", which opens both versions; "Keep mine" edits the failed entry (Outbox.edit: a new entry on the version of the kept current), "Take theirs" discards it. An edit of a deleted message (410) keeps its text with "Copy" and "Discard". No automatic merge. Brief Design note 4; 04 13.3.
D-M4-20-4 2026-10-18 Actions follow the server's answer, never grants: the level and capabilities of the stored ChannelView and the channel's read_only map to the actions by the table of 05 (message.create, reaction.add, thread.create, thread.close, thread.reopen: level write or trigger; message.update: write and author; message.delete: write and author, or manage; pin.* and thread.move: manage; thread.title.update: write and creator or manage; message.revisions.read: read, offered for edited messages). The server checks every write again. Brief Design note 4; 15 1 principle 1.
D-M4-20-5 2026-10-18 The header button "Pin" shows the session column with its section "Pinned"; it writes nothing, because no operation pins a thread (03 has pins of messages). The session column shows the pinned messages of the open thread from the local pinned flag of the messages (MessagesDao.watchPinned); opening the section reads listPins once and stores the pinned messages that are not stored yet (getMessage), so pins outside the loaded history show too. Brief Design note 5; no contract change (P-11).
D-M4-20-6 2026-10-18 D2: a thread of a text channel shows to the right of the channel's main history in the history column, and the session column is collapsed until the person shows it ("Column", "Pin"); the area keeps the main history it showed when a removal deletes the rows, so both columns close with "No access anymore"; a channel route of a text channel shows its main history; the main history shows "Thread: title, n replies" under a message that started a thread. 01 5.1 D2.
D-M4-20-7 2026-10-18 Fixes outside the app that the thread view needs: drift shares query streams of equal SQL, and the trigger statements of watchThread, watchForChannel and watchContiguous were all SELECT 1, so with a post list open the history did not show new messages or loaded ranges; each now has its own statement text. CatchUpListener gets loadOlderMessages and loadMessageRange (paging up and the jump), DsDelivery.delayed shows "Sending delayed", and DsMarkdown styles a mention token only for the handles of mention_ids (03 content format item 4). Reproduced in a test of local_store; the brief's history needs paging and the delayed state.
D-M4-20-8 2026-10-18 The context menu of a message opens on the raw right click (Listener), not through a gesture recognizer, because a recognizer of the content won the arena after the first menu and the menu no longer opened. Reproduced in the integration test.
D-M4-20-9 2026-10-20 Port to main: the ticket commits from 35d6a9c to 9ba1a62 on top of M4-19, M4-25, M4-26, M4-27, M4-29, M4-37 and M3-13; the fake server registers the thread view handlers next to the admin, access, audit and host handlers, world.dart keeps the host import next to the thread view part file, the l10n catalogues keep the keys of main plus the keys of this ticket with regenerated localizations, the contract test declares final world = h.server.world; once (the admin part further down uses it), and the chapter reference of thread/providers.dart names 15-client-architecture.md. The admin part of M4-26 and the thread view part of this ticket each declared the same variable in one test body, which no longer compiled (duplicate_definition); the integration branch fixed it the same way.