Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions app/lib/hooks/__tests__/useObservable.test.ts
Original file line number Diff line number Diff line change
@@ -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<number>();
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<string>();
const second = new Subject<string>();
const { result, rerender } = renderHook(({ source }: { source: Subject<string> }) => 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<number>(() => 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();
});
});
27 changes: 27 additions & 0 deletions app/lib/hooks/useObservable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { useCallback, useRef, useSyncExternalStore } from 'react';
import { type Observable } from 'rxjs';

type Emission<T> = { source: Observable<T>; value: T };

export function useObservable<T>(observable: Observable<T> | undefined): T | undefined {
const latestEmission = useRef<Emission<T> | 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;
});
}
49 changes: 28 additions & 21 deletions app/views/RoomView/hooks/__tests__/useThreadFollowing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,51 +15,58 @@ type Emit<T> = (value: T) => void;
const setupObservable = () => {
let emit: Emit<any> | undefined;
const unsubscribe = jest.fn();
const threadRecord = {
observe: () => ({
subscribe: (cb: Emit<any>) => {
emit = cb;
return { unsubscribe };
}
})
};
mockGet.mockImplementation(() => ({ find: jest.fn(() => Promise.resolve(threadRecord)) }));
const observeWithColumns = jest.fn(() => ({
subscribe: (cb: Emit<any>) => {
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);
});
Expand Down
39 changes: 17 additions & 22 deletions app/views/RoomView/hooks/useThreadFollowing.ts
Original file line number Diff line number Diff line change
@@ -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<TMessageModel>('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;
}
Loading