diff --git a/CHANGELOG.md b/CHANGELOG.md index 8de63e1c4a..8c003ed686 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - Codebase cleanup ([#22](https://github.com/7w1/sable/pull/22)) - Fix mono font ([#18](https://github.com/7w1/sable/pull/18)) - Merge final commits from ([cinnyapp#2599](https://github.com/cinnyapp/cinny/pull/2599)) +- Pin counter tracks unread pins ## 1.1.7 diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 6ab448f73c..d87cdec2b1 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -1,4 +1,4 @@ -import React, { MouseEventHandler, forwardRef, useState } from 'react'; +import React, { MouseEventHandler, forwardRef, useEffect, useState } from 'react'; import FocusTrap from 'focus-trap-react'; import { Box, @@ -47,7 +47,6 @@ import { roomToUnreadAtom } from '$state/room/roomToUnread'; import { copyToClipboard } from '$appUtils/dom'; import { LeaveRoomPrompt } from '$components/leave-room-prompt'; import { useRoomAvatar, useRoomName, useRoomTopic } from '$hooks/useRoomMeta'; -import { mDirectAtom } from '$state/mDirectList'; import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; import { stopPropagation } from '$appUtils/keyboard'; import { getMatrixToRoom } from '$plugins/matrix-to'; @@ -71,6 +70,23 @@ import { InviteUserPrompt } from '$components/invite-user-prompt'; import { useCallState } from '$pages/client/call/CallProvider'; import { ContainerColor } from '$styles/ContainerColor.css'; import { useRoomWidgets } from '$hooks/useRoomWidgets'; +import { AccountDataEvent } from '$types/matrix/accountData'; + +async function getPinsHash(pinnedIds: string[]): Promise { + const sorted = [...pinnedIds].sort().join(','); + const encoder = new TextEncoder(); + const data = encoder.encode(sorted); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); + return hashHex.slice(0, 10); +} + +interface PinReadMarker { + hash: string; + count: number; + last_seen_id: string; +} type RoomMenuProps = { room: Room; @@ -268,7 +284,6 @@ export function RoomViewHeader() { const direct = useIsDirectRoom(); const { isChatOpen, toggleChat } = useCallState(); - const pinnedEvents = useRoomPinnedEvents(room); const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption); const encryptedRoom = !!encryptionEvent; const avatarMxc = useRoomAvatar(room, direct); @@ -282,6 +297,46 @@ export function RoomViewHeader() { const [widgetDrawer, setWidgetDrawer] = useSetting(settingsAtom, 'isWidgetDrawer'); const widgets = useRoomWidgets(room); + const pinnedIds = useRoomPinnedEvents(room); + const pinMarker = room + .getAccountData(AccountDataEvent.SablePinStatus) + ?.getContent() as PinReadMarker; + const [unreadPinsCount, setUnreadPinsCount] = useState(0); + + const [currentHash, setCurrentHash] = useState(''); + + useEffect(() => { + void getPinsHash(pinnedIds).then(setCurrentHash); + }, [pinnedIds]); + + useEffect(() => { + const checkUnreads = async () => { + if (!pinnedIds.length) { + setUnreadPinsCount(0); + return; + } + + const hash = await getPinsHash(pinnedIds); + + if (pinMarker?.hash === hash) { + setUnreadPinsCount(0); + return; + } + + const lastSeenIndex = pinnedIds.indexOf(pinMarker?.last_seen_id); + if (lastSeenIndex !== -1) { + const newPins = pinnedIds.slice(lastSeenIndex + 1); + setUnreadPinsCount(newPins.length); + } else { + const oldCount = pinMarker?.count ?? 0; + const startIndex = Math.max(0, oldCount - 1); + const newCount = pinnedIds.length > 0 ? pinnedIds.length - startIndex : 0; + setUnreadPinsCount(Math.max(0, newCount)); + } + }; + void checkUnreads(); + }, [pinnedIds, pinMarker]); + const handleSearchClick = () => { const searchParams: _SearchPathSearchParams = { rooms: room.roomId, @@ -298,6 +353,19 @@ export function RoomViewHeader() { const handleOpenPinMenu: MouseEventHandler = (evt) => { setPinMenuAnchor(evt.currentTarget.getBoundingClientRect()); + + const updateMarker = async () => { + if (pinnedIds.length === 0) return; + + const hash = await getPinsHash(pinnedIds); + await mx.setRoomAccountData(room.roomId, AccountDataEvent.SablePinStatus, { + hash: hash, + count: pinnedIds.length, + last_seen_id: pinnedIds[pinnedIds.length - 1], + }); + }; + + void updateMarker(); }; return ( @@ -411,7 +479,7 @@ export function RoomViewHeader() { ref={triggerRef} aria-pressed={!!pinMenuAnchor} > - {pinnedEvents.length > 0 && ( + {unreadPinsCount > 0 && ( - {pinnedEvents.length} + {unreadPinsCount} )} @@ -447,7 +515,11 @@ export function RoomViewHeader() { escapeDeactivates: stopPropagation, }} > - setPinMenuAnchor(undefined)} /> + setPinMenuAnchor(undefined)} + currentHash={currentHash} + /> } /> diff --git a/src/app/features/room/room-pin-menu/RoomPinMenu.tsx b/src/app/features/room/room-pin-menu/RoomPinMenu.tsx index d274f86776..de1c0f1283 100644 --- a/src/app/features/room/room-pin-menu/RoomPinMenu.tsx +++ b/src/app/features/room/room-pin-menu/RoomPinMenu.tsx @@ -88,6 +88,7 @@ import { } from '$hooks/useMemberPowerTag'; import { useRoomCreatorsTag } from '$hooks/useRoomCreatorsTag'; import { nicknamesAtom } from '$state/nicknames'; +import { AccountDataEvent } from '$types/matrix/accountData'; type PinnedMessageProps = { room: Room; @@ -100,6 +101,7 @@ type PinnedMessageProps = { legacyUsernameColor: boolean; hour24Clock: boolean; dateFormatString: string; + isNew: boolean; }; function PinnedMessage({ room, @@ -112,6 +114,7 @@ function PinnedMessage({ legacyUsernameColor, hour24Clock, dateFormatString, + isNew, }: PinnedMessageProps) { const pinnedEvent = useRoomEvent(room, eventId); const useAuthentication = useMediaAuthentication(); @@ -144,13 +147,19 @@ function PinnedMessage({ const renderOptions = () => ( - + Jump {canPinEvent && ( void; + currentHash: string; }; export const RoomPinMenu = forwardRef( - ({ room, requestClose }, ref) => { + ({ room, requestClose, currentHash }, ref) => { const mx = useMatrixClient(); const userId = mx.getUserId()!; const nicknames = useAtomValue(nicknamesAtom); @@ -285,6 +295,17 @@ export const RoomPinMenu = forwardRef( const { navigateRoom } = useRoomNavigate(); const scrollRef = useRef(null); + const pinMarker = useMemo(() => + room.getAccountData(AccountDataEvent.SablePinStatus)?.getContent(), + [room]); + + const lastSeenIndex = useMemo(() => { + if (!pinMarker?.last_seen_id) return -1; + return pinnedEvents.indexOf(pinMarker.last_seen_id); + }, [pinnedEvents, pinMarker]); + + const hasNewContent = pinMarker?.hash !== currentHash; + const virtualizer = useVirtualizer({ count: sortedPinnedEvent.length, getScrollElement: () => scrollRef.current, @@ -493,6 +514,17 @@ export const RoomPinMenu = forwardRef( const eventId = sortedPinnedEvent[vItem.index]; if (!eventId) return null; + const originalIndex = pinnedEvents.indexOf(eventId); + let isNew = false; + if (pinMarker?.hash !== currentHash) { + if (lastSeenIndex !== -1) { + isNew = originalIndex > lastSeenIndex; + } else { + const oldCount = pinMarker?.count ?? 0; + isNew = originalIndex >= (oldCount - 1); + } + } + return ( ( key={vItem.index} > ( accessibleTagColors={accessibleTagColors} legacyUsernameColor={legacyUsernameColor || direct} hour24Clock={hour24Clock} + isNew={isNew} dateFormatString={dateFormatString} /> diff --git a/src/types/matrix/accountData.ts b/src/types/matrix/accountData.ts index 8c8b4a69ef..6d0a804e27 100644 --- a/src/types/matrix/accountData.ts +++ b/src/types/matrix/accountData.ts @@ -20,6 +20,7 @@ export enum AccountDataEvent { // Sable account data SableNicknames = 'moe.sable.app.nicknames', + SablePinStatus = 'moe.sable.app.pins_read_marker', } export type MDirectContent = Record;