diff --git a/apps/web/src/components/channel-sidebar/ChannelSidebar.tsx b/apps/web/src/components/channel-sidebar/ChannelSidebar.tsx
index a39c7c7bd..77d6c3910 100644
--- a/apps/web/src/components/channel-sidebar/ChannelSidebar.tsx
+++ b/apps/web/src/components/channel-sidebar/ChannelSidebar.tsx
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react"
import { useSetAtom } from "jotai"
import { NavLink, useMatch, useNavigate, useParams } from "react-router-dom"
import { useHotkeys } from "react-hotkeys-hook"
-import { Check, ChevronDown, ChevronRight, Hash, PencilLine, Star } from "lucide-react"
+import { BellOff, Check, ChevronDown, ChevronRight, Hash, PencilLine, Star } from "lucide-react"
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"
import { useLocalStorage } from "usehooks-ts"
import { useChannelUnread, useGroupUnreadCount, useWorkspaceUnread } from "@stores/unread/useChannelUnread"
@@ -23,6 +23,7 @@ import {
} from "@components/ui/dropdown-menu"
import { Avatar, AvatarFallback, AvatarImage } from "@components/ui/avatar"
import { ChannelIcon } from "@components/common/ChannelIcon/ChannelIcon"
+import { ChannelRowActions } from "@components/common/ChannelRowActions"
import { CustomizeSidebarButton } from "@components/features/channel/CustomizeSidebar/CustomizeSidebarButton"
import { MobileSearchButton } from "@components/features/header/QuickSearch/SearchButton"
import { useWorkspaces, type WorkspaceFields } from "@hooks/useWorkspaces"
@@ -38,6 +39,8 @@ import { useChannelDraft } from "@components/features/ChatInput/draft"
import { Tooltip, TooltipContent, TooltipTrigger } from "@components/ui/tooltip"
import type { ChannelListItem } from "@raven/types/common/ChannelListItem"
import { useIsMobile } from "@hooks/use-mobile"
+import { useLongPress } from "@hooks/useLongPress"
+import { hapticTick } from "@utils/haptics"
interface GroupsState {
[key: string]: boolean
@@ -413,19 +416,32 @@ const ChannelRow = ({ channel, workspaceID }: { channel: ChannelListItem; worksp
// Channel rows have no preview line — an unsent draft shows as a pencil.
const draft = useChannelDraft(channel.name)
+ const isMobile = useIsMobile()
+ const [sheetOpen, setSheetOpen] = useState(false)
+ const [menuOpen, setMenuOpen] = useState(false)
+ const longPress = useLongPress(() => {
+ hapticTick()
+ setSheetOpen(true)
+ }, isMobile)
+
return (
cn(
- "flex min-w-0 select-none items-center gap-1.5 overflow-hidden rounded text-base px-3 md:px-2 text-ink-gray-6 dark:text-ink-gray-7 py-2 md:py-1.5",
+ "group/row flex min-w-0 select-none items-center gap-1.5 overflow-hidden rounded text-base px-3 md:px-2 text-ink-gray-6 dark:text-ink-gray-7 py-2 md:py-1.5",
// `transition` (not transition-colors) so box-shadow animates IN SYNC
// with the background — Virtuoso recycles rows on workspace switch, and
// transition-colors left the shadow popping while the bg cross-faded.
"outline-none ring-outline-gray-2 transition focus-visible:ring-2",
"hover:bg-surface-gray-3 active:bg-surface-gray-3",
unread > 0 && !channel.muted && "text-ink-gray-7 dark:text-ink-gray-8",
+ // Menu/sheet open pins the HOVER look (not the route-active raised
+ // look): the pointer may leave the row while its menu is up, and
+ // the highlight should hold until the menu closes.
+ (menuOpen || sheetOpen) && "bg-surface-gray-3",
isActive && "bg-surface-elevation-3 shadow-sm text-ink-gray-8 dark:text-ink-gray-9 hover:bg-surface-elevation-3 active:bg-surface-elevation-3",
)
}
@@ -453,11 +469,34 @@ const ChannelRow = ({ channel, workspaceID }: { channel: ChannelListItem; worksp
)}
+ {/* Badge yields its slot to the kebab on hover/menu-open (display swap,
+ like the muted marker below) — the kebab appears in its place instead
+ of alongside it. */}
{unread > 0 && !channel.muted && (
-
+
{unread > 9 ? "9+" : unread}
)}
+ {/* Notifications-muted marker (allow_notifications — the flag the kebab's
+ Mute and the settings panel's bell both flip): in-flow in the badge
+ slot, swapping display with the kebab (hover/menu-open) so the two
+ never occupy space together. */}
+ {channel.allow_notifications === 0 && (
+
+ )}
+
)
}
diff --git a/apps/web/src/components/common/ChannelRowActions.tsx b/apps/web/src/components/common/ChannelRowActions.tsx
new file mode 100644
index 000000000..0511ee6cf
--- /dev/null
+++ b/apps/web/src/components/common/ChannelRowActions.tsx
@@ -0,0 +1,379 @@
+import { useContext, useEffect, useState } from "react"
+import { FrappeConfig, FrappeContext, useFrappeUpdateDoc } from "frappe-react-sdk"
+import { toast } from "sonner"
+import { Bell, BellOff, Check, Folder, Link, MessageSquareDot, MoreVertical, Star, X, type LucideIcon } from "lucide-react"
+import { Button } from "@components/ui/button"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "@components/ui/dropdown-menu"
+import { Drawer, DrawerContent, DrawerTitle } from "@components/ui/drawer"
+import { ChannelIcon } from "@components/common/ChannelIcon/ChannelIcon"
+import { errorResponseToast } from "@components/ui/error-banner"
+import { channelStore } from "@stores/channels/store"
+import { useChannelById } from "@stores/channels/useChannelList"
+import { useChannelUnread } from "@stores/unread/useChannelUnread"
+import { useMarkUnread } from "@stores/unread/useMarkUnread"
+import useCurrentRavenUser from "@raven/lib/hooks/useCurrentRavenUser"
+import { assignChannelToGroup } from "@raven/lib/utils/channelGroups"
+import type { RavenUser } from "@raven/types/Raven/RavenUser"
+import { cn } from "@lib/utils"
+import _ from "@lib/translate"
+
+type RowAction = {
+ id: string
+ label: string
+ icon: LucideIcon
+ onSelect: () => void
+}
+
+/** One assignable target in the group submenu — mirrors ChannelGroupSelect's options
+ * (Favorites, the user's groups, Clear when assigned), minus "New group". */
+type GroupOption = {
+ id: string
+ label: string
+ /** null = clear the assignment (ungroup/unpin). */
+ target: string | null
+ /** The channel's current assignment — rendered as a trailing check. */
+ isCurrent: boolean
+ /** Favorites gets the filled star, Clear gets the X; plain groups have no icon. */
+ icon?: "star" | "clear"
+}
+
+/**
+ * The channel-level actions for one sidebar row. Same { id, label, icon, onSelect }
+ * shape as useMessageActions, so the dropdown and the sheet render identically from
+ * one source; the group submenu is separate structured data (it nests).
+ *
+ * Order (user-specified): Move to group, Copy link, Mute, Mark as unread.
+ * DM rows: Mute, Mark as unread.
+ */
+const useChannelRowActions = ({ channelID, isDM, workspaceID }: Pick) => {
+ const { call } = useContext(FrappeContext) as FrappeConfig
+ const markUnread = useMarkUnread()
+ const channel = useChannelById(channelID)
+ const notificationsOff = channel?.allow_notifications === 0
+
+ // Group/Favorites assignment lives on the Raven User doc (grouped_channels /
+ // pinned_channels child tables) — the same rows the customize-sidebar dialog
+ // edits, written here immediately (no Save step) through the shared, tested
+ // assignChannelToGroup helper (pinned XOR grouped).
+ const { myProfile, mutate: mutateUser } = useCurrentRavenUser()
+ const { updateDoc } = useFrappeUpdateDoc()
+
+ const isPinned = myProfile?.pinned_channels?.some((row) => row.channel_id === channelID) ?? false
+ const currentGroup = myProfile?.grouped_channels?.find((row) => row.channel_id === channelID)?.channel_group
+
+ const assignToGroup = (target: string | null) => {
+ if (!myProfile) return
+ const { grouped, pinned } = assignChannelToGroup(
+ myProfile.grouped_channels ?? [],
+ myProfile.pinned_channels ?? [],
+ channelID,
+ target,
+ )
+ updateDoc("Raven User", myProfile.name, {
+ grouped_channels: grouped,
+ pinned_channels: pinned,
+ })
+ .then((doc) => mutateUser({ message: doc as RavenUser }, { revalidate: false }))
+ .catch((error) => errorResponseToast(_("Could not move channel"), error))
+ }
+
+ // Mirrors ChannelGroupSelect: Favorites first, then the user's groups (doc
+ // order), then Clear — only when the channel is assigned somewhere. No
+ // "New group" here; groups are created in the customize-sidebar dialog.
+ const groupOptions: GroupOption[] = isDM
+ ? []
+ : [
+ { id: "favorites", label: _("Favorites"), target: "Favorites", isCurrent: isPinned, icon: "star" as const },
+ ...(myProfile?.channel_groups ?? []).map((group) => ({
+ id: group.name ?? group.group_name,
+ label: group.group_name,
+ target: group.group_name,
+ isCurrent: currentGroup === group.group_name,
+ })),
+ ...(isPinned || currentGroup
+ ? [{ id: "clear", label: _("Clear"), target: null, isCurrent: false, icon: "clear" as const }]
+ : []),
+ ]
+
+ // Channel rows only — a link to someone else's DM is useless to the recipient.
+ const copyLink = () => {
+ const base = import.meta.env.VITE_BASE_NAME ? `/${import.meta.env.VITE_BASE_NAME}` : ""
+ const path = `/${encodeURIComponent(workspaceID ?? "")}/${encodeURIComponent(channelID)}`
+ navigator.clipboard.writeText(`${window.location.origin}${base}${path}`)
+ toast.success(_("Link copied"))
+ }
+
+ // TODO(unify-mute): "Mute" here flips `allow_notifications` (push) through the
+ // SAME endpoint and member flag as the settings panel's bell dropdown — one
+ // knob, two surfaces. The separate `muted` member flag (badge/bold/aggregate
+ // suppression — see the notification-prefs TODO in ChannelSettingsTab) still
+ // has no writer; when the notification pipeline work lands, fold both flags
+ // into one user-facing "mute" so silencing a channel also quiets its badge.
+ const toggleMute = () => {
+ const member = channel?.member_id
+ if (!member) return
+ const next: 0 | 1 = notificationsOff ? 1 : 0
+ // Deliberately NOT optimistic: the row's bell-off marker and the menu
+ // label flip only once the server confirms, so the marker never shows
+ // for a toggle that then fails.
+ call.post("raven.api.notification.toggle_push_notification_for_channel", {
+ member,
+ allow_notifications: next,
+ })
+ .then(() => channelStore.patchChannel(channelID, { allow_notifications: next }))
+ .catch((error) => {
+ // Generic on purpose — this path serves channels and DM conversations.
+ errorResponseToast(notificationsOff ? _("Could not unmute") : _("Could not mute"), error)
+ })
+ }
+
+ // Unread/mute state lives on the user's Raven Channel Member row, so both
+ // need one to exist. Mark-unread additionally works on Open channels with no
+ // row yet (the backend auto-creates the watermark there — same rule as
+ // track_channel_visit); for non-member Public/Private channels it would
+ // silently no-op, and mute would throw — so neither is offered. An already
+ // unread channel has nothing to mark either — the badge is already up.
+ const { count: unreadCount } = useChannelUnread(channelID)
+ const isMember = Boolean(channel?.member_id)
+ const canMarkUnread = (isMember || channel?.type === "Open") && unreadCount === 0
+
+ const channelType = channel?.type
+
+ const actions: RowAction[] = isDM
+ ? [
+ // DM participants always have a member row, but keep the same gate as
+ // channels — a stale store entry without member_id must not offer a
+ // toggle the server will reject.
+ ...(isMember
+ ? [{ id: "mute", label: notificationsOff ? _("Unmute conversation") : _("Mute conversation"), icon: notificationsOff ? Bell : BellOff, onSelect: toggleMute }]
+ : []),
+ ...(canMarkUnread
+ ? [{ id: "mark-unread", label: _("Mark as unread"), icon: MessageSquareDot, onSelect: () => markUnread(channelID) }]
+ : []),
+ ]
+ : [
+ { id: "copy-link", label: _("Copy link"), icon: Link, onSelect: copyLink },
+ ...(isMember
+ ? [{ id: "mute", label: notificationsOff ? _("Unmute channel") : _("Mute channel"), icon: notificationsOff ? Bell : BellOff, onSelect: toggleMute }]
+ : []),
+ ...(canMarkUnread
+ ? [{ id: "mark-unread", label: _("Mark as unread"), icon: MessageSquareDot, onSelect: () => markUnread(channelID) }]
+ : []),
+ ]
+
+ return { actions, groupOptions, assignToGroup, channelType }
+}
+
+/** The submenu/subview row for one group option: star for Favorites, X for Clear,
+ * trailing check on the channel's current assignment. */
+const GroupOptionContent = ({ option }: { option: GroupOption }) => (
+ <>
+ {option.icon === "star" && }
+ {option.icon === "clear" && }
+ {option.label}
+ {option.isCurrent && }
+ >
+)
+
+export type ChannelRowActionsProps = {
+ channelID: string
+ /** Sheet title — channel name or DM peer name. */
+ channelName: string
+ /** DM rows get the reduced set: Mute + Mark as unread. */
+ isDM?: boolean
+ /** For the channel-row copy-link URL; unused for DMs. */
+ workspaceID?: string
+ /** Mobile long-press sheet — owned by the row (which owns the long-press). */
+ sheetOpen: boolean
+ onSheetOpenChange: (open: boolean) => void
+ /** Desktop dropdown open state — rows use it for meta-hide + the active look. */
+ onMenuOpenChange?: (open: boolean) => void
+}
+
+/**
+ * One sidebar row's actions, in both shells:
+ *
+ * Desktop — a hover/focus-revealed kebab rendered IN-FLOW in the unread
+ * badge's slot (rows mount this component right after the badge), opening a
+ * DropdownMenu with "Move to group" as a nested submenu. Per-row Radix
+ * instances are fine here (unlike the message stream): Virtuoso mounts ~20-30
+ * rows and a closed menu renders only its trigger. The kebab lives INSIDE the
+ * row NavLink, so its events are stopped to keep the row from navigating.
+ *
+ * Mobile — a bottom sheet, opened by the row's long-press (the row owns the
+ * gesture + open state; this renders the sheet). "Move to group" swaps the
+ * sheet to the group list (a submenu has nowhere to fly out to on a phone).
+ */
+export const ChannelRowActions = ({
+ channelID,
+ channelName,
+ isDM,
+ workspaceID,
+ sheetOpen,
+ onSheetOpenChange,
+ onMenuOpenChange,
+}: ChannelRowActionsProps) => {
+ const { actions, groupOptions, assignToGroup, channelType } = useChannelRowActions({ channelID, isDM, workspaceID })
+
+ /** Mobile sheet view: the action list, or the pushed group list. */
+ const [sheetView, setSheetView] = useState<"actions" | "groups">("actions")
+ // Reset on OPEN, not close — resetting while the sheet slides out swaps the
+ // content mid-animation and flashes the action list on the way down.
+ useEffect(() => {
+ if (sheetOpen) setSheetView("actions")
+ }, [sheetOpen])
+
+ // Portaled in the DOM but still inside the row NavLink in the React tree —
+ // React bubbles synthetic events through portals along that tree, so an
+ // unstopped click in any of these surfaces would also fire the row's
+ // navigate handler (observed live). Applied to every portaled content.
+ const stopBubbling = {
+ onClick: (event: React.MouseEvent) => event.stopPropagation(),
+ onPointerDown: (event: React.PointerEvent) => event.stopPropagation(),
+ }
+
+ // Nothing offerable (an unread DM loses its only action) — no kebab, no sheet,
+ // rather than an affordance that opens an empty menu.
+ if (actions.length === 0 && groupOptions.length === 0) return null
+
+ return (
+ <>
+ {/* Desktop kebab + dropdown */}
+
+
+
+
+
+ {groupOptions.length > 0 && (
+
+
+
+ {_("Move to group")}
+
+ {/* Its own portal — needs its own bubbling guards. */}
+
+ {groupOptions.map((option, index) => (
+
+ {/* Separators mirror ChannelGroupSelect: after
+ Favorites (when groups exist) and before Clear. */}
+ {index > 0 && (option.id === "clear" || index === 1) && }
+ assignToGroup(option.target)}>
+
+
+
+ ))}
+
+
+ )}
+ {actions.map((action) => (
+
+
+ {action.label}
+
+ ))}
+
+
+
+ {/* Mobile long-press sheet (row owns the gesture; vaul portals this out of the NavLink) */}
+
+ event.preventDefault()} {...stopBubbling}>
+
+ {sheetView === "groups" ? (
+ _("Move to group")
+ ) : (
+ <>
+ {!isDM && }
+ {channelName}
+ >
+ )}
+
+
+
+
+ >
+ )
+}
diff --git a/apps/web/src/components/dm-sidebar/DMSidebar.tsx b/apps/web/src/components/dm-sidebar/DMSidebar.tsx
index 31d2ea73f..be8ccfc55 100644
--- a/apps/web/src/components/dm-sidebar/DMSidebar.tsx
+++ b/apps/web/src/components/dm-sidebar/DMSidebar.tsx
@@ -1,4 +1,4 @@
-import { memo, useEffect, useMemo, useRef } from "react"
+import { memo, useEffect, useMemo, useRef, useState, type ReactNode } from "react"
import { NavLink, useMatch, useNavigate } from "react-router-dom"
import { useHotkeys } from "react-hotkeys-hook"
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"
@@ -22,7 +22,11 @@ import { usePrefetchChannel, setChannelListScrolling } from "@stores/messages/us
import { useUserCookieData } from "@hooks/useUserCookieData"
import { MobileSearchButton } from "@components/features/header/QuickSearch/SearchButton"
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@components/ui/empty"
-import { UsersRoundIcon } from "lucide-react"
+import { BellOff, UsersRoundIcon } from "lucide-react"
+import { ChannelRowActions } from "@components/common/ChannelRowActions"
+import { useIsMobile } from "@hooks/use-mobile"
+import { useLongPress } from "@hooks/useLongPress"
+import { hapticTick } from "@utils/haptics"
/** A DM channel joined to its resolved peer — what a sidebar row renders. */
type DMRowData = { dm: DMChannelListItem; peer: UserData }
@@ -226,7 +230,15 @@ const DMRow = memo(function DMRow({ dmChannel, peerUser }: DMRowProps) {
// it's the thing you'd want to be reminded of when scanning the list.
const draft = useChannelDraft(dmChannel.name)
- return
+ const isMobile = useIsMobile()
+ const [sheetOpen, setSheetOpen] = useState(false)
+ const [menuOpen, setMenuOpen] = useState(false)
+ const longPress = useLongPress(() => {
+ hapticTick()
+ setSheetOpen(true)
+ }, isMobile)
+
+ return
{({ isActive }) => (
+ }
/>
)}
@@ -300,6 +328,19 @@ interface DMRowShellProps {
isDraft?: boolean
unread?: number
isActive: boolean
+ /** The row's ChannelRowActions (kebab + sheet), rendered in-flow in the badge slot. */
+ actions?: ReactNode
+ /** The kebab's dropdown is open — hide the muted marker so they don't stack. */
+ menuOpen?: boolean
+ /** Pin the hover look while the row's menu/sheet is open (pointer may have left). */
+ forceHover?: boolean
+ /** `muted` member flag: badge and unread-bold suppressed. Currently has no
+ * writer (see TODO(unify-mute) in ChannelRowActions) — kept for parity with
+ * channel rows, which carry the same dormant suppression. */
+ muted?: boolean
+ /** `allow_notifications` off — the flag the kebab's Mute flips — shows the
+ * bell-off marker in the badge slot. */
+ notificationsOff?: boolean
}
function DMRowShell({
@@ -310,6 +351,11 @@ function DMRowShell({
isDraft = false,
unread = 0,
isActive,
+ actions,
+ menuOpen = false,
+ forceHover = false,
+ muted = false,
+ notificationsOff = false,
}: DMRowShellProps) {
@@ -319,6 +365,7 @@ function DMRowShell({
"flex w-full items-center gap-3 px-2 py-2 md:py-2 text-sm rounded transition-colors relative text-left",
"select-none",
"hover:bg-surface-gray-3 active:bg-surface-gray-3",
+ forceHover && "bg-surface-gray-3",
isActive && "bg-surface-elevation-3 hover:bg-surface-elevation-3 active:bg-surface-elevation-3 shadow-sm"
)}
>
@@ -338,7 +385,7 @@ function DMRowShell({
// contain descenders (g/y/p) once `truncate` clips overflow — Safari
// cuts them on some DPIs. A looser single-line height fixes it.
"truncate text-lg md:text-sm leading-snug text-ink-gray-8",
- unread > 0 ? "font-semibold" : "font-normal"
+ unread > 0 && !muted ? "font-semibold" : "font-normal"
)}
>
{name}
@@ -349,7 +396,10 @@ function DMRowShell({
)}
- {(lastMessage || unread > 0) &&
+ {/* The bottom line also hosts the in-flow marker + kebab (the badge's
+ slot) — rendered even with no preview/unread so the kebab stays
+ reachable on empty conversations. */}
+ {(lastMessage || unread > 0 || notificationsOff || actions) &&
}
- {unread > 0 && (
-
+ {/* Badge yields its slot to the kebab on hover/menu-open (display
+ swap, like the muted marker below). */}
+ {unread > 0 && !muted && (
+
{unread > 9 ? "9+" : unread}
)}
+ {/* Notifications-muted marker (allow_notifications — the flag the
+ kebab's Mute flips): in-flow in the badge slot, swapping display
+ with the kebab (hover/menu-open). Mirrors ChannelRow's marker. */}
+ {notificationsOff && (
+
+ )}
+ {actions}
}
diff --git a/apps/web/src/components/features/message/actions/useMessageActions.tsx b/apps/web/src/components/features/message/actions/useMessageActions.tsx
index 08793368a..a5c5cbfc6 100644
--- a/apps/web/src/components/features/message/actions/useMessageActions.tsx
+++ b/apps/web/src/components/features/message/actions/useMessageActions.tsx
@@ -8,6 +8,7 @@ import {
BookmarkMinus,
Copy,
Link,
+ MessageSquareDot,
MessageSquareText,
Edit3Icon,
Eye,
@@ -31,6 +32,7 @@ import { isOptimistic } from "@stores/messages/types"
import { channelStore } from "@stores/channels/store"
import { useChannelPinnedString } from "@stores/channels/useChannelList"
import { seedThreadMeta } from "@stores/threads/useThreadMeta"
+import { useMarkUnread } from "@stores/unread/useMarkUnread"
import _ from "@lib/translate"
import type { Message } from "@raven/types/common/Message"
import { useUserCookieData } from "@hooks/useUserCookieData"
@@ -125,6 +127,7 @@ export const useMessageActions = (
const includeFileActions = options?.includeFileActions ?? true
const { name: currentUser } = useUserCookieData()
const setDialog = useSetAtom(messageDialogAtom)
+ const markUnread = useMarkUnread()
const navigateFromDrawer = useNavigateFromDrawer()
const { call } = useContext(FrappeContext) as FrappeConfig
// Pinned state lives on the channel, and pinning doesn't change the message object —
@@ -369,6 +372,24 @@ export const useMessageActions = (
},
},
)
+ // Mark unread from THIS message: the anchor and everything after it become
+ // unread (Slack semantics — allowed on your own messages too; they never
+ // count toward the badge, so anchoring on one only affects what follows).
+ // Channels + DMs only: both the channel-unread store and the sidebar badge
+ // are channel-keyed, so inside a thread this would visibly do nothing —
+ // same parentChannel gate as Create thread. Not gated on canInteract:
+ // read-state operation, valid in archived channels. It IS gated on having
+ // a member row (or being an Open channel, where the backend auto-creates
+ // the watermark): unread state lives on Raven Channel Member.last_visit,
+ // and for non-member Public/Private channels the server silently no-ops.
+ if (parentChannel && (parentChannel.member_id || parentChannel.type === "Open")) {
+ organize.push({
+ id: "mark-unread",
+ label: _("Mark as unread"),
+ icon: MessageSquareDot,
+ onSelect: () => markUnread(message.channel_id, message.name),
+ })
+ }
if (hasReactions) {
organize.push({
id: "reactions",
diff --git a/apps/web/src/stores/unread/useMarkUnread.ts b/apps/web/src/stores/unread/useMarkUnread.ts
new file mode 100644
index 000000000..3e61f9757
--- /dev/null
+++ b/apps/web/src/stores/unread/useMarkUnread.ts
@@ -0,0 +1,43 @@
+import { useCallback, useContext } from "react"
+import { FrappeConfig, FrappeContext, useSWRConfig } from "frappe-react-sdk"
+import { toast } from "sonner"
+import { errorResponseToast } from "@components/ui/error-banner"
+import _ from "@lib/translate"
+
+/**
+ * Mark a channel unread — from a specific message (the anchor and everything
+ * after become unread) or, with no messageID, from the channel's latest message.
+ *
+ * The store update rides the realtime `mark_unread` event the backend publishes
+ * to all of this user's sessions (exact watermark + server-computed count —
+ * useUnreadRealtime routes it to channelUnreadStore.markUnread, which also rolls
+ * the read tracker's baseline back so re-entering the channel re-reads it). On
+ * the POST response we additionally revalidate the authoritative counts as a
+ * socket-down fallback, instead of re-deriving watermarks client-side.
+ *
+ * The success toast is the only immediate feedback on mobile, where the sidebar
+ * badge isn't visible from inside a channel.
+ */
+export const useMarkUnread = () => {
+ const { call } = useContext(FrappeContext) as FrappeConfig
+ const { mutate } = useSWRConfig()
+
+ return useCallback(
+ (channelID: string, messageID?: string) => {
+ call.post("raven.api.raven_channel_member.mark_channel_as_unread", {
+ channel_id: channelID,
+ ...(messageID ? { message_id: messageID } : {}),
+ })
+ .then((response: { message?: number | null }) => {
+ // A null count means the server had nothing to anchor on (no
+ // member row on a non-Open channel, or no messages from anyone
+ // else) and no-oped — don't claim success for it.
+ if (response?.message == null) return
+ toast.success(_("Marked as unread"))
+ mutate("unread_channel_counts")
+ })
+ .catch((error) => errorResponseToast(_("Could not mark as unread"), error))
+ },
+ [call, mutate],
+ )
+}
diff --git a/raven/tests/test_read_receipts.py b/raven/tests/test_read_receipts.py
index 14f7f7a8c..02672fefe 100644
--- a/raven/tests/test_read_receipts.py
+++ b/raven/tests/test_read_receipts.py
@@ -107,6 +107,28 @@ def test_set_channel_unread_anchors_to_message(self):
# unread counts exclude the user's own messages; m1,m2,m3 authored by `test`
self.assertEqual(get_unread_count_for_channel(self.channel.name), 2)
+ def test_set_channel_unread_skips_own_latest_message(self):
+ """Channel-level mark-unread must anchor past the user's own trailing
+ messages: own messages never count toward the badge, so anchoring on one
+ would mark the channel 'unread' with a count of zero — invisibly."""
+ from raven.api.raven_message import get_unread_count_for_channel
+ from raven.utils import set_channel_unread
+
+ frappe.set_user("test@example.com")
+ self._send("from someone else")
+
+ # test1 replies last, then marks the channel unread with no explicit anchor.
+ frappe.set_user("test1@example.com")
+ own_reply = self._send("my own reply")
+ self._set_last_visit(
+ "test1@example.com", frappe.utils.add_to_date(own_reply.creation, seconds=5)
+ )
+
+ set_channel_unread(self.channel.name)
+
+ # Anchor skipped the own reply and landed on test@'s message.
+ self.assertEqual(get_unread_count_for_channel(self.channel.name), 1)
+
def test_set_channel_unread_no_message_uses_latest(self):
from raven.api.raven_message import get_unread_count_for_channel
from raven.utils import set_channel_unread
diff --git a/raven/utils.py b/raven/utils.py
index 0d2b3fbb8..508499aaa 100644
--- a/raven/utils.py
+++ b/raven/utils.py
@@ -109,10 +109,13 @@ def set_channel_unread(channel_id: str, message_id: str = None, user: str = None
anchor message — the anchor and everything after it become unread.
Anchor: `message_id` when given, else the latest non-System message in the
- channel. The new watermark is set one microsecond below the anchor's
- creation, so the anchor becomes the first unread message and everything
- before it stays read (when the anchor is the channel's first message this
- marks the whole channel unread).
+ channel NOT authored by the user — unread counts exclude the user's own
+ messages, so anchoring on one would produce a count of zero and the mark
+ would be invisible (the "I replied last, now mark it unread" case). The new
+ watermark is set one microsecond below the anchor's creation, so the anchor
+ becomes the first unread message and everything before it stays read (when
+ the anchor is the channel's first message this marks the whole channel
+ unread).
This is the one intentional *backward* watermark move, so it writes
`last_visit` directly and bypasses the monotonic guard in
@@ -140,6 +143,7 @@ def set_channel_unread(channel_id: str, message_id: str = None, user: str = None
.select(message.creation)
.where(message.channel_id == channel_id)
.where(message.message_type != "System")
+ .where(message.owner != user)
.orderby(message.creation, order=Order.desc)
.orderby(message.name, order=Order.desc)
.limit(1)