Repository: okTurtles/group-income
Pull request #2753: #2712 - Implement 'mark message as unread' feature in the chatroom
Author: login: SebinSong
Created: 2025-04-17T02:49:38Z | Merged: 2025-04-28T18:26:45Z

closes #2712 

**[Video demo for the feature]**
As you can see, once 'mark unread' operation is processed, it is immediately reflected in the UI(eg. 'is-new' blue divider and also notification badge **if** there are any message where the user is mentioned). This state does not disappear until the user leaves and re-enters the chatroom. Hope it sounds good.

https://github.com/user-attachments/assets/7d785a4d-a344-4894-9e7d-71650de26632

Please let me know if we would love to change the icon to something else BTW:

<img src='https://github.com/user-attachments/assets/b166914e-98d3-44eb-8b70-f860f7f47627' width='280'>



Linked issue #2712: Mark messages as unread menu item
### Problem

Similar to how Slack and other programs let you mark messages as unread, it would be nice if we had that feature too.

### Solution

Add a menu item to chatrooms that lets you mark a message as unread, and every message from that point on will be unread.

Diff:
diff --git a/frontend/controller/actions/identity-kv.js b/frontend/controller/actions/identity-kv.js
index d4a4db5e90..100017b943 100644
--- a/frontend/controller/actions/identity-kv.js
+++ b/frontend/controller/actions/identity-kv.js
@@ -115,6 +115,37 @@ export default (sbp('sbp/selectors/register', {
       }
     })
   },
