Skip to content
Open
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
4 changes: 0 additions & 4 deletions app/views/ThreadMessagesView/Item.test.tsx

This file was deleted.

21 changes: 21 additions & 0 deletions app/views/ThreadMessagesView/components/EmptyThreads.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import I18n from '../../../i18n';
import BackgroundContainer from '../../../containers/BackgroundContainer';
import { Filter } from '../filters';

type TEmptyThreads = {
currentFilter: Filter;
};

const EmptyThreads = ({ currentFilter }: TEmptyThreads) => {
let text;
if (currentFilter === Filter.Following) {
text = I18n.t('No_threads_following');
} else if (currentFilter === Filter.Unread) {
text = I18n.t('No_threads_unread');
} else {
text = I18n.t('No_threads');
}
return <BackgroundContainer text={text} />;
};
Comment on lines +5 to +19

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Forward a loading flag so the empty-state text doesn't flash during initial fetch.

BackgroundContainer only renders text when loading is falsy and shows a spinner otherwise. Since EmptyThreads is used as ListEmptyComponent, it renders whenever threads is empty — including the initial load — and without a loading prop it will display "No_threads" before the first fetch completes.

🔧 Proposed change
 type TEmptyThreads = {
 	currentFilter: Filter;
+	loading?: boolean;
 };

