-
Notifications
You must be signed in to change notification settings - Fork 1.5k
chore: migrate ThreadMessagesView to hooks #5915
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
7fbfbed
2420dd2
1f78445
ec237d4
6412b8b
4b54580
99132b6
a4ee14b
6fbd1aa
78084a1
c8a7d69
cb57644
5c7f6cc
31f1ca8
0ce1aa1
398c70e
7da5e9f
02bcb24
3008a85
280ec28
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
| 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} />; | ||
| }; | ||
|
|
||
| export default EmptyThreads; | ||
| 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 |
|---|---|---|
| @@ -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; |
| 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'> {} |
| 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); | ||
|
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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 🛡️ 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 |
||
|
|
||
| return { | ||
| subscription, | ||
| subscriptionLoaded | ||
| }; | ||
| }; | ||
|
|
||
| export default useSubscription; | ||
| 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; |
There was a problem hiding this comment.
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
loadingflag so the empty-state text doesn't flash during initial fetch.BackgroundContaineronly renderstextwhenloadingis falsy and shows a spinner otherwise. SinceEmptyThreadsis used asListEmptyComponent, it renders wheneverthreadsis empty — including the initial load — and without aloadingprop 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 passloading.🤖 Prompt for AI Agents