+  'gi.actions/identity/kv/markAsUnread': ({ contractID, messageHash, createdHeight, unreadMessages }: {
+    contractID: string,
+    messageHash: string,
+    createdHeight: number,
+    unreadMessages: Array<{ messageHash: string, createdHeight: number }>
+  }) => {
+    return sbp('okTurtles.eventQueue/queueEvent', KV_QUEUE, async () => {
+      const getUpdatedUnreadMessages = async () => {
+        const currentData = await sbp('gi.actions/identity/kv/fetchChatRoomUnreadMessages')
+        const existingReadUntil = currentData[contractID]?.readUntil
+
+        // If the requested mark-unread hash has already been set, ignore it.
+        if (existingReadUntil &&
+          existingReadUntil.isManuallyMarked &&
+          existingReadUntil?.messageHash === messageHash) { return null }
+
+        return {
+          ...currentData,
+          [contractID]: {
+            readUntil: { messageHash, createdHeight, isManuallyMarked: true },
+            unreadMessages
+          }
+        }
+      }
+
+      const data = await getUpdatedUnreadMessages()
+      if (data) {
+        await sbp('gi.actions/identity/kv/saveChatRoomUnreadMessages', { data, onconflict: getUpdatedUnreadMessages })
+      }
+    })
+  },
   'gi.actions/identity/kv/addChatRoomUnreadMessage': ({ contractID, messageHash, createdHeight }: {
     contractID: string, messageHash: string, createdHeight: number
   }) => {
@@ -145,7 +176,7 @@ export default (sbp('sbp/selectors/register', {
       const getUpdatedUnreadMessages = async () => {
         const currentData = await sbp('gi.actions/identity/kv/fetchChatRoomUnreadMessages')
 
-        const index = currentData[contractID]?.unreadMessages.findIndex(msg => msg.messageHash === messageHash)
+        const index = currentData[contractID]?.unreadMessages?.findIndex(msg => msg.messageHash === messageHash)
         // NOTE: index could be undefined if unreadMessages is not initialized
         if (Number.isInteger(index) && index >= 0) {
           currentData[contractID].unreadMessages.splice(index, 1)
diff --git a/frontend/model/chatroom/getters.js b/frontend/model/chatroom/getters.js
index 901ab045e9..995feef02e 100644
--- a/frontend/model/chatroom/getters.js
+++ b/frontend/model/chatroom/getters.js
@@ -142,6 +142,11 @@ const getters: { [x: string]: (state: Object, getters: { [x: string]: any }, roo
       return getters.ourUnreadMessages[chatRoomID]?.unreadMessages || []
     }
   },
+  isChatRoomManuallyMarkedUnread (state, getters) {
+    return (chatroomID: string) => {
+      return Boolean(getters.ourUnreadMessages[chatroomID || getters.currentChatRoomId]?.readUntil?.isManuallyMarked)
+    }
+  },
   groupUnreadMessages (state, getters, rootState) {
     return (groupID: string) => {
       const isGroupDirectMessage = cID => Object.keys(getters.directMessagesByGroup(groupID)).includes(cID)
diff --git a/frontend/model/chatroom/vuexModule.js b/frontend/model/chatroom/vuexModule.js
index 40a218d3dd..e74f248586 100644
--- a/frontend/model/chatroom/vuexModule.js
+++ b/frontend/model/chatroom/vuexModule.js
@@ -9,12 +9,10 @@ const defaultState = {
   currentChatRoomIDs: {}, // { [groupId]: currentChatRoomId }
   pendingChatRoomIDs: {}, // { [groupId]: currentChatRoomId }
   chatRoomScrollPosition: {}, // [chatRoomID]: messageHash
-  unreadMessages: null, // [chatRoomID]: { readUntil: { messageHash, createdHeight }, unreadMessages: [{ messageHash, createdHeight }]}
+  unreadMessages: null, // [chatRoomID]: { readUntil: { messageHash, createdHeight, isManuallyMarked?: boolean }, unreadMessages: [{ messageHash, createdHeight }]}
   chatNotificationSettings: {} // { [chatRoomID]: { messageNotification: MESSAGE_NOTIFY_SETTINGS, messageSound: MESSAGE_NOTIFY_SETTINGS } }
 }
 
-// getters
-
 // mutations
 const mutations = {
   setCurrentChatRoomId (state, { groupID, chatRoomID }) {
diff --git a/frontend/views/containers/chatroom/ChatMain.vue b/frontend/views/containers/chatroom/ChatMain.vue
index 824c12af9d..92a3c1f010 100644
--- a/frontend/views/containers/chatroom/ChatMain.vue
+++ b/frontend/views/containers/chatroom/ChatMain.vue
@@ -48,7 +48,6 @@
             :name='summary.title'
             :description='summary.attributes.description'
           )
-
       template(v-for='(message, index) in messages')
         .c-divider(
           v-if='changeDay(index) || isNew(message.hash)'
@@ -156,7 +155,7 @@ import { proximityDate, MINS_MILLIS } from '@model/contracts/shared/time.js'
 import { cloneDeep, debounce, throttle, delay } from 'turtledash'
 import { EVENT_HANDLED } from '~/shared/domains/chelonia/events.js'
 import { compressImage } from '@utils/image.js'
-import { swapMentionIDForDisplayname } from '@model/chatroom/utils.js'
+import { swapMentionIDForDisplayname, makeMentionFromUserID } from '@model/chatroom/utils.js'
 
 const ignorableScrollDistanceInPixel = 500
 
@@ -254,6 +253,8 @@ export default ({
       latestEvents: [],
       ephemeral: {
         startedUnreadMessageHash: null,
+        // Below contains the message hash on which the user manually marked as unread
+        messageHashToMarkUnread: null,
         scrollableDistance: 0,
         onChatScroll: null,
         infiniteLoading: null,
@@ -961,32 +962,31 @@ export default ({
       this.ephemeral.startedUnreadMessageHash = null
       if (this.currentChatRoomReadUntil) {
         const index = this.messages.findIndex(msg => msg.height > this.currentChatRoomReadUntil.createdHeight)
+
         if (index >= 0) {
-          // NOTE: When the user switches channel before the message is not fully processed,
-          //       (in other words, until this.variant(msg) === 'sent')
-          //       the chatroomReadUntil position would not be saved correctly
-          //       because the pending messages could not be saved in state.
-          //       Considering those such cases, we shoud set 'isNew' position for the messages
-          //       only whose sender is not ourselves.
-          for (let i = index; i < this.messages.length; i++) {
-            const message = this.messages[i]
-            if (!this.isMsgSender(message.from)) {
-              this.ephemeral.startedUnreadMessageHash = message.hash
-              break
-            }
-          }
+          this.ephemeral.startedUnreadMessageHash = this.messages[index].hash
         }
       }
     },
-    updateReadUntilMessageHash ({ messageHash, createdHeight, forceUpdate = false }) {
+    updateReadUntilMessageHash ({
+      messageHash,
+      createdHeight,
+      // 'forceUpdate' flag here is for the rare case where the 'readUntil' value needs to be set to the msg with lower 'createdHeight'.
+      // eg. when the latest message is deleted. (reference: https://github.com/okTurtles/group-income/issues/2729)
+      forceUpdate = false
+    }) {
+      if (this.ephemeral.messageHashToMarkUnread) {
+        // 'Mark unread' feature allows user to set 'currentChatRoomReadUntil' to the message they want.
+        // So if user has used this functionality at least once in the current chatroom,
+        // the chatroom should stop auto-updating the 'readUntil' data in various situations (eg. while scrolling),
+        // So that the message that has been marked unread is kept until user leaves and re-enter the chatroom in the future.
+        return
+      }
+
       const chatRoomID = this.ephemeral.renderingChatRoomId
       if (chatRoomID && this.isJoinedChatRoom(chatRoomID)) {
-        if (!forceUpdate && this.currentChatRoomReadUntil?.createdHeight >= createdHeight) {
-          // NOTE-1: skip adding useless invocations in KV_QUEUE queue.
-          // NOTE-2: 'forceUpdate' flag here is for the rare case where the 'readUntil' value needs to be set to the msg with lower 'createdHeight'.
-          //         eg. when the latest message is deleted. (reference: https://github.com/okTurtles/group-income/issues/2729)
-          return
-        }
+        // NOTE: skip adding useless invocations in KV_QUEUE queue.
+        if (!forceUpdate && this.currentChatRoomReadUntil?.createdHeight >= createdHeight) { return }
 
         sbp('gi.actions/identity/kv/setChatRoomReadUntil', {
           contractID: chatRoomID, messageHash, createdHeight, forceUpdate
@@ -995,6 +995,63 @@ export default ({
         })
       }
     },
+    async markAsUnread ({ messageHash, createdHeight }) {
+      const chatRoomID = this.ephemeral.renderingChatRoomId
+      if (!chatRoomID || !this.isJoinedChatRoom(chatRoomID)) { return }
+
+      const index = this.messages.findIndex(msg => msg.hash === messageHash)
+      const isFirstMessage = index === 0
+      const targetMsg = isFirstMessage ? this.messages[index] : this.messages[index - 1]
+
+      const getUpdatedUnreadMessages = () => {
+        // This method filters the messages to store in 'unreadMessages' property (eg. messages that mentions me or contains '@all'),
+        // which are reflected as the unread-count badge in the UI.
+        const isInDM = this.isGroupDirectMessage(this.ephemeral.renderingChatRoomId)
+        const mentions = makeMentionFromUserID(this.ourIdentityContractId)
+        const messageMentionsMe = msg => {
+          return msg.type === MESSAGE_TYPES.TEXT &&
+            msg.text &&
+            (msg.text.includes(mentions.me) || msg.text.includes(mentions.all))
+        }
+
+        return this.messages.slice(index)
+          .filter(msg => {
+            if (this.isMsgSender(msg.from)) { return false }
+
+            return isInDM ||
+              [MESSAGE_TYPES.INTERACTIVE, MESSAGE_TYPES.POLL].includes(msg.type) ||
+              messageMentionsMe(msg)
+          }).map(msg => ({ messageHash: msg.hash, createdHeight: msg.height }))
+      }
+
+      try {
+        this.ephemeral.messageHashToMarkUnread = targetMsg.hash
+        await sbp('gi.actions/identity/kv/markAsUnread', {
+          contractID: chatRoomID,
+          messageHash: targetMsg.hash,
+          // NOTE: 'createdHeight' field stores the 'msg.height' value of the previous message of the target message and
+          //       then later used in the UI to determine on which message to display 'is-new' UI Element [1].
+          //       But in the case where the target is the first message of the chatroom, meaning there is no previous message,
+          //       we need to manually specify the decremented 'createdHeight' value here, so that [1] above does not break in the UI.
+          createdHeight: isFirstMessage ? createdHeight - 1 : targetMsg.height,
+          // When marked as unread, all the messages after the target message are stored in 'unreadMessages' property.
+          unreadMessages: getUpdatedUnreadMessages()
+        })
+      } catch (e) {
+        console.error('[ChatMain.vue] Error while marking message unread', e)
+        this.ephemeral.messageHashToMarkUnread = null
+      }
+    },
+    markUnreadPostActions () {
+      this.ephemeral.startedUnreadMessageHash = null
+      if (this.currentChatRoomReadUntil) {
+        const foundIndex = this.messages.findIndex(msg => msg.height > this.currentChatRoomReadUntil.createdHeight)
+
+        if (foundIndex >= 0) {
+          this.ephemeral.startedUnreadMessageHash = this.messages[foundIndex].hash
+        }
+      }
+    },
     listenChatRoomActions (contractID: string, message?: SPMessage) {
       if (this.chatroomHasSwitchedFrom(contractID)) return
 
@@ -1102,6 +1159,17 @@ export default ({
               }
             }
           }
+
+          if (addedOrDeleted !== 'NONE' && this.ephemeral.messageHashToMarkUnread) {
+            // If user has used 'Mark unread' but then messages are either added or deleted,
+            // 'unreadMessages' data in the store should be updated accordingly.
+            const action = addedOrDeleted === 'ADDED' ? 'addChatRoomUnreadMessage' : 'removeChatRoomUnreadMessage'
+            sbp(`gi.actions/identity/kv/${action}`, {
+              contractID: this.ephemeral.renderingChatRoomId,
+              messageHash: value.data.hash,
+              createdHeight: value.data.height
+            })
+          }
         })
       })
     },
@@ -1268,7 +1336,10 @@ export default ({
   },
   provide () {
     return {
-      chatMainConfig: this.config
+      chatMainConfig: this.config,
+      chatMainUtils: {
+        markAsUnread: this.markAsUnread
+      }
     }
   },
   watch: {
@@ -1290,10 +1361,17 @@ export default ({
         // Prevent the infinite scroll handler from rendering more messages
         this.ephemeral.messagesInitiated = undefined
         this.ephemeral.scrollableDistance = 0
+        this.ephemeral.messageHashToMarkUnread = null
         this.ephemeral.chatroomIdToSwitchTo = toChatRoomId
 
         sbp('chelonia/queueInvocation', toChatRoomId, () => initAfterSynced(toChatRoomId))
       }
+    },
+    'currentChatRoomReadUntil' (newReadUntil) {
+      const msgHash = newReadUntil?.messageHash
+      if (msgHash && msgHash === this.ephemeral.messageHashToMarkUnread) {
+        this.$nextTick(this.markUnreadPostActions)
+      }
     }
   }
 }: Object)
diff --git a/frontend/views/containers/chatroom/ChatMembers.vue b/frontend/views/containers/chatroom/ChatMembers.vue
index 89abab3aeb..dc2333e0c6 100644
--- a/frontend/views/containers/chatroom/ChatMembers.vue
+++ b/frontend/views/containers/chatroom/ChatMembers.vue
@@ -22,7 +22,10 @@
       .profile-wrapper(v-if='isDMToMyself')
         profile-card(:contractID='ourIdentityContractId' deactivated)
           avatar-user(:contractID='ourIdentityContractId' :picture='picture' size='sm' data-test='openMemberProfileCard')
-          span.is-unstyled.c-name.has-ellipsis(:data-test='title')
+          span.is-unstyled.c-name.has-ellipsis(
+            :class='{ "has-text-bold": shouldStyleBold(chatRoomID) }'
+            :data-test='title'
+          )
             span {{ title }}
             i18n.c-you (you)
 
@@ -30,18 +33,24 @@
         .profile-wrapper(v-if='partners.length === 1')
           profile-card(:contractID='partners[0].contractID' deactivated)
             avatar-user(:contractID='partners[0].contractID' :picture='picture' size='sm' data-test='openMemberProfileCard')
-            span.is-unstyled.c-name.has-ellipsis(:data-test='partners[0].username') {{ title }}
+            span.is-unstyled.c-name.has-ellipsis(
+              :class='{ "has-text-bold": shouldStyleBold(chatRoomID) }'
+              :data-test='partners[0].username'
+            ) {{ title }}
 
         .group-wrapper(v-else)
           .picture-wrapper
             avatar(:src='picture' :alt='title' size='xs')
             .c-badge {{ partners.length }}
-          span.is-unstyled.c-name.has-ellipsis(:data-test='title') {{ title }}
+          span.is-unstyled.c-name.has-ellipsis(
+            :data-test='title'
+            :class='{ "has-text-bold": shouldStyleBold(chatRoomID) }'
+          ) {{ title }}
 
       .c-unreadcount-wrapper
         .pill.is-danger(
           v-if='getUnreadMsgCount(chatRoomID)'
-        ) {{limitedUnreadCount(getUnreadMsgCount(chatRoomID))}}
+        ) {{ limitedUnreadCount(getUnreadMsgCount(chatRoomID)) }}
 </template>
 
 <script>
@@ -80,7 +89,8 @@ export default ({
       'groupShouldPropose',
       'ourGroupDirectMessages',
       'ourIdentityContractId',
-      'chatRoomUnreadMessages'
+      'chatRoomUnreadMessages',
+      'isChatRoomManuallyMarkedUnread'
     ])
   },
   methods: {
@@ -107,6 +117,10 @@ export default ({
     getUnreadMsgCount (chatRoomID) {
       return this.chatRoomUnreadMessages(chatRoomID).length
     },
+    shouldStyleBold (chatRoomID) {
+      return this.isChatRoomManuallyMarkedUnread(chatRoomID) ||
+        this.getUnreadMsgCount(chatRoomID) > 0
+    },
     limitedUnreadCount (n) {
       const nLimit = 99
       if (n > nLimit) {
@@ -156,7 +170,6 @@ export default ({
   display: inline-block;
   margin-left: 0.25rem;
   font-size: 0.875em;
-  font-weight: normal;
 }
 
 .c-menu {
diff --git a/frontend/views/containers/chatroom/ConversationsList.vue b/frontend/views/containers/chatroom/ConversationsList.vue
index 50ca07b723..0bbc1b2d38 100644
--- a/frontend/views/containers/chatroom/ConversationsList.vue
+++ b/frontend/views/containers/chatroom/ConversationsList.vue
@@ -27,16 +27,19 @@
         size='sm'
       )
 
-      span.c-channel-name {{list.channels[id].displayName || list.channels[id].name}}
+      span.c-channel-name(
+        :class='{ "has-text-bold": shouldStyleBold(id) }'
+      ) {{list.channels[id].displayName || list.channels[id].name}}
 
       .c-unreadcount-wrapper
         .pill.is-danger(
-          v-if='list.channels[id].unreadMessagesCount'
-        ) {{limitedUnreadCount(list.channels[id].unreadMessagesCount)}}
+          v-if='list.channels[id].unreadMessagesCount > 0'
+        ) {{ limitedUnreadCount(list.channels[id].unreadMessagesCount) }}
 </template>
 
 <script>
 import sbp from '@sbp/sbp'
+import { mapGetters } from 'vuex'
 import { OPEN_MODAL } from '@utils/events.js'
 import ListItem from '@components/ListItem.vue'
 import Avatar from '@components/Avatar.vue'
@@ -62,6 +65,9 @@ export default ({
     list: Object,
     routeName: String
   },
+  computed: {
+    ...mapGetters(['isChatRoomManuallyMarkedUnread'])
+  },
   methods: {
     onClickedNewChannel () {
       this.openModal('CreateNewChannelModal')
@@ -100,6 +106,10 @@ export default ({
       } else {
         return `${n}`
       }
+    },
+    shouldStyleBold (chatRoomID) {
+      return this.isChatRoomManuallyMarkedUnread(chatRoomID) ||
+        this.list.channels[chatRoomID].unreadMessagesCount > 0
     }
   }
 }: Object)
diff --git a/frontend/views/containers/chatroom/MessageActions.vue b/frontend/views/containers/chatroom/MessageActions.vue
index 0a5ed0ee39..a78d761b3d 100644
--- a/frontend/views/containers/chatroom/MessageActions.vue
+++ b/frontend/views/containers/chatroom/MessageActions.vue
@@ -133,6 +133,11 @@ export default ({
         action: 'openEmoticon',
         icon: 'smile-beam',
         conditionToShow: !this.isDesktopScreen
+      }, {
+        name: L('Mark unread'),
+        action: 'markAsUnread',
+        icon: 'envelope',
+        conditionToShow: true
       }, {
         name: L('Edit'),
         action: 'editMessage',
@@ -298,7 +303,7 @@ export default ({
   }
 
   .c-menuItem ::v-deep .c-item-link {
-    @extend %floating-panel-item;
+    height: 2.31rem;
   }
 }
 
diff --git a/frontend/views/containers/chatroom/MessageBase.vue b/frontend/views/containers/chatroom/MessageBase.vue
index cf4a62a81b..c6eb55c892 100644
--- a/frontend/views/containers/chatroom/MessageBase.vue
+++ b/frontend/views/containers/chatroom/MessageBase.vue
@@ -103,6 +103,7 @@
     @retry='$emit("retry")'
     @pinToChannel='$emit("pin-to-channel")'
     @unpinFromChannel='$emit("unpin-from-channel")'
+    @markAsUnread='markAsUnread'
   )
 
   .c-truncate-toggle-container(
@@ -145,7 +146,10 @@ import { getFileType } from '@view-utils/filters.js'
 export default ({
   name: 'MessageBase',
   mixins: [emoticonsMixins],
-  inject: ['chatMainConfig'],
+  inject: [
+    'chatMainConfig',
+    'chatMainUtils'
+  ],
   components: {
     Avatar,
     ProfileCard,
@@ -336,6 +340,12 @@ export default ({
         // When folding the expanded message, scroll to the message so that it stays in the screen.
         this.$el.scrollIntoView({ behavior: 'instant' })
       }
+    },
+    markAsUnread () {
+      this.chatMainUtils.markAsUnread({
+        messageHash: this.messageHash,
+        createdHeight: this.height
+      })
     }
   },
   watch: {


Reviews:
user: login: SebinSong
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-17T09:41:22Z
user: login: SebinSong
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-17T09:50:14Z
user: login: taoeffect
body: Great work @SebinSong!

I've left a few comments. Also, below I've included feedback from `o4-mini` — **note:** I haven't reviewed this feedback, so it might contain irrelevant/incorrect feedback, especially since I gave it a lot of extra context to review (please check to see if there's anything useful though, and I'd be curious to hear your thoughts as to how it did):

## o4-mini

<details>
Here are a few spots I think could lead to bugs or be improved. I’ve quoted the exact lines and given replacement suggestions.

---

1) **Dangerous mutation / missing guard in removeChatRoomUnreadMessage**  
   
Current code (can throw if `unreadMessages` is `undefined`, and mutates `currentData` in place):  
```js
// frontend/controller/actions/identity-kv.js
...
const index = currentData[contractID]?.unreadMessages.findIndex(msg => msg.messageHash === messageHash)
// NOTE: index could be undefined if unreadMessages is not initialized
if (Number.isInteger(index) && index >= 0) {
  currentData[contractID].unreadMessages.splice(index, 1)
  return currentData
}
return null
...
```

**Problems**  
- If `currentData[contractID]` exists but `unreadMessages` is `undefined`, `.findIndex` throws.  
- Mutating `currentData` in place can lead to subtle bugs if some handler is holding the old reference.

**Suggested fix** – guard against missing array and do an immutable update (using shallow copies):  
```js
// frontend/controller/actions/identity-kv.js
...
const getUpdatedUnreadMessages = async () => {
  const currentData = await sbp('gi.actions/identity/kv/fetchChatRoomUnreadMessages')
  const chatData = currentData[contractID]
  const arr = Array.isArray(chatData?.unreadMessages)
    ? chatData.unreadMessages
    : []
  const idx = arr.findIndex(msg => msg.messageHash === messageHash)
  if (idx < 0) {
    return null
  }
  // build a new object rather than mutating
  return {
    ...currentData,
    [contractID]: {
      ...chatData,
      unreadMessages: [
        ...arr.slice(0, idx),
        ...arr.slice(idx + 1)
      ]
    }
  }
}
...
```

---

2) **Similarly, immutable update in addChatRoomUnreadMessage**  
   
Current (mutates `currentData` in place):  
```js
// frontend/controller/actions/identity-kv.js
if (currentData[contractID]?.readUntil.createdHeight < createdHeight) {
  const index = currentData[contractID].unreadMessages.findIndex(msg => msg.messageHash === messageHash)
  if (index === -1) {
    currentData[contractID].unreadMessages.push({ messageHash, createdHeight })
    return currentData
  }
}
```

**Suggested fix** – clone unreadMessages before pushing:  
```js
// frontend/controller/actions/identity-kv.js
if (currentData[contractID]?.readUntil.createdHeight < createdHeight) {
  const chatData = currentData[contractID]
  const arr = Array.isArray(chatData.unreadMessages)
    ? chatData.unreadMessages
    : []
  if (!arr.find(msg => msg.messageHash === messageHash)) {
    return {
      ...currentData,
      [contractID]: {
        ...chatData,
        unreadMessages: arr.concat({ messageHash, createdHeight })
      }
    }
  }
}
```

---

3) **Restrict “Mark unread” to relevant message types**  

Right now in `MessageActions.vue` you have:  
```js
{
  name: L('Mark unread'),
  action: 'markAsUnread',
  icon: 'envelope',
  conditionToShow: !this.isMsgSender
},
```

That will show “Mark unread” on _every_ non‑sender message (including, e.g., notifications). You probably only want it for chat messages (TEXT, INTERACTIVE, POLL). For example:  
```js
// frontend/views/containers/chatroom/MessageActions.vue
{
  name: L('Mark unread'),
  action: 'markAsUnread',
  icon: 'envelope',
  conditionToShow:
    !this.isMsgSender &&
    [MESSAGE_TYPES.TEXT, MESSAGE_TYPES.INTERACTIVE, MESSAGE_TYPES.POLL].includes(this.type)
},
```

---

4) **Close the menu after clicking “Mark unread”**  

In `MessageBase.vue` you bound the event:  
```pug
<message-actions
  …
  @markAsUnread='markAsUnread'
/>
```

But you never call your menu‑closing logic, so the actions menu stays open. You probably want something like:  
```js
// frontend/views/containers/chatroom/MessageBase.vue
methods: {
  …,
  markAsUnread () {
    this.chatMainUtils.markAsUnread({
      messageHash: this.messageHash,
      createdHeight: this.height
    })
    // close the menu (assuming you have a ref or a method)
    this.$refs.messageAction.closeMenu && this.$refs.messageAction.closeMenu()
  }
}
```

---

5) **Optional: clear the “manual mark” flag after post‑action**  

You set  
```js
this.ephemeral.messageHashToMarkUnread = targetMsg.hash
```
and never clear it (other than when switching rooms). That means _all_ future scrolls will silently skip auto‑updating read‑until. If you intended to resume auto‑scroll/updating once you’ve applied your post‑mark logic, you should clear it in your watcher:

```js
// in ChatMain.vue watcher:
watch: {
  currentChatRoomReadUntil (newRU) {
    if (newRU?.messageHash === this.ephemeral.messageHashToMarkUnread) {
      this.$nextTick(() => {
        this.markUnreadPostActions()
        // now allow auto‑updates again
        this.ephemeral.messageHashToMarkUnread = null
      })
    }
  }
}
```

Otherwise, add a comment to clarify that this is deliberate.

---

With these changes you’ll avoid silent crashes, accidental state mutations, confusing menu states, and overly‑broad display of “Mark unread.” Let me know if you need any further context!
</details>
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-17T18:32:34Z
user: login: SebinSong
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-20T00:27:13Z
user: login: taoeffect
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-20T00:32:51Z
user: login: SebinSong
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-20T00:54:49Z
user: login: SebinSong
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-20T01:16:55Z
user: login: SebinSong
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-20T08:38:33Z
user: login: SebinSong
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-21T00:29:04Z
user: login: SebinSong
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-21T00:29:17Z
user: login: taoeffect
body: Nice work @SebinSong, cool how the chatrooms are made bold now. 😎 👍 

I noticed though that I cannot mark DMs to myself as unread though, the menu doesn't appear:

![Screenshot 2025-04-24 at 9 54 44 AM](https://github.com/user-attachments/assets/c20d724d-15a4-4b98-a019-20382e1658a9)

state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-24T16:55:40Z
user: login: corrideat
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T23:11:21Z
user: login: corrideat
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T23:12:05Z
user: login: corrideat
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T23:12:31Z
user: login: corrideat
body: Added comments to the parts that'll conflict with https://github.com/okTurtles/group-income/pull/2742 and will need to be updated.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T23:13:30Z
user: login: SebinSong
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-28T06:36:09Z
user: login: taoeffect
body: Amazing work @SebinSong! Great job, just tested it and verified everything works great!
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-28T18:26:39Z

Review comments:
diff_hunk: @@ -133,6 +133,11 @@ export default ({
         action: 'openEmoticon',
         icon: 'smile-beam',
         conditionToShow: !this.isDesktopScreen
+      }, {
+        name: L('Mark unread'),
+        action: 'markAsUnread',
+        icon: 'envelope',
+        conditionToShow: !this.isMsgSender
path: frontend/views/containers/chatroom/MessageActions.vue
user: login: SebinSong
body: `Mark unread` menu is only enabled on messages that are sent by other users(I believe it does not make much sense to unread messages that I _myself_ wrote/sent).

FYI, Slack does show this menu for messages sent by myself. But what it actually does in this case is, it finds an older message that is sent by other user and then mark that as unread. I feel this seems a bit unnecessary and more importantly, overcomplicates the logic. (feels reluctant because chatroom is already one of the most complicated area in the app IMO) Hope it sounds okay.
created_at: 2025-04-17T09:41:21Z
author_association: COLLABORATOR
diff_hunk: @@ -995,6 +1005,64 @@ export default ({
         })
       }
     },
+    async markAsUnread ({ messageHash, createdHeight }) {
+      const chatRoomID = this.ephemeral.renderingChatRoomId
+      if (!chatRoomID || !this.isJoinedChatRoom(chatRoomID)) { return }
+
+      // helper functions
+      const getUpdatedUnreadMessages = () => {
+        // This method filters the messages to store in 'unreadMessages' property (eg. messages that mentions me or contains '@all'),
+        // which are reflected as the unread-count badge in the UI.
path: frontend/views/containers/chatroom/ChatMain.vue
user: login: SebinSong
body: This method basically re-determines the messages to display as this numbered-badge UI when user uses 'mark-unread' feature (eg. messages that mentions `@my-username` or `@all`, messages sent in DM, Group proposal notifcation messages etc.):

<img src='https://github.com/user-attachments/assets/571d23fe-dab9-4456-af2e-2822e9a9ce2c' width='270'>

created_at: 2025-04-17T09:50:14Z
author_association: COLLABORATOR
diff_hunk: @@ -133,6 +133,11 @@ export default ({
         action: 'openEmoticon',
         icon: 'smile-beam',
         conditionToShow: !this.isDesktopScreen
+      }, {
+        name: L('Mark unread'),
+        action: 'markAsUnread',
+        icon: 'envelope',
+        conditionToShow: !this.isMsgSender
path: frontend/views/containers/chatroom/MessageActions.vue
user: login: taoeffect
body: I actually use this feature quite frequently, so would very much appreciate being able to mark messages to myself as unread. 😄 

It gives me the ability to remind myself that I have a TODO or something that I need to do soon and not to forget about it.
created_at: 2025-04-17T18:02:51Z
author_association: MEMBER
diff_hunk: @@ -115,6 +115,30 @@ export default (sbp('sbp/selectors/register', {
       }
     })
   },
+  'gi.actions/identity/kv/markAsUnread': ({ contractID, messageHash, createdHeight, unreadMessages }: {
+    contractID: string,
+    messageHash: string,
+    createdHeight: number,
+    unreadMessages: Array<{ messageHash: string, createdHeight: number }>
+  }) => {
+    return sbp('okTurtles.eventQueue/queueEvent', KV_QUEUE, async () => {
+      const getUpdatedUnreadMessages = async () => {
+        const currentData = await sbp('gi.actions/identity/kv/fetchChatRoomUnreadMessages')
+
+        return {
+          ...currentData,
+          [contractID]: {
+            readUntil: { messageHash, createdHeight }, unreadMessages
+          }
+        }
+      }
+
+      const data = await getUpdatedUnreadMessages()
+      if (data) {
path: frontend/controller/actions/identity-kv.js
user: login: taoeffect
body: The way the other unread actions are written check for `if (data)` because `getUpdatedUnreadMessages` could actually return `null`, but the way this version of it is written, it will never return `null`. So either this `if` should be removed, or `null` should be returned in some special case of `getUpdatedUnreadMessages`.  I have not looked closely enough to decide which should be the case (so please investigate), just noticing this inconsistency.
created_at: 2025-04-17T18:09:16Z
author_association: MEMBER
diff_hunk: @@ -978,15 +979,24 @@ export default ({
         }
       }
     },
-    updateReadUntilMessageHash ({ messageHash, createdHeight, forceUpdate = false }) {
+    updateReadUntilMessageHash ({
+      messageHash,
+      createdHeight,
+      // 'forceUpdate' flag here is for the rare case where the 'readUntil' value needs to be set to the msg with lower 'createdHeight'.
+      // eg. when the latest message is deleted. (reference: https://github.com/okTurtles/group-income/issues/2729)
+      forceUpdate = false,
+      // 'Mark unread' feature allows user to set 'currentChatRoomReadUntil' to the message they want.
+      // So if user has used this functionality at least once, the chatroom should stop auto-updating the 'readUntil' data in the store (eg. while scrolling),
+      // So that the message that has been marked unread is kept until user leaves the chatroom and re-enter in the future.
+      // Hence, setting below flag to 'true' by default.
+      noUpdateWhenMarkUnreadIsUsed = true
path: frontend/views/containers/chatroom/ChatMain.vue
user: login: taoeffect
body: I checked the codebase and this `noUpdateWhenMarkUnreadIsUsed` flag isn't used anywhere... so why have it?
created_at: 2025-04-17T18:11:24Z
author_association: MEMBER
diff_hunk: @@ -995,6 +1005,64 @@ export default ({
         })
       }
     },
+    async markAsUnread ({ messageHash, createdHeight }) {
+      const chatRoomID = this.ephemeral.renderingChatRoomId
+      if (!chatRoomID || !this.isJoinedChatRoom(chatRoomID)) { return }
+
+      // helper functions
+      const getUpdatedUnreadMessages = () => {
+        // This method filters the messages to store in 'unreadMessages' property (eg. messages that mentions me or contains '@all'),
+        // which are reflected as the unread-count badge in the UI.
path: frontend/views/containers/chatroom/ChatMain.vue
user: login: taoeffect
body: I see... if you want to have the badge number only reflect the number of @mentions in a chatroom, that's fine, but if a user marks a message in a chatroom as unread then we need to show *something*. I noticed that when none of the messages contain @mentions, then this PR does nothing, which isn't right.

On Slack, in the #random channel, if I mark a message as unread it makes the chatroom bold white in color. So we can either do that (and show the badge reflecting the number of @mentions), or alternatively, we can make it bold white and also count all messages in the counter (up to you). But either way it should do something.
created_at: 2025-04-17T18:20:58Z
author_association: MEMBER
diff_hunk: @@ -133,6 +133,11 @@ export default ({
         action: 'openEmoticon',
         icon: 'smile-beam',
         conditionToShow: !this.isDesktopScreen
+      }, {
+        name: L('Mark unread'),
+        action: 'markAsUnread',
+        icon: 'envelope',
+        conditionToShow: !this.isMsgSender
path: frontend/views/containers/chatroom/MessageActions.vue
user: login: SebinSong
body: @taoeffect 
That requires to remove this logic from L971 - L977 below:

https://github.com/okTurtles/group-income/blob/51937576a77a67cf30711cad5006dbdb75e78c0e/frontend/views/containers/chatroom/ChatMain.vue#L960-L979

`setStartNewMessageIndex` method here is currently used to determine the initial scroll position using `currentChatRoomReadUntil` vallue and also display below `--- new ---` divider UI when needed.

![image](https://github.com/user-attachments/assets/79daeefb-8963-4020-bc3b-82e671bc7b97)

Instead of simply going directly to the message stored in `currentChatRoomReadUntil`, currently it finds **a newer message that is sent by other user** and scroll to it. In order to implement your requirement, we need to change this behaviour. Should we be good to go ahead with it?

created_at: 2025-04-20T00:27:13Z
author_association: COLLABORATOR
diff_hunk: @@ -133,6 +133,11 @@ export default ({
         action: 'openEmoticon',
         icon: 'smile-beam',
         conditionToShow: !this.isDesktopScreen
+      }, {
+        name: L('Mark unread'),
+        action: 'markAsUnread',
+        icon: 'envelope',
+        conditionToShow: !this.isMsgSender
path: frontend/views/containers/chatroom/MessageActions.vue
user: login: taoeffect
body: I'm not sure I fully understand the question or implications.... but feel free to go ahead and make it work. If you're saying it will break some other functionality though, please help me understand better what it will break.
created_at: 2025-04-20T00:32:51Z
author_association: MEMBER
diff_hunk: @@ -133,6 +133,11 @@ export default ({
         action: 'openEmoticon',
         icon: 'smile-beam',
         conditionToShow: !this.isDesktopScreen
+      }, {
+        name: L('Mark unread'),
+        action: 'markAsUnread',
+        icon: 'envelope',
+        conditionToShow: !this.isMsgSender
path: frontend/views/containers/chatroom/MessageActions.vue
user: login: SebinSong
body: @taoeffect 
I meant the behavior explained by below comment will be overriden. I'm not the author of this logic so don't know the full context of it either..

```
// NOTE: When the user switches channel before the message is not fully processed, 
//       (in other words, until this.variant(msg) === 'sent') 
//       the chatroomReadUntil position would not be saved correctly 
//       because the pending messages could not be saved in state. 
//       Considering those such cases, we shoud set 'isNew' position for the messages 
//       only whose sender is not ourselves. 
```
created_at: 2025-04-20T00:54:48Z
author_association: COLLABORATOR
diff_hunk: @@ -995,6 +1005,64 @@ export default ({
         })
       }
     },
+    async markAsUnread ({ messageHash, createdHeight }) {
+      const chatRoomID = this.ephemeral.renderingChatRoomId
+      if (!chatRoomID || !this.isJoinedChatRoom(chatRoomID)) { return }
+
+      // helper functions
+      const getUpdatedUnreadMessages = () => {
+        // This method filters the messages to store in 'unreadMessages' property (eg. messages that mentions me or contains '@all'),
+        // which are reflected as the unread-count badge in the UI.
path: frontend/views/containers/chatroom/ChatMain.vue
user: login: SebinSong
body: >  if you want to have the badge number only reflect the number of @mentions in a chatroom, that's fine ...

FYI That is what the badge number shown means currently in the app (not a new behavior I created in this PR).

> I noticed that when none of the messages contain @mentions, then this PR does nothing, which isn't right.

Not true.. It currently does do something which is displaying/positioning this `--- New ---` UI element to the message marked unread:

![image](https://github.com/user-attachments/assets/9788b993-b4f4-41d6-9a36-bf58ab1e7ec6)

I agree the chatroom title having bold-style text makes things more explicit though. So let me try making this change. 
created_at: 2025-04-20T01:16:55Z
author_association: COLLABORATOR
diff_hunk: @@ -115,6 +115,30 @@ export default (sbp('sbp/selectors/register', {
       }
     })
   },
+  'gi.actions/identity/kv/markAsUnread': ({ contractID, messageHash, createdHeight, unreadMessages }: {
+    contractID: string,
+    messageHash: string,
+    createdHeight: number,
+    unreadMessages: Array<{ messageHash: string, createdHeight: number }>
+  }) => {
+    return sbp('okTurtles.eventQueue/queueEvent', KV_QUEUE, async () => {
+      const getUpdatedUnreadMessages = async () => {
+        const currentData = await sbp('gi.actions/identity/kv/fetchChatRoomUnreadMessages')
+
+        return {
+          ...currentData,
+          [contractID]: {
+            readUntil: { messageHash, createdHeight }, unreadMessages
+          }
+        }
+      }
+
+      const data = await getUpdatedUnreadMessages()
+      if (data) {
path: frontend/controller/actions/identity-kv.js
user: login: SebinSong
body: @taoeffect Right. In this case, we can add a logic to check if the existing message hash in `readUntil` property is the same as the one requested (eg. User trying to mark the same message as unread multiple times.) and then returning `null` if that's the case. 
created_at: 2025-04-20T08:38:33Z
author_association: COLLABORATOR
diff_hunk: @@ -995,6 +1005,64 @@ export default ({
         })
       }
     },
+    async markAsUnread ({ messageHash, createdHeight }) {
+      const chatRoomID = this.ephemeral.renderingChatRoomId
+      if (!chatRoomID || !this.isJoinedChatRoom(chatRoomID)) { return }
+
+      // helper functions
+      const getUpdatedUnreadMessages = () => {
+        // This method filters the messages to store in 'unreadMessages' property (eg. messages that mentions me or contains '@all'),
+        // which are reflected as the unread-count badge in the UI.
path: frontend/views/containers/chatroom/ChatMain.vue
user: login: SebinSong
body: @taoeffect 
Made updates accordingly:

<img src='https://github.com/user-attachments/assets/f11e17a7-0b51-4b66-8abe-ba98cc55f651' width='320'>

Green-box here is for the case when user has used `mark-unread` feature and there is no mentions (or proposal notification etc..) and below it is for the case where the unread messages does contain mentions.

---

BTW just FYI, `getUpdatedUnreadMessages` logic(determining the messages to display as the number badge) here were created based on the existing logics which are:

https://github.com/okTurtles/group-income/blob/51937576a77a67cf30711cad5006dbdb75e78c0e/frontend/controller/actions/chatroom.js#L28-L29

https://github.com/okTurtles/group-income/blob/51937576a77a67cf30711cad5006dbdb75e78c0e/frontend/controller/actions/chatroom.js#L46

https://github.com/okTurtles/group-income/blob/51937576a77a67cf30711cad5006dbdb75e78c0e/frontend/model/notifications/messageReceivePostEffect.js#L33-L39
 
created_at: 2025-04-21T00:29:04Z
author_association: COLLABORATOR
diff_hunk: @@ -978,15 +979,24 @@ export default ({
         }
       }
     },
-    updateReadUntilMessageHash ({ messageHash, createdHeight, forceUpdate = false }) {
+    updateReadUntilMessageHash ({
+      messageHash,
+      createdHeight,
+      // 'forceUpdate' flag here is for the rare case where the 'readUntil' value needs to be set to the msg with lower 'createdHeight'.
+      // eg. when the latest message is deleted. (reference: https://github.com/okTurtles/group-income/issues/2729)
+      forceUpdate = false,
+      // 'Mark unread' feature allows user to set 'currentChatRoomReadUntil' to the message they want.
+      // So if user has used this functionality at least once, the chatroom should stop auto-updating the 'readUntil' data in the store (eg. while scrolling),
+      // So that the message that has been marked unread is kept until user leaves the chatroom and re-enter in the future.
+      // Hence, setting below flag to 'true' by default.
+      noUpdateWhenMarkUnreadIsUsed = true
path: frontend/views/containers/chatroom/ChatMain.vue
user: login: SebinSong
body: Removed it.
created_at: 2025-04-21T00:29:17Z
author_association: COLLABORATOR
diff_hunk: @@ -115,6 +115,37 @@ export default (sbp('sbp/selectors/register', {
       }
     })
   },
+  'gi.actions/identity/kv/markAsUnread': ({ contractID, messageHash, createdHeight, unreadMessages }: {
+    contractID: string,
+    messageHash: string,
+    createdHeight: number,
+    unreadMessages: Array<{ messageHash: string, createdHeight: number }>
+  }) => {
+    return sbp('okTurtles.eventQueue/queueEvent', KV_QUEUE, async () => {
+      const getUpdatedUnreadMessages = async () => {
+        const currentData = await sbp('gi.actions/identity/kv/fetchChatRoomUnreadMessages')
path: frontend/controller/actions/identity-kv.js
user: login: corrideat
body: This is OK, but will need to be updated after #2742 is merged (the signature will be updated and the fetch will no longer be needed)
created_at: 2025-04-24T23:11:20Z
author_association: MEMBER
diff_hunk: @@ -115,6 +115,37 @@ export default (sbp('sbp/selectors/register', {
       }
     })
   },
+  'gi.actions/identity/kv/markAsUnread': ({ contractID, messageHash, createdHeight, unreadMessages }: {
+    contractID: string,
+    messageHash: string,
+    createdHeight: number,
+    unreadMessages: Array<{ messageHash: string, createdHeight: number }>
+  }) => {
+    return sbp('okTurtles.eventQueue/queueEvent', KV_QUEUE, async () => {
+      const getUpdatedUnreadMessages = async () => {
+        const currentData = await sbp('gi.actions/identity/kv/fetchChatRoomUnreadMessages')
+        const existingReadUntil = currentData[contractID]?.readUntil
+
+        // If the requested mark-unread hash has already been set, ignore it.
+        if (existingReadUntil &&
+          existingReadUntil.isManuallyMarked &&
+          existingReadUntil?.messageHash === messageHash) { return null }
+
+        return {
+          ...currentData,
+          [contractID]: {
+            readUntil: { messageHash, createdHeight, isManuallyMarked: true },
+            unreadMessages
+          }
+        }
+      }
+
+      const data = await getUpdatedUnreadMessages()
+      if (data) {
path: frontend/controller/actions/identity-kv.js
user: login: corrideat
body: This here too might need to be updated.
created_at: 2025-04-24T23:12:05Z
author_association: MEMBER
diff_hunk: @@ -115,6 +115,37 @@ export default (sbp('sbp/selectors/register', {
       }
     })
   },
+  'gi.actions/identity/kv/markAsUnread': ({ contractID, messageHash, createdHeight, unreadMessages }: {
+    contractID: string,
+    messageHash: string,
+    createdHeight: number,
+    unreadMessages: Array<{ messageHash: string, createdHeight: number }>
+  }) => {
+    return sbp('okTurtles.eventQueue/queueEvent', KV_QUEUE, async () => {
+      const getUpdatedUnreadMessages = async () => {
+        const currentData = await sbp('gi.actions/identity/kv/fetchChatRoomUnreadMessages')
+        const existingReadUntil = currentData[contractID]?.readUntil
+
+        // If the requested mark-unread hash has already been set, ignore it.
+        if (existingReadUntil &&
+          existingReadUntil.isManuallyMarked &&
+          existingReadUntil?.messageHash === messageHash) { return null }
+
+        return {
path: frontend/controller/actions/identity-kv.js
user: login: corrideat
body: And this return will need to be `[..., etag]`
created_at: 2025-04-24T23:12:31Z
author_association: MEMBER
diff_hunk: @@ -115,6 +115,37 @@ export default (sbp('sbp/selectors/register', {
       }
     })
   },
+  'gi.actions/identity/kv/markAsUnread': ({ contractID, messageHash, createdHeight, unreadMessages }: {
+    contractID: string,
+    messageHash: string,
+    createdHeight: number,
+    unreadMessages: Array<{ messageHash: string, createdHeight: number }>
+  }) => {
+    return sbp('okTurtles.eventQueue/queueEvent', KV_QUEUE, async () => {
+      const getUpdatedUnreadMessages = async () => {
+        const currentData = await sbp('gi.actions/identity/kv/fetchChatRoomUnreadMessages')
path: frontend/controller/actions/identity-kv.js
user: login: SebinSong
body: @corrideat Thanks for a heads up. It seems that PR has not been merged yet. Will make these updates if that goes earlier than this PR. 
created_at: 2025-04-28T06:36:09Z
author_association: COLLABORATOR

PR comments:
user: login: cypress[bot]
created_at: 2025-04-17T09:36:57Z
body: 
<h3>
  <a href="https://cloud.cypress.io/projects/q6whky?utm_source=github&utm_medium=PASSED&utm_campaign=project">group-income</a>&nbsp;
  <img src="https://assets.cypress.io/github-pr-comment-icons/chevron.svg" alt="" height="14" width="14" />&nbsp;
  <a href="https://cloud.cypress.io/projects/q6whky/runs/4438/overview?utm_source=github&utm_medium=PASSED&utm_campaign=run%20number">Run #4438</a>
</h3>

<details>
  <summary>
    <strong>Run Properties:</strong>&nbsp;
    <code><img src="https://assets.cypress.io/github-pr-comment-icons/passed.svg" alt="status check passed" height="10" width="10" />&nbsp;Passed&nbsp;<a href="https://cloud.cypress.io/projects/q6whky/runs/4438/overview?utm_source=github&utm_medium=PASSED&utm_campaign=run%20number">#4438</a></code> &nbsp;&bull;&nbsp;
    <code><img src="https://assets.cypress.io/github-pr-comment-icons/git-commit.svg" alt="git commit" width="10" height="10" /> <a href="https://github.com/okTurtles/group-income/commit/b48823ddea727fc4b8dd9092322d018f080368dc">b48823ddea <span style="cursor:help;" title="Your CI provider generated this commit by merging this branch into the target branch.">ℹ️</span></a>: Merge 1f8e4f156f836918a59c22413c17e9ff0ec2111c into 75e836448b95e1aeb93f69cd07f7...</code>
  </summary><br />

  <table width="744">
    <tbody>
      <tr />
      <tr height="48">
        <td width="224">
          <strong>Project</strong>
        </td>
        <td width="520">
          <code><a href="https://cloud.cypress.io/projects/q6whky?utm_source=github&utm_medium=PASSED&utm_campaign=project">group-income</a></code>
        </td>
      </tr>
      <tr />
      <tr height="48">
        <td width="224">
          <strong>Branch Review</strong>
        </th>
        <td>
          <code><a href="https://cloud.cypress.io/projects/q6whky/branches/sebin%2Ftask%2F%232712-mark-message-as-undread/review?utm_source=github&utm_medium=PASSED&utm_campaign=review%20changes&changeRequest=2753">sebin/task/#2712-mark-message-as-undread</a></code>
        </td>
      </tr>
      <tr />
      <tr height="48">
        <td><strong>Run status</strong></td>
        <td>
          <code><img src="https://assets.cypress.io/github-pr-comment-icons/passed.svg" alt="status check passed" height="10" width="10" />&nbsp;Passed&nbsp;<a href="https://cloud.cypress.io/projects/q6whky/runs/4438/overview?utm_source=github&utm_medium=PASSED&utm_campaign=run%20number">#4438</a></code>
        </td>
      </tr>
      <tr />
      <tr height="48">
        <td><strong>Run duration</strong></td>
        <td><code>12m 21s</code></td>
      </tr>
      <tr />
      <tr height="48">
        <td><strong>Commit</strong></td>
        <td>
          <code><img src="https://assets.cypress.io/github-pr-comment-icons/git-commit.svg" alt="git commit" width="10" height="10" /> <a href="https://github.com/okTurtles/group-income/commit/b48823ddea727fc4b8dd9092322d018f080368dc">b48823ddea <span style="cursor:help;" title="Your CI provider generated this commit by merging this branch into the target branch.">ℹ️</span></a>: Merge 1f8e4f156f836918a59c22413c17e9ff0ec2111c into 75e836448b95e1aeb93f69cd07f7...</code>
        </td>
      </tr>
      <tr />
      <tr height="48">
        <td><strong>Committer</strong></td>
        <td><code>Sebin Song</code></td>
      </tr>
    </tbody>
    <tfoot>
      <tr />
      <tr height="48">
        <td colspan="2">
          <a href="https://cloud.cypress.io/projects/q6whky/runs/4438/properties?utm_source=github&utm_medium=PASSED&utm_campaign=properties">View all properties for this run ↗︎</a>
        </td>
      </tr>
    </tfoot>
  </table>
  <hr />
</details>

<table width="744">
  <thead>
    <tr />
    <tr height="48">
      <th colspan="2" align="left" width="744">Test results</th>
    </tr>
  </thead>
  <tbody>
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/failed.svg" alt="Tests that failed" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/q6whky/runs/4438/overview?reviewViewBy=FAILED&utm_source=github&utm_medium=PASSED&utm_campaign=failed%20tests">
          <strong>Failures</strong>
        </a>
      </td>
      <td>
        <code><strong>0</strong></code>
      </td>
    </tr>
    <tr />
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/flaky-v3.svg" alt="Tests that were flaky" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/q6whky/runs/4438/overview?reviewViewBy=FLAKY&utm_source=github&utm_medium=PASSED&utm_campaign=flaky%20tests">
          <strong>Flaky</strong>
        </a>
      </td>
      <td>
        <code><strong>0</strong></code>
      </td>
    </tr>
    <tr />
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/pending.svg" alt="Tests that did not run due to a developer annotating a test with .skip" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/q6whky/runs/4438/test-results?utm_source=github&utm_medium=PASSED&utm_campaign=pending%20tests&statuses=%5B%7B%22value%22%3A%22PENDING%22%2C%22label%22%3A%22PENDING%22%7D%5D">
          <strong>Pending</strong>
        </a>
      </td>
      <td>
        <code><strong>10</strong></code>
      </td>
    </tr>
    <tr />
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/skipped-v3.svg" alt="Tests that did not run due to a failure in a mocha hook" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/q6whky/runs/4438/test-results?utm_source=github&utm_medium=PASSED&utm_campaign=skipped%20tests&statuses=%5B%7B%22value%22%3A%22SKIPPED%22%2C%22label%22%3A%22SKIPPED%22%7D%5D">
          <strong>Skipped</strong>
        </a>
      </td>
      <td>
        <code><strong>0</strong></code>
      </td>
    </tr>
    <tr />
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/passed.svg" alt="Tests that passed" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/q6whky/runs/4438/overview?utm_source=github&utm_medium=PASSED&utm_campaign=passed%20tests">
          <strong>Passing</strong>
        </a>
      </td>
      <td>
        <code><strong>116</strong></code>
      </td>
    </tr><tfoot>
    <tr height="48"><td colspan="2"><a href="https://cloud.cypress.io/projects/q6whky/branches/sebin%2Ftask%2F%232712-mark-message-as-undread/review?utm_source=github&utm_medium=PASSED&utm_campaign=review%20changes&changeRequest=2753">View all changes introduced in this branch ↗︎</a></td></tr></tfoot>
  </tbody>
</table>






author_association: NONE
user: login: SebinSong
created_at: 2025-04-21T00:38:38Z
body: @taoeffect PR is ready for review again.

I also did take a look at the `o4-mini` suggestion and adopted **1)** from it. Below are my assessment for its review.

1): OK.
2), 3): Unnecessary
4): Incorrect information
5): The behavior it thinks needs to be fixed is actually intentional.    

It does feel like another developer reviewing my work though (but without thorough investigation). So I feel the tool can be helpful.
author_association: COLLABORATOR
user: login: SebinSong
created_at: 2025-04-28T06:29:08Z
body: @taoeffect 
It does display the `mark unread` menu in chatroom to myself:

<img src='https://github.com/user-attachments/assets/dd14f1a7-6328-44fa-a30d-21f63ebe624e' width='360'>

<img src='https://github.com/user-attachments/assets/775d7ae1-79d6-4685-b80b-17b139e28570' width='560'>

Did you tested it with the latest commit?
author_association: COLLABORATOR
user: login: taoeffect
created_at: 2025-04-28T18:25:08Z
body: Oops, you're right @SebinSong! I must've been working off of old changes, my mistake, sorry about that!
author_association: MEMBER