-const EmptyThreads = ({ currentFilter }: TEmptyThreads) => {
+const EmptyThreads = ({ currentFilter, loading }: TEmptyThreads) => {
 	let text;
 	if (currentFilter === Filter.Following) {
 		text = I18n.t('No_threads_following');
 	} else if (currentFilter === Filter.Unread) {
 		text = I18n.t('No_threads_unread');
 	} else {
 		text = I18n.t('No_threads');
 	}
-	return <BackgroundContainer text={text} />;
+	return <BackgroundContainer text={text} loading={loading} />;
 };

Update the call site in index.tsx (Line 225) to pass loading.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/views/ThreadMessagesView/components/EmptyThreads.tsx` around lines 5 -
19, EmptyThreads is rendering the empty-state message during the initial fetch
because it always passes only text to BackgroundContainer. Update EmptyThreads
to accept a loading prop and forward it to BackgroundContainer, and make sure
the ListEmptyComponent call site in index.tsx passes the current loading state
so the spinner shows instead of flashing “No_threads” before data loads.


export default EmptyThreads;
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { ScrollView } from 'react-native';

import * as List from '../../containers/List';
import { themes } from '../../lib/constants/colors';
import { ThemeContext, type TSupportedThemes } from '../../theme';
import Item, { type IItem } from './Item';
import * as List from '../../../../containers/List';
import { themes } from '../../../../lib/constants/colors';
import { ThemeContext, type TSupportedThemes } from '../../../../theme';
import Item, { type IItem } from '.';

const author = {
_id: 'userid',
Expand Down
4 changes: 4 additions & 0 deletions app/views/ThreadMessagesView/components/Item/Item.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { generateSnapshots } from '../../../../../.rnstorybook/generateSnapshots';
import * as stories from './Item.stories';

generateSnapshots(stories);
Original file line number Diff line number Diff line change
@@ -1,60 +1,15 @@
import { StyleSheet, Text, View } from 'react-native';
import { Text, View } from 'react-native';
import { type ReactElement } from 'react';

import { useTheme } from '../../theme';
import Avatar from '../../containers/Avatar';
import sharedStyles from '../Styles';
import { themes } from '../../lib/constants/colors';
import { MarkdownPreview } from '../../containers/markdown';
import { formatDateThreads, makeThreadName } from '../../lib/methods/helpers/room';
import ThreadDetails from '../../containers/ThreadDetails';
import { type TThreadModel } from '../../definitions';
import Touch from '../../containers/Touch';

const styles = StyleSheet.create({
container: {
flexDirection: 'row',
padding: 16
},
contentContainer: {
flexDirection: 'column',
flex: 1
},
titleContainer: {
flexDirection: 'row',
marginBottom: 2,
alignItems: 'center'
},
title: {
flexShrink: 1,
fontSize: 18,
...sharedStyles.textMedium
},
time: {
fontSize: 14,
marginLeft: 4,
...sharedStyles.textRegular
},
avatar: {
marginRight: 8
},
threadDetails: {
marginTop: 8
},
badge: {
width: 8,
height: 8,
borderRadius: 4,
marginHorizontal: 8,
alignSelf: 'center'
},
messageContainer: {
flexDirection: 'row'
},
markdown: {
flex: 1
}
});
import { useTheme } from '../../../../theme';
import Avatar from '../../../../containers/Avatar';
import { themes } from '../../../../lib/constants/colors';
import { MarkdownPreview } from '../../../../containers/markdown';
import { formatDateThreads, makeThreadName } from '../../../../lib/methods/helpers/room';
import ThreadDetails from '../../../../containers/ThreadDetails';
import { type TThreadModel } from '../../../../definitions';
import Touch from '../../../../containers/Touch';
import styles from './styles';

export interface IItem {
item: TThreadModel;
Expand Down
50 changes: 50 additions & 0 deletions app/views/ThreadMessagesView/components/Item/styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { StyleSheet } from 'react-native';

import sharedStyles from '../../../Styles';

const styles = StyleSheet.create({
container: {
flexDirection: 'row',
padding: 16
},
contentContainer: {
flexDirection: 'column',
flex: 1
},
titleContainer: {
flexDirection: 'row',
marginBottom: 2,
alignItems: 'center'
},
title: {
flexShrink: 1,
fontSize: 18,
...sharedStyles.textMedium
},
time: {
fontSize: 14,
marginLeft: 4,
...sharedStyles.textRegular
},
avatar: {
marginRight: 8
},
threadDetails: {
marginTop: 8
},
badge: {
width: 8,
height: 8,
borderRadius: 4,
marginHorizontal: 8,
alignSelf: 'center'
},
messageContainer: {
flexDirection: 'row'
},
markdown: {
flex: 1
}
});

export default styles;
9 changes: 9 additions & 0 deletions app/views/ThreadMessagesView/definitions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { type IBaseScreen } from '../../definitions';
import { type ChatsStackParamList } from '../../stacks/types';

export interface ISearchThreadMessages {
isSearching: boolean;
searchText: string;
}

export interface IThreadMessagesViewProps extends IBaseScreen<ChatsStackParamList, 'ThreadMessagesView'> {}
52 changes: 52 additions & 0 deletions app/views/ThreadMessagesView/hooks/useSubscription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { useEffect, useRef, useState } from 'react';
import { type Subscription } from 'rxjs';

import { type TSubscriptionModel } from '../../../definitions';
import log from '../../../lib/methods/helpers/log';
import database from '../../../lib/database';

interface IUseSubscriptionProps {
rid: string;
}

const useSubscription = ({ rid }: IUseSubscriptionProps) => {
const subSubscription = useRef<Subscription | null>(null);

const [subscription, setSubscription] = useState<TSubscriptionModel>({} as TSubscriptionModel);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const [subscriptionLoaded, setSubscriptionLoaded] = useState(false);

const initSubscription = async () => {
try {
const db = database.active;

const subscription = await db.get('subscriptions').find(rid);
const observable = subscription.observe();
subSubscription.current = observable.subscribe(data => {
setSubscription(data);
});
setSubscriptionLoaded(true);
} catch (e) {
setSubscriptionLoaded(true);
log(e);
}
};

const unsubscribe = () => {
subSubscription.current?.unsubscribe();
};

useEffect(() => {
initSubscription();

return () => {
unsubscribe();
};
}, []);
Comment on lines +18 to +44

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Possible subscription leak / setState after unmount on fast unmount.

If the component unmounts before db.get('subscriptions').find(rid) resolves, the cleanup runs while subSubscription.current is still null, so the RxJS subscription created afterward is never torn down and setSubscription fires on an unmounted component. Track a cancellation flag to guard both paths.

🛡️ Proposed guard
 const useSubscription = ({ rid }: IUseSubscriptionProps) => {
 	const subSubscription = useRef<Subscription | null>(null);
+	const isMounted = useRef(true);
 
 	const [subscription, setSubscription] = useState<TSubscriptionModel>({} as TSubscriptionModel);
 	const [subscriptionLoaded, setSubscriptionLoaded] = useState(false);
 
 	const initSubscription = async () => {
 		try {
 			const db = database.active;
 
 			const subscription = await db.get('subscriptions').find(rid);
+			if (!isMounted.current) {
+				return;
+			}
 			const observable = subscription.observe();
 			subSubscription.current = observable.subscribe(data => {
 				setSubscription(data);
 			});
 			setSubscriptionLoaded(true);
 		} catch (e) {
 			setSubscriptionLoaded(true);
 			log(e);
 		}
 	};

 	useEffect(() => {
 		initSubscription();

 		return () => {
+			isMounted.current = false;
 			unsubscribe();
 		};
 	}, []);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/views/ThreadMessagesView/hooks/useSubscription.ts` around lines 18 - 44,
The initSubscription/useEffect flow can leak a subscription and call
setSubscription after unmount if the async db.get('subscriptions').find(rid)
resolves late. Update useSubscription so initSubscription and unsubscribe share
a cancellation flag (or equivalent mounted state) that is checked before calling
observable.subscribe, setSubscription, and setSubscriptionLoaded, and ensure
cleanup marks the request as canceled before unsubscribing any existing
subSubscription.current.


return {
subscription,
subscriptionLoaded
};
};

