feat: implement useMarkAsRead hook for improved unread state management and update notification handling

This commit is contained in:
2026-02-28 00:10:35 +11:00
parent c890cce37a
commit 29c409fad8
16 changed files with 299 additions and 113 deletions

View File

@@ -1,6 +1,14 @@
import { style } from '@vanilla-extract/css'; import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds'; import { color, config, toRem } from 'folds';
export const CheckButtonContainer = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '32px',
width: '32px',
});
export const CheckButton = style({ export const CheckButton = style({
all: 'unset', all: 'unset',
display: 'flex', display: 'flex',
@@ -11,13 +19,19 @@ export const CheckButton = style({
cursor: 'pointer', cursor: 'pointer',
color: color.Secondary.Main, color: color.Secondary.Main,
backgroundColor: 'transparent', backgroundColor: 'transparent',
transition: 'background-color 0.15s', transition: 'opacity 0.2s, background-color 0.15s',
height: '32px', height: '32px',
width: '32px', width: '32px',
opacity: 0.7, opacity: 0,
WebkitAppRegion: 'no-drag', WebkitAppRegion: 'no-drag',
flexShrink: 0, flexShrink: 0,
selectors: {
'&[data-visible="true"]': {
opacity: 0.7,
},
},
':hover': { ':hover': {
backgroundColor: color.Surface.ContainerHover, backgroundColor: color.Surface.ContainerHover,
opacity: 1, opacity: 1,
@@ -30,6 +44,7 @@ export const CheckButton = style({
':focus-visible': { ':focus-visible': {
outline: `2px solid ${color.Secondary.Main}`, outline: `2px solid ${color.Secondary.Main}`,
outlineOffset: '2px', outlineOffset: '2px',
opacity: 1,
}, },
}); });

View File

@@ -21,6 +21,7 @@ export function UpdateNotification() {
const [downloadProgress, setDownloadProgress] = useState(0); const [downloadProgress, setDownloadProgress] = useState(0);
const [updateReady, setUpdateReady] = useState(false); const [updateReady, setUpdateReady] = useState(false);
const [checking, setChecking] = 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); const [menuAnchor, setMenuAnchor] = useState<{ x: number; y: number; width: number; height: number } | undefined>(undefined);
useEffect(() => { useEffect(() => {
@@ -118,29 +119,36 @@ export function UpdateNotification() {
// Show check button if no update status // Show check button if no update status
if (!updateAvailable && !updateReady && !checking) { if (!updateAvailable && !updateReady && !checking) {
return ( return (
<button <div
className={css.CheckButton} className={css.CheckButtonContainer}
onClick={handleCheckForUpdates} onMouseEnter={() => setIsHovered(true)}
aria-label="Check for updates" onMouseLeave={() => setIsHovered(false)}
title="Check for updates"
type="button"
> >
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"> <button
<path className={css.CheckButton}
d="M8 2V10M8 10L5 7M8 10L11 7" data-visible={isHovered}
stroke="currentColor" onClick={handleCheckForUpdates}
strokeWidth="1.5" aria-label="Check for updates"
strokeLinecap="round" title="Check for updates"
strokeLinejoin="round" type="button"
/> >
<path <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
d="M3 14H13" <path
stroke="currentColor" d="M8 2V10M8 10L5 7M8 10L11 7"
strokeWidth="1.5" stroke="currentColor"
strokeLinecap="round" strokeWidth="1.5"
/> strokeLinecap="round"
</svg> strokeLinejoin="round"
</button> />
<path
d="M3 14H13"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
</div>
); );
} }

View File

@@ -29,7 +29,7 @@ import { useRoomUnread } from '../../state/hooks/unread';
import { roomToUnreadAtom } from '../../state/room/roomToUnread'; import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { usePowerLevels } from '../../hooks/usePowerLevels'; import { usePowerLevels } from '../../hooks/usePowerLevels';
import { copyToClipboard } from '../../utils/dom'; import { copyToClipboard } from '../../utils/dom';
import { markAsRead } from '../../utils/notifications'; import { useMarkAsRead } from '../../hooks/useMarkAsRead';
import { UseStateProvider } from '../../components/UseStateProvider'; import { UseStateProvider } from '../../components/UseStateProvider';
import { LeaveRoomPrompt } from '../../components/leave-room-prompt'; import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers'; import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
@@ -68,6 +68,7 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
const powerLevels = usePowerLevels(room); const powerLevels = usePowerLevels(room);
const creators = useRoomCreators(room); const creators = useRoomCreators(room);
const { startCall, endCall, activeCall, callSupported } = useCall(); const { startCall, endCall, activeCall, callSupported } = useCall();
const markAsRead = useMarkAsRead(mx);
const permissions = useRoomPermissions(creators, powerLevels); const permissions = useRoomPermissions(creators, powerLevels);
const canInvite = permissions.action('invite', mx.getSafeUserId()); const canInvite = permissions.action('invite', mx.getSafeUserId());
@@ -77,7 +78,7 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
const [invitePrompt, setInvitePrompt] = useState(false); const [invitePrompt, setInvitePrompt] = useState(false);
const handleMarkAsRead = () => { const handleMarkAsRead = () => {
markAsRead(mx, room.roomId, hideActivity); markAsRead(room.roomId, hideActivity);
requestClose(); requestClose();
}; };

View File

@@ -11,7 +11,7 @@ import { settingsAtom } from '../../state/settings';
import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels'; import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels';
import { useRoom } from '../../hooks/useRoom'; import { useRoom } from '../../hooks/useRoom';
import { useKeyDown } from '../../hooks/useKeyDown'; import { useKeyDown } from '../../hooks/useKeyDown';
import { markAsRead } from '../../utils/notifications'; import { useMarkAsRead } from '../../hooks/useMarkAsRead';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomMembers } from '../../hooks/useRoomMembers'; import { useRoomMembers } from '../../hooks/useRoomMembers';
import { activeRoomIdAtom } from '../../state/activeRoom'; import { activeRoomIdAtom } from '../../state/activeRoom';
@@ -27,6 +27,7 @@ export function Room() {
const screenSize = useScreenSizeContext(); const screenSize = useScreenSizeContext();
const powerLevels = usePowerLevels(room); const powerLevels = usePowerLevels(room);
const members = useRoomMembers(mx, room.roomId); const members = useRoomMembers(mx, room.roomId);
const markAsRead = useMarkAsRead(mx);
// Update titlebar with current room ID // Update titlebar with current room ID
useEffect(() => { useEffect(() => {
@@ -39,10 +40,10 @@ export function Room() {
useCallback( useCallback(
(evt) => { (evt) => {
if (isKeyHotkey('escape', evt)) { if (isKeyHotkey('escape', evt)) {
markAsRead(mx, room.roomId, hideActivity); markAsRead(room.roomId, hideActivity);
} }
}, },
[mx, room.roomId, hideActivity] [markAsRead, room.roomId, hideActivity]
) )
); );

View File

@@ -94,7 +94,7 @@ import {
getIntersectionObserverEntry, getIntersectionObserverEntry,
useIntersectionObserver, useIntersectionObserver,
} from '../../hooks/useIntersectionObserver'; } from '../../hooks/useIntersectionObserver';
import { markAsRead } from '../../utils/notifications'; import { useMarkAsRead } from '../../hooks/useMarkAsRead';
import { useDebounce } from '../../hooks/useDebounce'; import { useDebounce } from '../../hooks/useDebounce';
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver'; import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
import * as css from './RoomTimeline.css'; import * as css from './RoomTimeline.css';
@@ -508,6 +508,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const spoilerClickHandler = useSpoilerClickHandler(); const spoilerClickHandler = useSpoilerClickHandler();
const openUserRoomProfile = useOpenUserRoomProfile(); const openUserRoomProfile = useOpenUserRoomProfile();
const space = useSpaceOptionally(); const space = useSpaceOptionally();
const markAsRead = useMarkAsRead(mx);
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents); 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), // 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. // 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. // 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) { if (!document.hasFocus() && !unreadInfo) {
@@ -665,7 +666,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
setUnreadInfo(getRoomUnreadInfo(room)); 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 tryAutoMarkAsRead = useCallback(() => {
const readUptoEventId = readUptoEventIdRef.current; const readUptoEventId = readUptoEventIdRef.current;
if (!readUptoEventId) { if (!readUptoEventId) {
requestAnimationFrame(() => markAsRead(mx, room.roomId, hideActivity)); requestAnimationFrame(() => markAsRead(room.roomId, hideActivity));
return; return;
} }
const evtTimeline = getEventTimeline(room, readUptoEventId); const evtTimeline = getEventTimeline(room, readUptoEventId);
const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward); const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward);
if (latestTimeline === room.getLiveTimeline()) { if (latestTimeline === room.getLiveTimeline()) {
requestAnimationFrame(() => markAsRead(mx, room.roomId, hideActivity)); requestAnimationFrame(() => markAsRead(room.roomId, hideActivity));
} }
}, [mx, room, hideActivity]); }, [room, hideActivity, markAsRead]);
const debounceSetAtBottom = useDebounce( const debounceSetAtBottom = useDebounce(
useCallback((entry: IntersectionObserverEntry) => { useCallback((entry: IntersectionObserverEntry) => {
@@ -946,7 +947,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
}; };
const handleMarkAsRead = () => { const handleMarkAsRead = () => {
markAsRead(mx, room.roomId, hideActivity); markAsRead(room.roomId, hideActivity);
}; };
const handleOpenReply: MouseEventHandler = useCallback( const handleOpenReply: MouseEventHandler = useCallback(

View File

@@ -44,8 +44,8 @@ import { _SearchPathSearchParams } from '../../pages/paths';
import * as css from './RoomViewHeader.css'; import * as css from './RoomViewHeader.css';
import { useRoomUnread } from '../../state/hooks/unread'; import { useRoomUnread } from '../../state/hooks/unread';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { markAsRead } from '../../utils/notifications';
import { roomToUnreadAtom } from '../../state/room/roomToUnread'; import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { useMarkAsRead } from '../../hooks/useMarkAsRead';
import { copyToClipboard } from '../../utils/dom'; import { copyToClipboard } from '../../utils/dom';
import { LeaveRoomPrompt } from '../../components/leave-room-prompt'; import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta'; import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
@@ -91,11 +91,12 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
const notificationPreferences = useRoomsNotificationPreferencesContext(); const notificationPreferences = useRoomsNotificationPreferencesContext();
const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId); const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId);
const { navigateRoom } = useRoomNavigate(); const { navigateRoom } = useRoomNavigate();
const markAsRead = useMarkAsRead(mx);
const [invitePrompt, setInvitePrompt] = useState(false); const [invitePrompt, setInvitePrompt] = useState(false);
const handleMarkAsRead = () => { const handleMarkAsRead = () => {
markAsRead(mx, room.roomId, hideActivity); markAsRead(room.roomId, hideActivity);
requestClose(); requestClose();
}; };

View File

@@ -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]
);
}

View File

@@ -42,7 +42,7 @@ import { useDirectRooms } from './useDirectRooms';
import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page'; import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page';
import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories'; import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories';
import { useRoomsUnread } from '../../../state/hooks/unread'; import { useRoomsUnread } from '../../../state/hooks/unread';
import { markAsRead } from '../../../utils/notifications'; import { useMarkRoomsAsRead } from '../../../hooks/useMarkAsRead';
import { stopPropagation } from '../../../utils/keyboard'; import { stopPropagation } from '../../../utils/keyboard';
import { useSetting } from '../../../state/hooks/settings'; import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings'; import { settingsAtom } from '../../../state/settings';
@@ -60,10 +60,11 @@ const DirectMenu = forwardRef<HTMLDivElement, DirectMenuProps>(({ requestClose }
const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const orphanRooms = useDirectRooms(); const orphanRooms = useDirectRooms();
const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
const markRoomsAsRead = useMarkRoomsAsRead(mx);
const handleMarkAsRead = () => { const handleMarkAsRead = () => {
if (!unread) return; if (!unread) return;
orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); markRoomsAsRead(orphanRooms, hideActivity);
requestClose(); requestClose();
}; };

View File

@@ -54,7 +54,7 @@ import { useCategoryHandler } from '../../../hooks/useCategoryHandler';
import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper'; import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
import { PageNav, PageNavHeader, PageNavContent } from '../../../components/page'; import { PageNav, PageNavHeader, PageNavContent } from '../../../components/page';
import { useRoomsUnread } from '../../../state/hooks/unread'; import { useRoomsUnread } from '../../../state/hooks/unread';
import { markAsRead } from '../../../utils/notifications'; import { useMarkRoomsAsRead } from '../../../hooks/useMarkAsRead';
import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories'; import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories';
import { stopPropagation } from '../../../utils/keyboard'; import { stopPropagation } from '../../../utils/keyboard';
import { useSetting } from '../../../state/hooks/settings'; import { useSetting } from '../../../state/hooks/settings';
@@ -78,10 +78,11 @@ const HomeMenu = forwardRef<HTMLDivElement, HomeMenuProps>(
const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
const mx = useMatrixClient(); const mx = useMatrixClient();
const markRoomsAsRead = useMarkRoomsAsRead(mx);
const handleMarkAsRead = () => { const handleMarkAsRead = () => {
if (!unread) return; if (!unread) return;
orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); markRoomsAsRead(orphanRooms, hideActivity);
requestClose(); requestClose();
}; };

View File

@@ -73,7 +73,7 @@ import * as customHtmlCss from '../../../styles/CustomHtml.css';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
import { useRoomUnread } from '../../../state/hooks/unread'; import { useRoomUnread } from '../../../state/hooks/unread';
import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
import { markAsRead } from '../../../utils/notifications'; import { useMarkAsRead } from '../../../hooks/useMarkAsRead';
import { ContainerColor } from '../../../styles/ContainerColor.css'; import { ContainerColor } from '../../../styles/ContainerColor.css';
import { VirtualTile } from '../../../components/virtualizer'; import { VirtualTile } from '../../../components/virtualizer';
import { UserAvatar } from '../../../components/user-avatar'; import { UserAvatar } from '../../../components/user-avatar';
@@ -374,6 +374,7 @@ function RoomNotificationsGroupComp({
const mentionClickHandler = useMentionClickHandler(room.roomId); const mentionClickHandler = useMentionClickHandler(room.roomId);
const spoilerClickHandler = useSpoilerClickHandler(); const spoilerClickHandler = useSpoilerClickHandler();
const markAsRead = useMarkAsRead(mx);
const linkifyOpts = useMemo<LinkifyOpts>( const linkifyOpts = useMemo<LinkifyOpts>(
() => ({ () => ({
@@ -540,7 +541,7 @@ function RoomNotificationsGroupComp({
onOpen(room.roomId, eventId); onOpen(room.roomId, eventId);
}; };
const handleMarkAsRead = () => { const handleMarkAsRead = () => {
markAsRead(mx, room.roomId, hideActivity); markAsRead(room.roomId, hideActivity);
}; };
return ( return (

View File

@@ -3,6 +3,9 @@ import { useNavigate } from 'react-router-dom';
import { Box, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config, toRem } from 'folds'; import { Box, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config, toRem } from 'folds';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { useAtomValue } from 'jotai'; 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 { useDirects } from '../../../state/hooks/roomList';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { mDirectAtom } from '../../../state/mDirectList'; import { mDirectAtom } from '../../../state/mDirectList';
@@ -21,7 +24,7 @@ import { UnreadBadge } from '../../../components/unread-badge';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath'; import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath';
import { useDirectRooms } from '../direct/useDirectRooms'; import { useDirectRooms } from '../direct/useDirectRooms';
import { markAsRead } from '../../../utils/notifications'; import { useMarkRoomsAsRead } from '../../../hooks/useMarkAsRead';
import { stopPropagation } from '../../../utils/keyboard'; import { stopPropagation } from '../../../utils/keyboard';
import { settingsAtom } from '../../../state/settings'; import { settingsAtom } from '../../../state/settings';
import { useSetting } from '../../../state/hooks/settings'; import { useSetting } from '../../../state/hooks/settings';
@@ -34,10 +37,11 @@ const DirectMenu = forwardRef<HTMLDivElement, DirectMenuProps>(({ requestClose }
const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
const mx = useMatrixClient(); const mx = useMatrixClient();
const markRoomsAsRead = useMarkRoomsAsRead(mx);
const handleMarkAsRead = () => { const handleMarkAsRead = () => {
if (!unread) return; if (!unread) return;
orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); markRoomsAsRead(orphanRooms, hideActivity);
requestClose(); requestClose();
}; };
@@ -98,11 +102,20 @@ export function DirectTab() {
<SidebarAvatar <SidebarAvatar
as="button" as="button"
ref={triggerRef} ref={triggerRef}
outlined
onClick={handleDirectClick} onClick={handleDirectClick}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
> >
<Icon src={Icons.User} filled={directSelected} /> <img
src={
directUnread?.highlight
? PaarrotHighlightSVG
: directUnread?.total
? PaarrotUnreadSVG
: PaarrotSVG
}
alt="Direct Messages"
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
</SidebarAvatar> </SidebarAvatar>
)} )}
</SidebarItemTooltip> </SidebarItemTooltip>

View File

@@ -22,7 +22,7 @@ import { UnreadBadge } from '../../../components/unread-badge';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath'; import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath';
import { useHomeRooms } from '../home/useHomeRooms'; import { useHomeRooms } from '../home/useHomeRooms';
import { markAsRead } from '../../../utils/notifications'; import { useMarkRoomsAsRead } from '../../../hooks/useMarkAsRead';
import { stopPropagation } from '../../../utils/keyboard'; import { stopPropagation } from '../../../utils/keyboard';
import { useSetting } from '../../../state/hooks/settings'; import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings'; import { settingsAtom } from '../../../state/settings';
@@ -39,10 +39,11 @@ const HomeMenu = forwardRef<HTMLDivElement, HomeMenuProps>(({ requestClose, onHi
const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
const mx = useMatrixClient(); const mx = useMatrixClient();
const markRoomsAsRead = useMarkRoomsAsRead(mx);
const handleMarkAsRead = () => { const handleMarkAsRead = () => {
if (!unread) return; if (!unread) return;
orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); markRoomsAsRead(orphanRooms, hideActivity);
requestClose(); requestClose();
}; };

View File

@@ -90,7 +90,7 @@ import { useOpenedSidebarFolderAtom } from '../../../state/hooks/openedSidebarFo
import { usePowerLevels } from '../../../hooks/usePowerLevels'; import { usePowerLevels } from '../../../hooks/usePowerLevels';
import { useRoomsUnread } from '../../../state/hooks/unread'; import { useRoomsUnread } from '../../../state/hooks/unread';
import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
import { markAsRead } from '../../../utils/notifications'; import { useMarkRoomsAsRead } from '../../../hooks/useMarkAsRead';
import { copyToClipboard } from '../../../utils/dom'; import { copyToClipboard } from '../../../utils/dom';
import { stopPropagation } from '../../../utils/keyboard'; import { stopPropagation } from '../../../utils/keyboard';
import { getMatrixToRoom } from '../../../plugins/matrix-to'; import { getMatrixToRoom } from '../../../plugins/matrix-to';
@@ -123,6 +123,7 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
const permissions = useRoomPermissions(creators, powerLevels); const permissions = useRoomPermissions(creators, powerLevels);
const canInvite = permissions.action('invite', mx.getSafeUserId()); const canInvite = permissions.action('invite', mx.getSafeUserId());
const openSpaceSettings = useOpenSpaceSettings(); const openSpaceSettings = useOpenSpaceSettings();
const markRoomsAsRead = useMarkRoomsAsRead(mx);
const [invitePrompt, setInvitePrompt] = useState(false); const [invitePrompt, setInvitePrompt] = useState(false);
@@ -134,7 +135,7 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
const unread = useRoomsUnread(allChild, roomToUnreadAtom); const unread = useRoomsUnread(allChild, roomToUnreadAtom);
const handleMarkAsRead = () => { const handleMarkAsRead = () => {
allChild.forEach((childRoomId) => markAsRead(mx, childRoomId, hideActivity)); markRoomsAsRead(allChild, hideActivity);
requestClose(); requestClose();
}; };

View File

@@ -59,7 +59,7 @@ import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page
import { usePowerLevels } from '../../../hooks/usePowerLevels'; import { usePowerLevels } from '../../../hooks/usePowerLevels';
import { useRecursiveChildScopeFactory, useSpaceChildren } from '../../../state/hooks/roomList'; import { useRecursiveChildScopeFactory, useSpaceChildren } from '../../../state/hooks/roomList';
import { roomToParentsAtom } from '../../../state/room/roomToParents'; import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { markAsRead } from '../../../utils/notifications'; import { useMarkRoomsAsRead } from '../../../hooks/useMarkAsRead';
import { useRoomsUnread } from '../../../state/hooks/unread'; import { useRoomsUnread } from '../../../state/hooks/unread';
import { UseStateProvider } from '../../../components/UseStateProvider'; import { UseStateProvider } from '../../../components/UseStateProvider';
import { LeaveSpacePrompt } from '../../../components/leave-space-prompt'; import { LeaveSpacePrompt } from '../../../components/leave-space-prompt';
@@ -101,6 +101,7 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClo
const canInvite = permissions.action('invite', mx.getSafeUserId()); const canInvite = permissions.action('invite', mx.getSafeUserId());
const openSpaceSettings = useOpenSpaceSettings(); const openSpaceSettings = useOpenSpaceSettings();
const { navigateRoom } = useRoomNavigate(); const { navigateRoom } = useRoomNavigate();
const markRoomsAsRead = useMarkRoomsAsRead(mx);
const [invitePrompt, setInvitePrompt] = useState(false); const [invitePrompt, setInvitePrompt] = useState(false);
@@ -112,7 +113,7 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClo
const unread = useRoomsUnread(allChild, roomToUnreadAtom); const unread = useRoomsUnread(allChild, roomToUnreadAtom);
const handleMarkAsRead = () => { const handleMarkAsRead = () => {
allChild.forEach((childRoomId) => markAsRead(mx, childRoomId, hideActivity)); markRoomsAsRead(allChild, hideActivity);
requestClose(); requestClose();
}; };

View File

@@ -4,29 +4,42 @@
*/ */
export const openExternalUrl = async (url: string): Promise<void> => { export const openExternalUrl = async (url: string): Promise<void> => {
console.log('[openExternalUrl] called with:', url); console.log('[openExternalUrl] called with:', url);
console.log('[openExternalUrl] __TAURI__ exists:', !!(window as any).__TAURI__);
// Only run this in Tauri builds // Use Electron's shell.openExternal if in Electron
if (typeof window !== 'undefined' && (window as any).__TAURI__) { 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 { try {
// First, try the plugin directly (works on both desktop and mobile) // 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'); const { openUrl } = await import('@tauri-apps/plugin-opener');
await openUrl(url); await openUrl(url);
console.log('[openExternalUrl] plugin succeeded'); console.log('[openExternalUrl] Tauri plugin succeeded');
return; return;
} catch (pluginErr) { } 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) // Fallback: try the custom command (useful if plugin fails due to ACL)
try { try {
console.log('[openExternalUrl] trying invoke command fallback...'); console.log('[openExternalUrl] trying Tauri invoke command fallback...');
const { invoke } = await import('@tauri-apps/api/core'); const { invoke } = await import('@tauri-apps/api/core');
await invoke('open_external_url', { url }); await invoke('open_external_url', { url });
console.log('[openExternalUrl] command fallback succeeded'); console.log('[openExternalUrl] Tauri command fallback succeeded');
return; return;
} catch (invokeErr) { } 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 * Bring the Tauri window to the front and focus it
*/ */
export const focusWindow = async (): Promise<void> => { export const focusWindow = async (): Promise<void> => {
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 { // Use Tauri's window focus for actual Tauri builds
const { getCurrentWindow } = await import('@tauri-apps/api/window'); if (isTauri() && !isElectron()) {
const window = getCurrentWindow(); try {
await window.unminimize(); const { getCurrentWindow } = await import('@tauri-apps/api/window');
await window.setFocus(); const window = getCurrentWindow();
} catch (err) { await window.unminimize();
console.warn('Failed to focus window:', err); await window.setFocus();
} catch (err) {
console.warn('Failed to focus Tauri window:', err);
}
} }
}; };
@@ -96,28 +123,43 @@ export const focusWindow = async (): Promise<void> => {
* Call this once at app startup with a navigation callback * Call this once at app startup with a navigation callback
*/ */
export const setupNotificationTapListener = async (onTap: (path: string) => void): Promise<void> => { export const setupNotificationTapListener = async (onTap: (path: string) => void): Promise<void> => {
if (!isTauri()) return;
notificationTapCallback = onTap; notificationTapCallback = onTap;
try { // Use Electron's native notification handler if in Electron
const { onAction } = await import('@tauri-apps/plugin-notification'); if (isElectron()) {
try {
await onAction(async (notification) => { const electron = (window as any).electron;
// Bring window to front first if (electron?.notification?.onNavigate) {
await focusWindow(); electron.notification.onNavigate((data: { path?: string }) => {
if (data.path && typeof data.path === 'string' && notificationTapCallback) {
// Get the path from extra data and navigate notificationTapCallback(data.path);
const path = notification.notification?.extra?.path; }
if (path && typeof path === 'string' && notificationTapCallback) { });
notificationTapCallback(path); return;
} }
}); } catch (err) {
} catch (err) { console.warn('Failed to set up Electron notification listener:', err);
// Silently ignore errors in Electron compatibility layer return;
// 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);
// 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 => export const isTauri = (): boolean =>
'__TAURI__' in window || '__TAURI_INTERNALS__' in window; '__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) * Check if we're running on a mobile platform (Android/iOS)
*/ */
export const isTauriMobile = (): boolean => { export const isTauriMobile = (): boolean => {
if (!isTauri()) return false; if (!isTauri() || isElectron()) return false;
const ua = navigator.userAgent.toLowerCase(); const ua = navigator.userAgent.toLowerCase();
return ua.includes('android') || ua.includes('iphone') || ua.includes('ipad'); return ua.includes('android') || ua.includes('iphone') || ua.includes('ipad');
}; };
@@ -242,7 +290,26 @@ export const sendNotification = async (options: {
}): Promise<void> => { }): Promise<void> => {
const { title, body, icon, path, onClick } = options; 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 { try {
const { const {
sendNotification: tauriSendNotification, sendNotification: tauriSendNotification,
@@ -269,14 +336,14 @@ export const sendNotification = async (options: {
extra: path ? { path } : undefined, extra: path ? { path } : undefined,
}); });
} }
return;
} catch (err) { } catch (err) {
console.warn('Tauri notification failed:', 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 * Returns a data URL if an image is found, null otherwise
*/ */
export const readClipboardImage = async (): Promise<File | null> => { export const readClipboardImage = async (): Promise<File | null> => {
if (!isTauri() || !isLinux()) return null; // Use Electron's clipboard API if in Electron
if (isElectron()) {
try { try {
const { invoke } = await import('@tauri-apps/api/core'); const electron = (window as any).electron;
const dataUrl = await invoke<string | null>('read_clipboard_image'); if (electron?.clipboard?.readImage) {
const dataUrl = await electron.clipboard.readImage();
if (!dataUrl) return null; if (!dataUrl) return null;
// Convert data URL to File // Convert data URL to File
const response = await fetch(dataUrl); const response = await fetch(dataUrl);
const blob = await response.blob(); const blob = await response.blob();
return new File([blob], 'clipboard-image.png', { type: 'image/png' }); return new File([blob], 'clipboard-image.png', { type: 'image/png' });
} catch (err) { }
console.warn('Failed to read clipboard image:', err); } catch (err) {
return null; 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<string | null>('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;
}; };
/** /**

View File

@@ -60,8 +60,8 @@ async function checkForUpdates() {
return; return;
} }
// Only run in Tauri context // Only run in Tauri context (not Electron)
if (!isTauri()) { if (!isTauri() || isElectron()) {
console.log('Update check skipped - not running in Tauri'); console.log('Update check skipped - not running in Tauri');
return; return;
} }