@@ -9,7 +9,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel
99import io.github.trevarj.motd.data.db.BufferEntity
1010import io.github.trevarj.motd.data.db.MemberEntity
1111import io.github.trevarj.motd.data.db.MessageEntity
12- import io.github.trevarj.motd.data.db.ReactionEntity
1312import io.github.trevarj.motd.data.repo.BufferRepository
1413import io.github.trevarj.motd.data.repo.LinkPreview
1514import io.github.trevarj.motd.data.repo.LinkPreviewRepository
@@ -25,17 +24,15 @@ import io.github.trevarj.motd.service.TypingTracker
2524import io.github.trevarj.motd.ui.nav.ChatRoute
2625import androidx.navigation.toRoute
2726import kotlinx.coroutines.flow.Flow
28- import kotlinx.coroutines.flow.MutableSharedFlow
2927import kotlinx.coroutines.flow.MutableStateFlow
30- import kotlinx.coroutines.flow.SharedFlow
3128import kotlinx.coroutines.flow.SharingStarted
3229import kotlinx.coroutines.flow.StateFlow
33- import kotlinx.coroutines.flow.asSharedFlow
3430import kotlinx.coroutines.flow.asStateFlow
3531import kotlinx.coroutines.flow.combine
3632import kotlinx.coroutines.flow.firstOrNull
3733import kotlinx.coroutines.flow.stateIn
3834import kotlinx.coroutines.launch
35+ import io.github.trevarj.motd.ui.components.ReactionChip
3936import java.time.Instant
4037import javax.inject.Inject
4138
@@ -51,6 +48,14 @@ data class ChatState(
5148 val connState : IrcClientState = IrcClientState .Disconnected ,
5249)
5350
51+ /* *
52+ * Wire text for resending a failed row. An ACTION is stored with its `/me ` prefix stripped, so
53+ * re-prefix it; the manager rewrites `/me ` back into a CTCP ACTION. Non-ACTION kinds resend
54+ * verbatim (plans/15 #10).
55+ */
56+ fun resendText (kind : io.github.trevarj.motd.data.db.MessageKind , text : String ): String =
57+ if (kind == io.github.trevarj.motd.data.db.MessageKind .ACTION ) " /me $text " else text
58+
5459@HiltViewModel
5560class ChatViewModel @Inject constructor(
5661 private val savedStateHandle : SavedStateHandle ,
@@ -95,9 +100,38 @@ class ChatViewModel @Inject constructor(
95100 )
96101 }.stateIn(viewModelScope, SharingStarted .WhileSubscribed (5_000 ), ChatState ())
97102
98- /* * Reactions for the currently visible msgids; the screen supplies the id set. */
99- fun reactions (msgids : List <String >): Flow <List <ReactionEntity >> =
100- messageRepository.reactions(bufferId, msgids)
103+ // --- reactions aggregation (plans/15 #5, #18) ---
104+
105+ /* * Msgids currently visible in the paging window; the screen keeps this current. */
106+ private val visibleMsgids = MutableStateFlow <List <String >>(emptyList())
107+
108+ fun setVisibleMsgids (ids : List <String >) {
109+ if (visibleMsgids.value != ids) visibleMsgids.value = ids
110+ }
111+
112+ /* *
113+ * Reaction chips keyed by msgid, aggregated in the VM so the value survives across message
114+ * arrivals (no blank frame from an emptyList re-seed) and picks up echo-confirm msgid swaps as
115+ * soon as [visibleMsgids] changes. Buffer-scoped reactions avoid the SQLite IN(...) overflow
116+ * (plans/15 #5); we filter to the visible window here.
117+ */
118+ val reactionChips: StateFlow <Map <String , List <ReactionChip >>> = combine(
119+ messageRepository.reactionsForBuffer(bufferId),
120+ visibleMsgids,
121+ connState,
122+ ) { all, visible, conn ->
123+ val visibleSet = visible.toHashSet()
124+ val relevant = all.filter { it.targetMsgid in visibleSet }
125+ val myNick = (conn as ? IrcClientState .Ready )?.nick
126+ aggregateReactions(relevant, myNick, nickNormalizer())
127+ }.stateIn(viewModelScope, SharingStarted .WhileSubscribed (5_000 ), emptyMap())
128+
129+ // --- read marker snapshot (plans/15 #2) ---
130+
131+ // Frozen on buffer entry so the "— New messages —" divider and the FAB unread badge keep a
132+ // stable boundary instead of flashing/vanishing as markRead advances the live marker.
133+ private val _readMarkerSnapshot = MutableStateFlow <Long ?>(null )
134+ val readMarkerSnapshot: StateFlow <Long ?> = _readMarkerSnapshot .asStateFlow()
101135
102136 // --- lifecycle: foreground tracker + mark-read (plans/07) ---
103137
@@ -133,8 +167,19 @@ class ChatViewModel @Inject constructor(
133167 connectionManager.sendReact(bufferId, msgid, emoji)
134168 }
135169
170+ /* *
171+ * Retry a failed message: drop the old failed row first (no permanent duplicate), then resend.
172+ * An ACTION is stored with its display text stripped of the `/me ` prefix, so re-prefix it to
173+ * resend as an ACTION rather than a plain PRIVMSG (plans/15 #10).
174+ */
136175 fun retry (message : MessageEntity ) = viewModelScope.launch {
137- connectionManager.sendMessage(bufferId, message.text, message.replyToMsgid)
176+ messageRepository.deleteMessage(message.id)
177+ connectionManager.sendMessage(bufferId, resendText(message.kind, message.text), message.replyToMsgid)
178+ }
179+
180+ /* * Delete a failed local row without resending (action-sheet delete affordance, plans/15 #10). */
181+ fun deleteFailed (message : MessageEntity ) = viewModelScope.launch {
182+ messageRepository.deleteMessage(message.id)
138183 }
139184
140185 suspend fun linkPreview (url : String ): LinkPreview ? = linkPreviewRepository.preview(url)
@@ -214,24 +259,47 @@ class ChatViewModel @Inject constructor(
214259 /* * Resolved jump target (index + optional highlight msgid); null when nothing to jump to. */
215260 val jumpTarget: StateFlow <ChatJumpResolver .Result .Target ?> = _jumpTarget .asStateFlow()
216261
217- private val _jumpFailed = MutableSharedFlow <Unit >(extraBufferCapacity = 1 )
218- /* * Emits when a jump could not be resolved (cap miss / not loaded) → snackbar. */
219- val jumpFailed: SharedFlow <Unit > = _jumpFailed .asSharedFlow()
262+ // Nullable-event StateFlow instead of a replay-less SharedFlow so a NotFound resolved in init
263+ // (before the screen subscribes) is not dropped; the UI clears it via [onJumpFailedShown]
264+ // (plans/15 #13).
265+ private val _jumpFailed = MutableStateFlow (false )
266+ val jumpFailed: StateFlow <Boolean > = _jumpFailed .asStateFlow()
267+
268+ // Re-resolve is allowed exactly once per navigation; a second index shift falls through to the
269+ // not-loaded snackbar rather than looping (plans/15 #12).
270+ private var reresolveUsed = false
220271
221272 init {
222273 if (jumpTime > 0 && savedStateHandle.get<Boolean >(JUMP_CONSUMED_KEY ) != true ) {
223274 savedStateHandle[JUMP_CONSUMED_KEY ] = true
224275 resolveJump()
225276 }
277+ // Freeze the read-marker once, on the first buffer emission, so the unread divider/badge
278+ // stay put instead of collapsing as markRead advances the live marker (plans/15 #2).
279+ viewModelScope.launch {
280+ _readMarkerSnapshot .value = bufferRepository.observeBuffer(bufferId).firstOrNull()?.readMarkerTime
281+ }
226282 }
227283
228284 private fun resolveJump () = viewModelScope.launch {
229285 // The buffer name (chathistory target) may not be in `state` yet on first composition;
230286 // read it directly from the repo so the AROUND fallback has a target.
231287 val name = bufferRepository.observeBuffer(bufferId).firstOrNull()?.name
288+ publishResolve(name)
289+ }
290+
291+ private suspend fun publishResolve (name : String? ) {
232292 when (val r = resolver.resolve(bufferId, jumpMsgid, jumpTime, name)) {
233- is ChatJumpResolver .Result .Target -> _jumpTarget .value = r
234- ChatJumpResolver .Result .NotFound -> _jumpFailed .tryEmit(Unit )
293+ is ChatJumpResolver .Result .Target -> {
294+ // Force a distinct emission so the screen's LaunchedEffect(jumpTarget) always
295+ // re-runs, even when the re-resolved index equals the previous one (plans/15 #12).
296+ _jumpTarget .value = null
297+ _jumpTarget .value = r
298+ }
299+ ChatJumpResolver .Result .NotFound -> {
300+ _jumpTarget .value = null
301+ _jumpFailed .value = true
302+ }
235303 }
236304 }
237305
@@ -240,16 +308,24 @@ class ChatViewModel @Inject constructor(
240308 _jumpTarget .value = null
241309 }
242310
243- /* * Re-resolve the same target once when a live message shifted indices mid-jump. */
311+ /* * Screen calls this after showing the not-loaded snackbar so it does not re-fire. */
312+ fun onJumpFailedShown () {
313+ _jumpFailed .value = false
314+ }
315+
316+ /* *
317+ * Re-resolve the same target once when a live message shifted indices mid-jump. The single-shot
318+ * guard means the screen can always call [onJumpHandled] after it; a repeat request just clears
319+ * the target so the not-loaded path takes over (plans/15 #12).
320+ */
244321 fun reresolveJumpOnce () = viewModelScope.launch {
245- val name = state.value.buffer?.name
246- when (val r = resolver.resolve(bufferId, jumpMsgid, jumpTime, name)) {
247- is ChatJumpResolver .Result .Target -> _jumpTarget .value = r
248- ChatJumpResolver .Result .NotFound -> {
249- _jumpTarget .value = null
250- _jumpFailed .tryEmit(Unit )
251- }
322+ if (reresolveUsed) {
323+ _jumpTarget .value = null
324+ _jumpFailed .value = true
325+ return @launch
252326 }
327+ reresolveUsed = true
328+ publishResolve(state.value.buffer?.name)
253329 }
254330
255331 /* *
0 commit comments