export default useSubscription;
141 changes: 141 additions & 0 deletions app/views/ThreadMessagesView/hooks/useThreads.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { useEffect, useRef, useState } from 'react';
import { Q } from '@nozbe/watermelondb';
import { type Subscription } from 'rxjs';
import { useDebouncedCallback } from 'use-debounce';

import { getSyncThreadsList, getThreadsList } from '../../../lib/services/restApi';
import { type TSubscriptionModel, type TThreadModel } from '../../../definitions';
import { sanitizeLikeString } from '../../../lib/database/utils';
import { type ISearchThreadMessages } from '../definitions';
import log from '../../../lib/methods/helpers/log';
import database from '../../../lib/database';
import updateThreads from '../methods/updateThreads';

interface IUseThreadsProps {
search: ISearchThreadMessages;
subscription: TSubscriptionModel;
subscriptionLoaded: boolean;
rid: string;
}

const API_FETCH_COUNT = 50;

const useThreads = ({ search, subscription, subscriptionLoaded, rid }: IUseThreadsProps) => {
const threadsSubscription = useRef<Subscription | null>(null);
const didInit = useRef(false);
const [loading, setLoading] = useState(false);
const [end, setEnd] = useState(false);
const [threads, setThreads] = useState<TThreadModel[]>([]);
const [offset, setOffset] = useState(0);

const init = () => {
if (!subscription._id) {
return load();
}
try {
if (subscription.lastThreadSync) {
sync(subscription.lastThreadSync);
} else {
load();
}
} catch (e) {
log(e);
}
};

const load = useDebouncedCallback(async (lastThreadSync?: Date) => {
if (end || loading) {
return;
}
setLoading(true);

try {
const result = await getThreadsList({
rid,
count: API_FETCH_COUNT,
offset,
text: search.searchText
});

if (result.success) {
const built = await updateThreads({ subscription, update: result.threads, lastThreadSync: lastThreadSync ?? new Date() });
if (!subscription._id && built) {
setThreads(prev => [...prev, ...(built as TThreadModel[])]);
}
setLoading(false);
setEnd(result.count < API_FETCH_COUNT);
setOffset(offset + API_FETCH_COUNT);
}
} catch (e) {
log(e);
setLoading(false);
setEnd(true);
}
}, 300);

const loadMore = () => load();

const sync = async (updatedSince: Date) => {
setLoading(true);
try {
const result = await getSyncThreadsList({
rid,
updatedSince: updatedSince.toISOString()
});
if (result.success && result.threads) {
const { update, remove } = result.threads;
updateThreads({ subscription, update, remove, lastThreadSync: updatedSince });
}
setLoading(false);
} catch (e) {
log(e);
setLoading(false);
}
};

const handleThreadsSubscription = ({ searchText }: { searchText?: string }) => {
if (!subscription._id) {
return;
}
try {
const db = database.active;
threadsSubscription.current?.unsubscribe();

const whereClause = [Q.where('rid', rid), Q.sortBy('tlm', Q.desc)];
if (searchText?.trim()) {
whereClause.push(Q.where('msg', Q.like(`%${sanitizeLikeString(searchText.trim())}%`)));
}

const threadsObservable = db
.get('threads')
.query(...whereClause)
.observeWithColumns(['_updated_at']);

threadsSubscription.current = threadsObservable.subscribe((threads: TThreadModel[]) => {
setThreads(threads);
});
} catch (e) {
log(e);
}
};

useEffect(() => {
if (!subscriptionLoaded || didInit.current) {
return;
}
didInit.current = true;
init();
handleThreadsSubscription({});
}, [subscriptionLoaded]);

useEffect(() => () => threadsSubscription.current?.unsubscribe(), []);

return {
loading,
loadMore,
threads,
handleThreadsSubscription
};
};

export default useThreads;
Loading
Loading