show unread badge in nav items and improve logic

This commit is contained in:
Ajay Bura 2026-08-15 09:41:21 +05:30
commit 188dce0e30
12 changed files with 89 additions and 50 deletions

View file

@ -59,6 +59,8 @@ import { useGetRoomName } from '../../../hooks/storage/useGetRoomName';
import { useGetRoomAvatar } from '../../../hooks/storage/useGetRoomAvatar';
import { useObservableValue } from '../../../hooks/useObservableValue';
import { useClient } from '../../../hooks/useClient';
import { unreadRoomsAtom } from '../../../state/room/roomToUnread';
import { useRoomsUnread } from '../../../state/hooks/unread';
type DirectMenuProps = {
requestClose: () => void;
@ -187,6 +189,7 @@ export function Direct() {
const getRoomAvatar = useGetRoomAvatar(getRoomHeroes, roomToHeroMembersResult.value);
const roomToJoinRule = useObservableValue(ct.roomToJoinRule);
const roomToNotifications = useRoomsNotifications(directs, roomToNotificationsAtom);
const unreadRooms = useRoomsUnread(directs, unreadRoomsAtom);
const notificationPreferences = useRoomsNotificationPreferencesContext();
const navigate = useNavigate();
@ -288,6 +291,7 @@ export function Direct() {
linkPath={getDirectRoomPath(ct.roomToAlias.read.get(roomId) ?? roomId)}
notificationMode={getRoomNotificationMode(notificationPreferences, roomId)}
notifications={notifications}
unread={unreadRooms.has(roomId)}
/>
</VirtualTile>
);

View file

@ -72,6 +72,7 @@ import {
import { roomToNotificationsAtom } from '../../../state/room/roomToNotifications';
import { useGetRoomAvatar } from '../../../hooks/storage/useGetRoomAvatar';
import { unreadRoomsAtom } from '../../../state/room/roomToUnread';
import { useRoomsUnread } from '../../../state/hooks/unread';
type HomeMenuProps = {
requestClose: () => void;
@ -212,7 +213,7 @@ export function Home() {
const getRoomAvatar = useGetRoomAvatar(getRoomHeroes, roomToHeroMembersResult.value);
const roomToJoinRule = useObservableValue(ct.roomToJoinRule);
const roomToNotifications = useRoomsNotifications(rooms, roomToNotificationsAtom);
const unreadRooms = useAtomValue(unreadRoomsAtom);
const unreadRooms = useRoomsUnread(rooms, unreadRoomsAtom);
const notificationPreferences = useRoomsNotificationPreferencesContext();
const navigate = useNavigate();

View file

@ -71,8 +71,6 @@ import { GetContentCallback, MessageEvent, StateEvent } from '../../../../types/
import { useMatrixEventRenderer } from '../../../hooks/useMatrixEventRenderer';
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 { ContainerColor } from '../../../styles/ContainerColor.css';
import { VirtualTile } from '../../../components/virtualizer';
@ -224,7 +222,7 @@ function RoomNotificationsGroupComp({
}: RoomNotificationsGroupProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
// const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
const powerLevels = usePowerLevels(room);
const creators = useRoomCreators(room);

View file

@ -91,6 +91,8 @@ import {
} from '../../../state/hooks/roomNotifications';
import { getJoinRoomStateId } from '../../../../client/storage/collections';
import { useAllJoinedRoomsSet } from '../../../hooks/useGetRoom';
import { unreadRoomsAtom } from '../../../state/room/roomToUnread';
import { useRoomsUnread } from '../../../state/hooks/unread';
type SpaceMenuProps = {
requestClose: () => void;
@ -405,6 +407,7 @@ export function Space() {
const getRoomAvatar = useGetRoomAvatar(getRoomHeroes, roomToHeroMembersResult.value);
const roomToJoinRule = useObservableValue(ct.roomToJoinRule);
const roomToNotifications = useRoomsNotifications(rooms, roomToNotificationsAtom);
const unreadRooms = useRoomsUnread(rooms, unreadRoomsAtom);
const virtualizer = useVirtualizer({
count: hierarchy.length,
@ -512,6 +515,7 @@ export function Space() {
linkPath={getToLink(roomId)}
notificationMode={getRoomNotificationMode(notificationPreferences, roomId)}
notifications={notifications}
unread={unreadRooms.has(roomId)}
/>
</VirtualTile>
);

View file

@ -1,12 +1,22 @@
import { Unread } from '../../../types/matrix/room';
import { roomToUnreadAtom } from '../room/roomToUnread';
import { useCallback } from 'react';
import { useAtomValue } from 'jotai';
import { selectAtom } from 'jotai/utils';
import { unreadRoomsAtom } from '../room/roomToUnread';
export const useRoomsUnread = (
rooms: string[],
roomToUnreadAtm: typeof roomToUnreadAtom
): Unread | undefined => undefined;
unreadRooms: typeof unreadRoomsAtom
): Set<string> => {
const selector = useCallback(
(unreads: Set<string>) => new Set<string>(rooms.filter((roomId) => unreads.has(roomId))),
[rooms]
);
export const useRoomUnread = (
roomId: string,
roomToUnreadAtm: typeof roomToUnreadAtom
): Unread | undefined => undefined;
return useAtomValue(selectAtom(unreadRooms, selector));
};
export const useRoomUnread = (roomId: string, unreadRooms: typeof unreadRoomsAtom): boolean => {
const selector = useCallback((unreads: Set<string>) => unreads.has(roomId), [roomId]);
return useAtomValue(selectAtom(unreadRooms, selector));
};

View file

@ -35,7 +35,6 @@ export const useBindRoomToNotificationsAtom = (
const unread = unreadState.status === AsyncStatus.Success ? unreadState.data : undefined;
const processNotifications = useCallback(() => {
console.log('roomToNotifications');
const notifications = unread?.notifications.read ?? [];
const threadNotifications = unread?.threadNotifications.read ?? [];
const { mute } = roomsNotificationPreferences;

View file

@ -1,42 +1,28 @@
import { atom, useSetAtom } from 'jotai';
import { useEffect, useMemo } from 'react';
import { useEffect } from 'react';
import { ThreadId } from '../../../types/matrix/room';
import { useObservableValue, useObservableValuePromise } from '../../hooks/useObservableValue';
import { useObservableValue } from '../../hooks/useObservableValue';
import { getReadReceiptId } from '../../../client/storage/collections';
import { ClientContext } from '../../../client/context';
export const roomToUnreadAtom = atom(new Map());
export const unreadRoomsAtom = atom<Set<string>>(new Set<string>());
export const useBindUnreadRoomsAtom = (ct: ClientContext, unreadRooms: typeof unreadRoomsAtom) => {
const setUnreadRooms = useSetAtom(unreadRooms);
const roomToLatestEvent = useObservableValue(ct.roomToLatestEvent);
const receiptKeys: string[] = useMemo(() => {
const keys: string[] = [];
roomToLatestEvent.forEach((_, roomId) => {
keys.push(getReadReceiptId(roomId, ct.session.userId, ThreadId.Main));
});
return keys;
}, [roomToLatestEvent, ct]);
const receiptsResult = useObservableValuePromise(
useMemo(() => ct.storage.readReceipt.observableRead.getMultiple(receiptKeys), [ct, receiptKeys])
);
const userReadReceipts = useObservableValue(ct.userReadReceipt);
useEffect(() => {
const receipts = receiptsResult.value;
if (receipts) {
const rooms: string[] = [];
Array.from(roomToLatestEvent.entries()).forEach(([roomId, event], index) => {
const receipt = receipts[index];
if (!receipt || receipt.ts > event.origin_server_ts) return;
rooms.push(roomId);
});
const unreads = new Set<string>();
setUnreadRooms(new Set(rooms));
}
}, [setUnreadRooms, receiptsResult, roomToLatestEvent]);
Array.from(roomToLatestEvent.entries()).forEach(([roomId, event]) => {
const receipt = userReadReceipts.find(
(r) => getReadReceiptId(roomId, ct.session.userId, ThreadId.Main) === r.id
);
if (!receipt || receipt.ts > event.origin_server_ts) return;
unreads.add(roomId);
});
setUnreadRooms(unreads);
}, [ct, setUnreadRooms, userReadReceipts, roomToLatestEvent]);
};

View file

@ -18,6 +18,7 @@ import {
JoinRoomStateObject,
KnockRoomStateObject,
LeaveRoomStateObject,
ReadReceiptObject,
sessionValue,
} from './storage/collections';
import { Storage } from './storage';
@ -117,6 +118,7 @@ export type ClientContext = {
roomToCreators: ObservableValue<Map<string, Set<string>>>;
roomToVersion: ObservableValue<Map<string, string>>;
roomToLatestEvent: ObservableValue<Map<string, MessageEventWithoutRoomId>>;
userReadReceipt: ObservableValue<ReadReceiptObject[]>;
roomToTimeline: ObservableValue<Map<string, Timeline>>;
pendingMemberRooms: ObservableValue<string[]>;
@ -836,6 +838,21 @@ export const createRoomToLatestEventObservable = async (
return roomToLatestEventObservable;
};
export const createUserReceiptObservable = async (
session: Session,
storage: Storage,
disposer: Disposer
): Promise<ObservableValue<ReadReceiptObject[]>> => {
const receiptObservable = await storage.readReceipt.observableRead.queryIndex(
'userId',
session.userId
);
disposer.collect(receiptObservable);
return receiptObservable;
};
export const createLogoutObservable = (
syncObservable: ObservableValue<SyncValue>,
disposer: Disposer

View file

@ -25,6 +25,7 @@ import {
createSyncTokenObservable,
createVerificationRequestObservable,
createBackupInfoObservable,
createUserReceiptObservable,
} from './context';
import { createStorage } from './storage';
import { getAuthJSONRequest } from './request';
@ -69,6 +70,7 @@ export const initializeClient = async (session: Session): Promise<ClientContext>
const roomToType = createRoomToTypeObservable(state, disposer);
const roomToReplacement = createRoomToReplacementObservable(state, disposer);
const roomToLatestEvent = await createRoomToLatestEventObservable(storage, disposer);
const userReadReceipt = await createUserReceiptObservable(session, storage, disposer);
const syncTokenObservable = await createSyncTokenObservable(storage, disposer);
const pendingMemberRooms = await createPendingMemberRoomObservable(storage, disposer);
@ -129,6 +131,7 @@ export const initializeClient = async (session: Session): Promise<ClientContext>
roomToType,
roomToReplacement,
roomToLatestEvent,
userReadReceipt,
roomToCreators,
roomToVersion,
roomToTimeline,

View file

@ -1,4 +1,4 @@
import { IDBPDatabase } from 'idb';
import { IDBPDatabase, IDBPTransaction } from 'idb';
import { StorageDriver } from 'neurone/lib/storage';
import { Collection, Entry } from 'neurone/lib/storage/collection';
import { ReceiptType } from 'neurone/lib/api/types';
@ -13,7 +13,11 @@ export const getReadReceiptId = (
threadId: string | ThreadId
): string => encodeKey(roomId, userId, threadId);
export const prepareReadReceiptStore = (idb: IDBPDatabase, oldVersion: number) => {
export const prepareReadReceiptStore = (
idb: IDBPDatabase,
oldVersion: number,
transaction: IDBPTransaction<any, any, 'versionchange'>
) => {
if (oldVersion < 1) {
const store = idb.createObjectStore(READ_RECEIPT_STORE_NAME, {
keyPath: 'id',
@ -22,11 +26,17 @@ export const prepareReadReceiptStore = (idb: IDBPDatabase, oldVersion: number) =
store.createIndex('roomId', 'roomId', { unique: false });
store.createIndex('eventId', 'eventId', { unique: false });
}
if (oldVersion < 2) {
const store = transaction.objectStore(READ_RECEIPT_STORE_NAME);
store.createIndex('userId', 'userId', { unique: false });
}
};
export type ReadReceiptObject = Entry<
'id',
'eventId' | 'roomId',
'userId' | 'eventId' | 'roomId',
{
id: string;
roomId: string;
@ -38,7 +48,11 @@ export type ReadReceiptObject = Entry<
}
>;
export type ReadReceiptCollection = Collection<'id', 'eventId' | 'roomId', ReadReceiptObject>;
export type ReadReceiptCollection = Collection<
'id',
'eventId' | 'roomId' | 'userId',
ReadReceiptObject
>;
export const createReadReceiptCollection = (driver: StorageDriver): ReadReceiptCollection =>
new Collection(READ_RECEIPT_STORE_NAME, 'id', driver);

View file

@ -50,7 +50,7 @@ import {
export * from './utils';
const DATABASE_VERSION = 1;
const DATABASE_VERSION = 2;
export interface Storage {
sessionValue: SessionValueCollection;
@ -95,7 +95,7 @@ export const createStorage = async (
// TODO: handle all openDB callbacks.
const idb = await openDB(dbName, DATABASE_VERSION, {
upgrade(db, oldVersion) {
upgrade(db, oldVersion, newVersion, transaction) {
prepareSessionValueStore(db, oldVersion);
prepareAccountDataStore(db, oldVersion);
@ -109,7 +109,7 @@ export const createStorage = async (
prepareUnreadNotificationsStore(db, oldVersion);
prepareUnreadThreadNotificationsStore(db, oldVersion);
prepareReadReceiptStore(db, oldVersion);
prepareReadReceiptStore(db, oldVersion, transaction);
prepareInviteRoomStateStore(db, oldVersion);

View file

@ -39,6 +39,7 @@ import {
UnreadThreadNotificationsObject,
} from '../storage/collections';
import { ThreadId } from '../../types/matrix/room';
import { getLatestEventSyncFilter } from '../filter';
export const writeAccountData = async (
ct: ClientContext,
@ -167,15 +168,17 @@ export const writeJoinRoomLatestEvent = async (
): Promise<void> => {
const roomLatestEventObjects: JoinRoomLatestEventObject[] = [];
const latestEventFilter = getLatestEventSyncFilter();
const supportedTypes = latestEventFilter.room?.timeline?.types ?? [];
joinRooms.forEach((roomId) => {
const joinRoom = join[roomId];
const { events } = joinRoom.timeline ?? {};
if (!events) return;
// TODO: filter events that are supported by timeline.
for (let i = events.length - 1; i >= 0; i -= 1) {
const event = events[i];
if (!('state_key' in event)) {
if (!('state_key' in event) && supportedTypes.includes(event.type)) {
roomLatestEventObjects.push({
id: roomId,
event,