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} + + )} + +
+ {sheetView === "groups" ? ( + groupOptions.map((option) => ( + + )) + ) : ( + <> + {groupOptions.length > 0 && ( + + )} + {actions.map((action) => ( + + ))} + + )} +
+
+
+ + ) +} 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) &&
{lastMessage && } - {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)