From 29c409fad8fbb62dc95c446b49aa6c14108a2ea3 Mon Sep 17 00:00:00 2001 From: Max Litruv Boonzaayer Date: Sat, 28 Feb 2026 00:10:35 +1100 Subject: [PATCH] feat: implement useMarkAsRead hook for improved unread state management and update notification handling --- .../UpdateNotification.css.ts | 19 +- .../UpdateNotification.tsx | 52 +++-- src/app/features/room-nav/RoomNavItem.tsx | 5 +- src/app/features/room/Room.tsx | 7 +- src/app/features/room/RoomTimeline.tsx | 15 +- src/app/features/room/RoomViewHeader.tsx | 5 +- src/app/hooks/useMarkAsRead.ts | 51 +++++ src/app/pages/client/direct/Direct.tsx | 5 +- src/app/pages/client/home/Home.tsx | 5 +- src/app/pages/client/inbox/Notifications.tsx | 5 +- src/app/pages/client/sidebar/DirectTab.tsx | 21 +- src/app/pages/client/sidebar/HomeTab.tsx | 5 +- src/app/pages/client/sidebar/SpaceTabs.tsx | 5 +- src/app/pages/client/space/Space.tsx | 5 +- src/app/utils/tauri.ts | 203 +++++++++++++----- src/index.tsx | 4 +- 16 files changed, 299 insertions(+), 113 deletions(-) create mode 100644 src/app/hooks/useMarkAsRead.ts diff --git a/src/app/components/update-notification/UpdateNotification.css.ts b/src/app/components/update-notification/UpdateNotification.css.ts index 7a0af37..d78bf7f 100644 --- a/src/app/components/update-notification/UpdateNotification.css.ts +++ b/src/app/components/update-notification/UpdateNotification.css.ts @@ -1,6 +1,14 @@ import { style } from '@vanilla-extract/css'; import { color, config, toRem } from 'folds'; +export const CheckButtonContainer = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: '32px', + width: '32px', +}); + export const CheckButton = style({ all: 'unset', display: 'flex', @@ -11,13 +19,19 @@ export const CheckButton = style({ cursor: 'pointer', color: color.Secondary.Main, backgroundColor: 'transparent', - transition: 'background-color 0.15s', + transition: 'opacity 0.2s, background-color 0.15s', height: '32px', width: '32px', - opacity: 0.7, + opacity: 0, WebkitAppRegion: 'no-drag', flexShrink: 0, + selectors: { + '&[data-visible="true"]': { + opacity: 0.7, + }, + }, + ':hover': { backgroundColor: color.Surface.ContainerHover, opacity: 1, @@ -30,6 +44,7 @@ export const CheckButton = style({ ':focus-visible': { outline: `2px solid ${color.Secondary.Main}`, outlineOffset: '2px', + opacity: 1, }, }); diff --git a/src/app/components/update-notification/UpdateNotification.tsx b/src/app/components/update-notification/UpdateNotification.tsx index 6070568..c9e4626 100644 --- a/src/app/components/update-notification/UpdateNotification.tsx +++ b/src/app/components/update-notification/UpdateNotification.tsx @@ -21,6 +21,7 @@ export function UpdateNotification() { const [downloadProgress, setDownloadProgress] = useState(0); const [updateReady, setUpdateReady] = useState(false); const [checking, setChecking] = useState(false); + const [isHovered, setIsHovered] = useState(false); const [menuAnchor, setMenuAnchor] = useState<{ x: number; y: number; width: number; height: number } | undefined>(undefined); useEffect(() => { @@ -118,29 +119,36 @@ export function UpdateNotification() { // Show check button if no update status if (!updateAvailable && !updateReady && !checking) { return ( - + + ); } diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index b5d1b32..0727e3b 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -29,7 +29,7 @@ import { useRoomUnread } from '../../state/hooks/unread'; import { roomToUnreadAtom } from '../../state/room/roomToUnread'; import { usePowerLevels } from '../../hooks/usePowerLevels'; import { copyToClipboard } from '../../utils/dom'; -import { markAsRead } from '../../utils/notifications'; +import { useMarkAsRead } from '../../hooks/useMarkAsRead'; import { UseStateProvider } from '../../components/UseStateProvider'; import { LeaveRoomPrompt } from '../../components/leave-room-prompt'; import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers'; @@ -68,6 +68,7 @@ const RoomNavItemMenu = forwardRef( const powerLevels = usePowerLevels(room); const creators = useRoomCreators(room); const { startCall, endCall, activeCall, callSupported } = useCall(); + const markAsRead = useMarkAsRead(mx); const permissions = useRoomPermissions(creators, powerLevels); const canInvite = permissions.action('invite', mx.getSafeUserId()); @@ -77,7 +78,7 @@ const RoomNavItemMenu = forwardRef( const [invitePrompt, setInvitePrompt] = useState(false); const handleMarkAsRead = () => { - markAsRead(mx, room.roomId, hideActivity); + markAsRead(room.roomId, hideActivity); requestClose(); }; diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index 51db0d0..e4f438f 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -11,7 +11,7 @@ import { settingsAtom } from '../../state/settings'; import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels'; import { useRoom } from '../../hooks/useRoom'; import { useKeyDown } from '../../hooks/useKeyDown'; -import { markAsRead } from '../../utils/notifications'; +import { useMarkAsRead } from '../../hooks/useMarkAsRead'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useRoomMembers } from '../../hooks/useRoomMembers'; import { activeRoomIdAtom } from '../../state/activeRoom'; @@ -27,6 +27,7 @@ export function Room() { const screenSize = useScreenSizeContext(); const powerLevels = usePowerLevels(room); const members = useRoomMembers(mx, room.roomId); + const markAsRead = useMarkAsRead(mx); // Update titlebar with current room ID useEffect(() => { @@ -39,10 +40,10 @@ export function Room() { useCallback( (evt) => { if (isKeyHotkey('escape', evt)) { - markAsRead(mx, room.roomId, hideActivity); + markAsRead(room.roomId, hideActivity); } }, - [mx, room.roomId, hideActivity] + [markAsRead, room.roomId, hideActivity] ) ); diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index fd75296..49b50c4 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -94,7 +94,7 @@ import { getIntersectionObserverEntry, useIntersectionObserver, } from '../../hooks/useIntersectionObserver'; -import { markAsRead } from '../../utils/notifications'; +import { useMarkAsRead } from '../../hooks/useMarkAsRead'; import { useDebounce } from '../../hooks/useDebounce'; import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver'; import * as css from './RoomTimeline.css'; @@ -508,6 +508,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli const spoilerClickHandler = useSpoilerClickHandler(); const openUserRoomProfile = useOpenUserRoomProfile(); const space = useSpaceOptionally(); + const markAsRead = useMarkAsRead(mx); const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents); @@ -641,7 +642,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli // Check if the document is in focus (user is actively viewing the app), // and either there are no unread messages or the latest message is from the current user. // If either condition is met, trigger the markAsRead function to send a read receipt. - requestAnimationFrame(() => markAsRead(mx, mEvt.getRoomId()!, hideActivity)); + requestAnimationFrame(() => markAsRead(mEvt.getRoomId()!, hideActivity)); } if (!document.hasFocus() && !unreadInfo) { @@ -665,7 +666,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli setUnreadInfo(getRoomUnreadInfo(room)); } }, - [mx, room, unreadInfo, hideActivity] + [mx, room, unreadInfo, hideActivity, markAsRead] ) ); @@ -734,15 +735,15 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli const tryAutoMarkAsRead = useCallback(() => { const readUptoEventId = readUptoEventIdRef.current; if (!readUptoEventId) { - requestAnimationFrame(() => markAsRead(mx, room.roomId, hideActivity)); + requestAnimationFrame(() => markAsRead(room.roomId, hideActivity)); return; } const evtTimeline = getEventTimeline(room, readUptoEventId); const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward); if (latestTimeline === room.getLiveTimeline()) { - requestAnimationFrame(() => markAsRead(mx, room.roomId, hideActivity)); + requestAnimationFrame(() => markAsRead(room.roomId, hideActivity)); } - }, [mx, room, hideActivity]); + }, [room, hideActivity, markAsRead]); const debounceSetAtBottom = useDebounce( useCallback((entry: IntersectionObserverEntry) => { @@ -946,7 +947,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli }; const handleMarkAsRead = () => { - markAsRead(mx, room.roomId, hideActivity); + markAsRead(room.roomId, hideActivity); }; const handleOpenReply: MouseEventHandler = useCallback( diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 2112e92..30f2c56 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -44,8 +44,8 @@ import { _SearchPathSearchParams } from '../../pages/paths'; import * as css from './RoomViewHeader.css'; import { useRoomUnread } from '../../state/hooks/unread'; import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; -import { markAsRead } from '../../utils/notifications'; import { roomToUnreadAtom } from '../../state/room/roomToUnread'; +import { useMarkAsRead } from '../../hooks/useMarkAsRead'; import { copyToClipboard } from '../../utils/dom'; import { LeaveRoomPrompt } from '../../components/leave-room-prompt'; import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta'; @@ -91,11 +91,12 @@ const RoomMenu = forwardRef(({ room, requestClose const notificationPreferences = useRoomsNotificationPreferencesContext(); const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId); const { navigateRoom } = useRoomNavigate(); + const markAsRead = useMarkAsRead(mx); const [invitePrompt, setInvitePrompt] = useState(false); const handleMarkAsRead = () => { - markAsRead(mx, room.roomId, hideActivity); + markAsRead(room.roomId, hideActivity); requestClose(); }; diff --git a/src/app/hooks/useMarkAsRead.ts b/src/app/hooks/useMarkAsRead.ts new file mode 100644 index 0000000..5dc1081 --- /dev/null +++ b/src/app/hooks/useMarkAsRead.ts @@ -0,0 +1,51 @@ +import { useCallback } from 'react'; +import { useSetAtom } from 'jotai'; +import { MatrixClient } from 'matrix-js-sdk'; +import { markAsRead } from '../utils/notifications'; +import { roomToUnreadAtom } from '../state/room/roomToUnread'; + +/** + * Hook that marks a room as read and immediately updates local unread state. + * This fixes the issue where unread dots persist after refresh because the + * server hasn't processed the read receipt yet. + * + * @param mx - Matrix client instance + * @returns A function to mark rooms as read + */ +export function useMarkAsRead(mx: MatrixClient) { + const setUnreadAtom = useSetAtom(roomToUnreadAtom); + + return useCallback( + async (roomId: string, privateReceipt: boolean) => { + // Immediately clear local unread state so UI updates instantly + setUnreadAtom({ type: 'DELETE', roomId }); + + // Then send the read receipt to the server + await markAsRead(mx, roomId, privateReceipt); + }, + [mx, setUnreadAtom] + ); +} + +/** + * Hook that marks multiple rooms as read simultaneously. + * + * @param mx - Matrix client instance + * @returns A function to mark multiple rooms as read + */ +export function useMarkRoomsAsRead(mx: MatrixClient) { + const setUnreadAtom = useSetAtom(roomToUnreadAtom); + + return useCallback( + async (roomIds: string[], privateReceipt: boolean) => { + // Immediately clear local unread state for all rooms + roomIds.forEach((roomId) => { + setUnreadAtom({ type: 'DELETE', roomId }); + }); + + // Send read receipts to the server + await Promise.all(roomIds.map((roomId) => markAsRead(mx, roomId, privateReceipt))); + }, + [mx, setUnreadAtom] + ); +} diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx index 04edf10..1a0955a 100644 --- a/src/app/pages/client/direct/Direct.tsx +++ b/src/app/pages/client/direct/Direct.tsx @@ -42,7 +42,7 @@ import { useDirectRooms } from './useDirectRooms'; import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page'; import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories'; import { useRoomsUnread } from '../../../state/hooks/unread'; -import { markAsRead } from '../../../utils/notifications'; +import { useMarkRoomsAsRead } from '../../../hooks/useMarkAsRead'; import { stopPropagation } from '../../../utils/keyboard'; import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; @@ -60,10 +60,11 @@ const DirectMenu = forwardRef(({ requestClose } const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const orphanRooms = useDirectRooms(); const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); + const markRoomsAsRead = useMarkRoomsAsRead(mx); const handleMarkAsRead = () => { if (!unread) return; - orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); + markRoomsAsRead(orphanRooms, hideActivity); requestClose(); }; diff --git a/src/app/pages/client/home/Home.tsx b/src/app/pages/client/home/Home.tsx index 33fc3a7..3de27bf 100644 --- a/src/app/pages/client/home/Home.tsx +++ b/src/app/pages/client/home/Home.tsx @@ -54,7 +54,7 @@ import { useCategoryHandler } from '../../../hooks/useCategoryHandler'; import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper'; import { PageNav, PageNavHeader, PageNavContent } from '../../../components/page'; import { useRoomsUnread } from '../../../state/hooks/unread'; -import { markAsRead } from '../../../utils/notifications'; +import { useMarkRoomsAsRead } from '../../../hooks/useMarkAsRead'; import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories'; import { stopPropagation } from '../../../utils/keyboard'; import { useSetting } from '../../../state/hooks/settings'; @@ -78,10 +78,11 @@ const HomeMenu = forwardRef( const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); const mx = useMatrixClient(); + const markRoomsAsRead = useMarkRoomsAsRead(mx); const handleMarkAsRead = () => { if (!unread) return; - orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); + markRoomsAsRead(orphanRooms, hideActivity); requestClose(); }; diff --git a/src/app/pages/client/inbox/Notifications.tsx b/src/app/pages/client/inbox/Notifications.tsx index 509fbcf..beff268 100644 --- a/src/app/pages/client/inbox/Notifications.tsx +++ b/src/app/pages/client/inbox/Notifications.tsx @@ -73,7 +73,7 @@ import * as customHtmlCss from '../../../styles/CustomHtml.css'; import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; import { useRoomUnread } from '../../../state/hooks/unread'; import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; -import { markAsRead } from '../../../utils/notifications'; +import { useMarkAsRead } from '../../../hooks/useMarkAsRead'; import { ContainerColor } from '../../../styles/ContainerColor.css'; import { VirtualTile } from '../../../components/virtualizer'; import { UserAvatar } from '../../../components/user-avatar'; @@ -374,6 +374,7 @@ function RoomNotificationsGroupComp({ const mentionClickHandler = useMentionClickHandler(room.roomId); const spoilerClickHandler = useSpoilerClickHandler(); + const markAsRead = useMarkAsRead(mx); const linkifyOpts = useMemo( () => ({ @@ -540,7 +541,7 @@ function RoomNotificationsGroupComp({ onOpen(room.roomId, eventId); }; const handleMarkAsRead = () => { - markAsRead(mx, room.roomId, hideActivity); + markAsRead(room.roomId, hideActivity); }; return ( diff --git a/src/app/pages/client/sidebar/DirectTab.tsx b/src/app/pages/client/sidebar/DirectTab.tsx index dcd44e2..ad28f18 100644 --- a/src/app/pages/client/sidebar/DirectTab.tsx +++ b/src/app/pages/client/sidebar/DirectTab.tsx @@ -3,6 +3,9 @@ import { useNavigate } from 'react-router-dom'; import { Box, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config, toRem } from 'folds'; import FocusTrap from 'focus-trap-react'; import { useAtomValue } from 'jotai'; +import PaarrotSVG from '../../../../../public/res/svg/paarrot.svg'; +import PaarrotUnreadSVG from '../../../../../public/res/svg/paarrot-unread.svg'; +import PaarrotHighlightSVG from '../../../../../public/res/svg/paarrot-highlight.svg'; import { useDirects } from '../../../state/hooks/roomList'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { mDirectAtom } from '../../../state/mDirectList'; @@ -21,7 +24,7 @@ import { UnreadBadge } from '../../../components/unread-badge'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath'; import { useDirectRooms } from '../direct/useDirectRooms'; -import { markAsRead } from '../../../utils/notifications'; +import { useMarkRoomsAsRead } from '../../../hooks/useMarkAsRead'; import { stopPropagation } from '../../../utils/keyboard'; import { settingsAtom } from '../../../state/settings'; import { useSetting } from '../../../state/hooks/settings'; @@ -34,10 +37,11 @@ const DirectMenu = forwardRef(({ requestClose } const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); const mx = useMatrixClient(); + const markRoomsAsRead = useMarkRoomsAsRead(mx); const handleMarkAsRead = () => { if (!unread) return; - orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); + markRoomsAsRead(orphanRooms, hideActivity); requestClose(); }; @@ -98,11 +102,20 @@ export function DirectTab() { - + Direct Messages )} diff --git a/src/app/pages/client/sidebar/HomeTab.tsx b/src/app/pages/client/sidebar/HomeTab.tsx index 73d5051..be7d505 100644 --- a/src/app/pages/client/sidebar/HomeTab.tsx +++ b/src/app/pages/client/sidebar/HomeTab.tsx @@ -22,7 +22,7 @@ import { UnreadBadge } from '../../../components/unread-badge'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath'; import { useHomeRooms } from '../home/useHomeRooms'; -import { markAsRead } from '../../../utils/notifications'; +import { useMarkRoomsAsRead } from '../../../hooks/useMarkAsRead'; import { stopPropagation } from '../../../utils/keyboard'; import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; @@ -39,10 +39,11 @@ const HomeMenu = forwardRef(({ requestClose, onHi const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); const mx = useMatrixClient(); + const markRoomsAsRead = useMarkRoomsAsRead(mx); const handleMarkAsRead = () => { if (!unread) return; - orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); + markRoomsAsRead(orphanRooms, hideActivity); requestClose(); }; diff --git a/src/app/pages/client/sidebar/SpaceTabs.tsx b/src/app/pages/client/sidebar/SpaceTabs.tsx index a43182a..fa022a2 100644 --- a/src/app/pages/client/sidebar/SpaceTabs.tsx +++ b/src/app/pages/client/sidebar/SpaceTabs.tsx @@ -90,7 +90,7 @@ import { useOpenedSidebarFolderAtom } from '../../../state/hooks/openedSidebarFo import { usePowerLevels } from '../../../hooks/usePowerLevels'; import { useRoomsUnread } from '../../../state/hooks/unread'; import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; -import { markAsRead } from '../../../utils/notifications'; +import { useMarkRoomsAsRead } from '../../../hooks/useMarkAsRead'; import { copyToClipboard } from '../../../utils/dom'; import { stopPropagation } from '../../../utils/keyboard'; import { getMatrixToRoom } from '../../../plugins/matrix-to'; @@ -123,6 +123,7 @@ const SpaceMenu = forwardRef( const permissions = useRoomPermissions(creators, powerLevels); const canInvite = permissions.action('invite', mx.getSafeUserId()); const openSpaceSettings = useOpenSpaceSettings(); + const markRoomsAsRead = useMarkRoomsAsRead(mx); const [invitePrompt, setInvitePrompt] = useState(false); @@ -134,7 +135,7 @@ const SpaceMenu = forwardRef( const unread = useRoomsUnread(allChild, roomToUnreadAtom); const handleMarkAsRead = () => { - allChild.forEach((childRoomId) => markAsRead(mx, childRoomId, hideActivity)); + markRoomsAsRead(allChild, hideActivity); requestClose(); }; diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index 3f60d2a..88894e5 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -59,7 +59,7 @@ import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page import { usePowerLevels } from '../../../hooks/usePowerLevels'; import { useRecursiveChildScopeFactory, useSpaceChildren } from '../../../state/hooks/roomList'; import { roomToParentsAtom } from '../../../state/room/roomToParents'; -import { markAsRead } from '../../../utils/notifications'; +import { useMarkRoomsAsRead } from '../../../hooks/useMarkAsRead'; import { useRoomsUnread } from '../../../state/hooks/unread'; import { UseStateProvider } from '../../../components/UseStateProvider'; import { LeaveSpacePrompt } from '../../../components/leave-space-prompt'; @@ -101,6 +101,7 @@ const SpaceMenu = forwardRef(({ room, requestClo const canInvite = permissions.action('invite', mx.getSafeUserId()); const openSpaceSettings = useOpenSpaceSettings(); const { navigateRoom } = useRoomNavigate(); + const markRoomsAsRead = useMarkRoomsAsRead(mx); const [invitePrompt, setInvitePrompt] = useState(false); @@ -112,7 +113,7 @@ const SpaceMenu = forwardRef(({ room, requestClo const unread = useRoomsUnread(allChild, roomToUnreadAtom); const handleMarkAsRead = () => { - allChild.forEach((childRoomId) => markAsRead(mx, childRoomId, hideActivity)); + markRoomsAsRead(allChild, hideActivity); requestClose(); }; diff --git a/src/app/utils/tauri.ts b/src/app/utils/tauri.ts index 6a4a848..26082c5 100644 --- a/src/app/utils/tauri.ts +++ b/src/app/utils/tauri.ts @@ -4,29 +4,42 @@ */ export const openExternalUrl = async (url: string): Promise => { console.log('[openExternalUrl] called with:', url); - console.log('[openExternalUrl] __TAURI__ exists:', !!(window as any).__TAURI__); - // Only run this in Tauri builds - if (typeof window !== 'undefined' && (window as any).__TAURI__) { + // Use Electron's shell.openExternal if in Electron + if (isElectron()) { + try { + const electron = (window as any).electron; + if (electron?.shell?.openExternal) { + await electron.shell.openExternal(url); + console.log('[openExternalUrl] Electron shell.openExternal succeeded'); + return; + } + } catch (err) { + console.warn('[openExternalUrl] Electron shell.openExternal failed:', err); + } + } + + // Use Tauri for actual Tauri builds + if (isTauri() && !isElectron()) { try { // First, try the plugin directly (works on both desktop and mobile) - console.log('[openExternalUrl] trying opener plugin...'); + console.log('[openExternalUrl] trying Tauri opener plugin...'); const { openUrl } = await import('@tauri-apps/plugin-opener'); await openUrl(url); - console.log('[openExternalUrl] plugin succeeded'); + console.log('[openExternalUrl] Tauri plugin succeeded'); return; } catch (pluginErr) { - console.warn('[openExternalUrl] opener plugin failed:', pluginErr); + console.warn('[openExternalUrl] Tauri opener plugin failed:', pluginErr); // Fallback: try the custom command (useful if plugin fails due to ACL) try { - console.log('[openExternalUrl] trying invoke command fallback...'); + console.log('[openExternalUrl] trying Tauri invoke command fallback...'); const { invoke } = await import('@tauri-apps/api/core'); await invoke('open_external_url', { url }); - console.log('[openExternalUrl] command fallback succeeded'); + console.log('[openExternalUrl] Tauri command fallback succeeded'); return; } catch (invokeErr) { - console.error('[openExternalUrl] command fallback also failed:', invokeErr); + console.error('[openExternalUrl] Tauri command fallback also failed:', invokeErr); } } } @@ -79,15 +92,29 @@ let notificationTapCallback: ((path: string) => void) | null = null; * Bring the Tauri window to the front and focus it */ export const focusWindow = async (): Promise => { - if (!isTauri()) return; + // Use Electron's window focus if in Electron + if (isElectron()) { + try { + const electron = (window as any).electron; + if (electron?.window?.focus) { + await electron.window.focus(); + } + return; + } catch (err) { + console.warn('Failed to focus Electron window:', err); + } + } - try { - const { getCurrentWindow } = await import('@tauri-apps/api/window'); - const window = getCurrentWindow(); - await window.unminimize(); - await window.setFocus(); - } catch (err) { - console.warn('Failed to focus window:', err); + // Use Tauri's window focus for actual Tauri builds + if (isTauri() && !isElectron()) { + try { + const { getCurrentWindow } = await import('@tauri-apps/api/window'); + const window = getCurrentWindow(); + await window.unminimize(); + await window.setFocus(); + } catch (err) { + console.warn('Failed to focus Tauri window:', err); + } } }; @@ -96,28 +123,43 @@ export const focusWindow = async (): Promise => { * Call this once at app startup with a navigation callback */ export const setupNotificationTapListener = async (onTap: (path: string) => void): Promise => { - if (!isTauri()) return; - notificationTapCallback = onTap; - try { - const { onAction } = await import('@tauri-apps/plugin-notification'); - - await onAction(async (notification) => { - // Bring window to front first - await focusWindow(); - - // Get the path from extra data and navigate - const path = notification.notification?.extra?.path; - if (path && typeof path === 'string' && notificationTapCallback) { - notificationTapCallback(path); + // Use Electron's native notification handler if in Electron + if (isElectron()) { + try { + const electron = (window as any).electron; + if (electron?.notification?.onNavigate) { + electron.notification.onNavigate((data: { path?: string }) => { + if (data.path && typeof data.path === 'string' && notificationTapCallback) { + notificationTapCallback(data.path); + } + }); + return; } - }); - } catch (err) { - // Silently ignore errors in Electron compatibility layer - // Only warn if we're actually in a Tauri environment - if (typeof window !== 'undefined' && '__TAURI__' in window) { - console.warn('Failed to set up notification tap listener:', err); + } catch (err) { + console.warn('Failed to set up Electron notification listener:', err); + return; + } + } + + // Use Tauri plugin for actual Tauri builds (not Electron) + if (isTauri() && !isElectron()) { + try { + const { onAction } = await import('@tauri-apps/plugin-notification'); + + await onAction(async (notification) => { + // Bring window to front first + await focusWindow(); + + // Get the path from extra data and navigate + const path = notification.notification?.extra?.path; + if (path && typeof path === 'string' && notificationTapCallback) { + notificationTapCallback(path); + } + }); + } catch (err) { + console.warn('Failed to set up Tauri notification tap listener:', err); } } }; @@ -128,11 +170,17 @@ export const setupNotificationTapListener = async (onTap: (path: string) => void export const isTauri = (): boolean => '__TAURI__' in window || '__TAURI_INTERNALS__' in window; +/** + * Check if we're running inside Electron (not Tauri) + */ +export const isElectron = (): boolean => + typeof window !== 'undefined' && 'electron' in window; + /** * Check if we're running on a mobile platform (Android/iOS) */ export const isTauriMobile = (): boolean => { - if (!isTauri()) return false; + if (!isTauri() || isElectron()) return false; const ua = navigator.userAgent.toLowerCase(); return ua.includes('android') || ua.includes('iphone') || ua.includes('ipad'); }; @@ -242,7 +290,26 @@ export const sendNotification = async (options: { }): Promise => { const { title, body, icon, path, onClick } = options; - if (isTauri()) { + // Use Electron's native notification API if in Electron + if (isElectron()) { + try { + const electron = (window as any).electron; + if (electron?.notification?.show) { + await electron.notification.show({ + title, + body, + icon, + path, + }); + return; + } + } catch (err) { + console.warn('Electron notification failed:', err); + } + } + + // Use Tauri plugin for actual Tauri builds (not Electron) + if (isTauri() && !isElectron()) { try { const { sendNotification: tauriSendNotification, @@ -269,14 +336,14 @@ export const sendNotification = async (options: { extra: path ? { path } : undefined, }); } + return; } catch (err) { console.warn('Tauri notification failed:', err); - // Fallback to browser notification on error - sendBrowserNotification({ title, body, icon, onClick }); } - } else { - sendBrowserNotification({ title, body, icon, onClick }); } + + // Fallback to browser notification + sendBrowserNotification({ title, body, icon, onClick }); }; /** @@ -292,22 +359,44 @@ export const isLinux = (): boolean => { * Returns a data URL if an image is found, null otherwise */ export const readClipboardImage = async (): Promise => { - if (!isTauri() || !isLinux()) return null; - - try { - const { invoke } = await import('@tauri-apps/api/core'); - const dataUrl = await invoke('read_clipboard_image'); - - if (!dataUrl) return null; - - // Convert data URL to File - const response = await fetch(dataUrl); - const blob = await response.blob(); - return new File([blob], 'clipboard-image.png', { type: 'image/png' }); - } catch (err) { - console.warn('Failed to read clipboard image:', err); - return null; + // Use Electron's clipboard API if in Electron + if (isElectron()) { + try { + const electron = (window as any).electron; + if (electron?.clipboard?.readImage) { + const dataUrl = await electron.clipboard.readImage(); + if (!dataUrl) return null; + + // Convert data URL to File + const response = await fetch(dataUrl); + const blob = await response.blob(); + return new File([blob], 'clipboard-image.png', { type: 'image/png' }); + } + } catch (err) { + console.warn('Failed to read Electron clipboard image:', err); + return null; + } } + + // Use Tauri's invoke for actual Tauri builds on Linux + if (isTauri() && !isElectron() && isLinux()) { + try { + const { invoke } = await import('@tauri-apps/api/core'); + const dataUrl = await invoke('read_clipboard_image'); + + if (!dataUrl) return null; + + // Convert data URL to File + const response = await fetch(dataUrl); + const blob = await response.blob(); + return new File([blob], 'clipboard-image.png', { type: 'image/png' }); + } catch (err) { + console.warn('Failed to read Tauri clipboard image:', err); + return null; + } + } + + return null; }; /** diff --git a/src/index.tsx b/src/index.tsx index aafc620..6dc036a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -60,8 +60,8 @@ async function checkForUpdates() { return; } - // Only run in Tauri context - if (!isTauri()) { + // Only run in Tauri context (not Electron) + if (!isTauri() || isElectron()) { console.log('Update check skipped - not running in Tauri'); return; }