diff --git a/app/lib/hooks/__tests__/useObservable.test.ts b/app/lib/hooks/__tests__/useObservable.test.ts new file mode 100644 index 0000000000..a4e5b4a8b6 --- /dev/null +++ b/app/lib/hooks/__tests__/useObservable.test.ts @@ -0,0 +1,47 @@ +import { act, renderHook } from '@testing-library/react-native'; +import { Observable, Subject } from 'rxjs'; + +import { useObservable } from '../useObservable'; + +describe('useObservable', () => { + it('returns undefined until the observable emits, then the latest value', () => { + const subject = new Subject(); + const { result } = renderHook(() => useObservable(subject)); + + expect(result.current).toBeUndefined(); + act(() => subject.next(1)); + expect(result.current).toBe(1); + act(() => subject.next(2)); + expect(result.current).toBe(2); + }); + + it('drops the previous value when the observable changes', () => { + const first = new Subject(); + const second = new Subject(); + const { result, rerender } = renderHook(({ source }: { source: Subject }) => useObservable(source), { + initialProps: { source: first } + }); + + act(() => first.next('first')); + expect(result.current).toBe('first'); + + rerender({ source: second }); + expect(result.current).toBeUndefined(); + act(() => second.next('second')); + expect(result.current).toBe('second'); + }); + + it('unsubscribes on unmount', () => { + const teardown = jest.fn(); + const observable = new Observable(() => teardown); + const { unmount } = renderHook(() => useObservable(observable)); + expect(teardown).not.toHaveBeenCalled(); + unmount(); + expect(teardown).toHaveBeenCalledTimes(1); + }); + + it('returns undefined without an observable', () => { + const { result } = renderHook(() => useObservable(undefined)); + expect(result.current).toBeUndefined(); + }); +}); diff --git a/app/lib/hooks/useObservable.ts b/app/lib/hooks/useObservable.ts new file mode 100644 index 0000000000..8f587c446a --- /dev/null +++ b/app/lib/hooks/useObservable.ts @@ -0,0 +1,27 @@ +import { useCallback, useRef, useSyncExternalStore } from 'react'; +import { type Observable } from 'rxjs'; + +type Emission = { source: Observable; value: T }; + +export function useObservable(observable: Observable | undefined): T | undefined { + const latestEmission = useRef | undefined>(undefined); + + const subscribe = useCallback( + (onChange: () => void) => { + if (!observable) { + return () => {}; + } + const subscription = observable.subscribe(value => { + latestEmission.current = { source: observable, value }; + onChange(); + }); + return () => subscription.unsubscribe(); + }, + [observable] + ); + + return useSyncExternalStore(subscribe, () => { + const emission = latestEmission.current; + return emission && emission.source === observable ? emission.value : undefined; + }); +} diff --git a/app/views/RoomView/hooks/__tests__/useThreadFollowing.test.ts b/app/views/RoomView/hooks/__tests__/useThreadFollowing.test.ts index 41431a6d69..486ab54f0b 100644 --- a/app/views/RoomView/hooks/__tests__/useThreadFollowing.test.ts +++ b/app/views/RoomView/hooks/__tests__/useThreadFollowing.test.ts @@ -15,51 +15,58 @@ type Emit = (value: T) => void; const setupObservable = () => { let emit: Emit | undefined; const unsubscribe = jest.fn(); - const threadRecord = { - observe: () => ({ - subscribe: (cb: Emit) => { - emit = cb; - return { unsubscribe }; - } - }) - }; - mockGet.mockImplementation(() => ({ find: jest.fn(() => Promise.resolve(threadRecord)) })); + const observeWithColumns = jest.fn(() => ({ + subscribe: (cb: Emit) => { + emit = cb; + return { unsubscribe }; + } + })); + mockGet.mockImplementation(() => ({ query: () => ({ observeWithColumns }) })); return { + observeWithColumns, unsubscribe, - emitThread: (thread: any) => act(() => emit?.(thread)) + emitThreads: (threads: any[]) => act(() => emit?.(threads)) }; }; -const flush = () => act(() => Promise.resolve()); - describe('useThreadFollowing', () => { beforeEach(() => jest.clearAllMocks()); - it('reflects whether the user is a replier on the observed thread', async () => { + it('reflects whether the user is a replier on the observed thread', () => { const observable = setupObservable(); const { result } = renderHook(() => useThreadFollowing('tmid-1', 'user-1')); - await flush(); - observable.emitThread({ replies: ['user-1', 'other'] }); expect(result.current).toBe(true); - observable.emitThread({ replies: ['other'] }); + observable.emitThreads([{ replies: ['user-1', 'other'] }]); + expect(result.current).toBe(true); + + observable.emitThreads([{ replies: ['other'] }]); + expect(result.current).toBe(false); + + observable.emitThreads([{ replies: undefined }]); expect(result.current).toBe(false); }); - it('does not observe without a tmid', async () => { + it('does not observe without a tmid', () => { setupObservable(); - renderHook(() => useThreadFollowing(undefined, 'user-1')); + const { result } = renderHook(() => useThreadFollowing(undefined, 'user-1')); - await flush(); + expect(result.current).toBe(true); expect(mockGet).not.toHaveBeenCalled(); }); - it('unsubscribes on unmount', async () => { + it('observes the replies column of the thread', () => { + const observable = setupObservable(); + renderHook(() => useThreadFollowing('tmid-1', 'user-1')); + + expect(observable.observeWithColumns).toHaveBeenCalledWith(['replies']); + }); + + it('unsubscribes on unmount', () => { const observable = setupObservable(); const { unmount } = renderHook(() => useThreadFollowing('tmid-1', 'user-1')); - await flush(); unmount(); expect(observable.unsubscribe).toHaveBeenCalledTimes(1); }); diff --git a/app/views/RoomView/hooks/useThreadFollowing.ts b/app/views/RoomView/hooks/useThreadFollowing.ts index 8f9b51c39d..5d184f2471 100644 --- a/app/views/RoomView/hooks/useThreadFollowing.ts +++ b/app/views/RoomView/hooks/useThreadFollowing.ts @@ -1,27 +1,22 @@ -import { useEffect, useState } from 'react'; +import { Q } from '@nozbe/watermelondb'; +import { useMemo } from 'react'; -import { getMessageById } from '../../../lib/database/services/Message'; +import { type TMessageModel } from '../../../definitions'; +import database from '../../../lib/database'; +import { useObservable } from '../../../lib/hooks/useObservable'; export function useThreadFollowing(tmid?: string, userId?: string): boolean { - const [isFollowingThread, setIsFollowingThread] = useState(true); + const threadObservable = useMemo( + () => + tmid + ? database.active.get('messages').query(Q.where('id', tmid)).observeWithColumns(['replies']) + : undefined, + [tmid] + ); + const thread = useObservable(threadObservable)?.[0]; - useEffect(() => { - if (!tmid) { - return; - } - let unsubscribe: (() => void) | undefined; - getMessageById(tmid).then(threadRecord => { - if (!threadRecord) { - return; - } - const subscription = threadRecord.observe().subscribe(thread => { - setIsFollowingThread(thread.replies?.some(replyUserId => replyUserId === userId) ?? false); - }); - unsubscribe = () => subscription.unsubscribe(); - }); - - return () => unsubscribe?.(); - }, [tmid, userId]); - - return isFollowingThread; + if (!thread) { + return true; + } + return thread.replies?.some(replyUserId => replyUserId === userId) ?? false; }