Skip to content
Merged
4 changes: 3 additions & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@
"syntax"
],
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
"react-hooks/exhaustive-deps": ["warn", {
"additionalHooks": "(useComponentDidUpdate)"
}]
},
"settings": {
"import/resolver": {
Expand Down
171 changes: 97 additions & 74 deletions app/threads/client/components/ThreadComponent.js
Original file line number Diff line number Diff line change
@@ -1,100 +1,123 @@
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { Modal, Box } from '@rocket.chat/fuselage';
import { Meteor } from 'meteor/meteor';
import { Template } from 'meteor/templating';
import { Blaze } from 'meteor/blaze';
import { Tracker } from 'meteor/tracker';
import { useLocalStorage } from '@rocket.chat/fuselage-hooks';

import { ChatMessage } from '../../../models/client';
import { useRoute } from '../../../../client/contexts/RouterContext';
import { roomTypes, APIClient } from '../../../utils/client';
import { call } from '../../../ui-utils/client';
import { useTranslation } from '../../../../client/contexts/TranslationContext';
import VerticalBar from '../../../../client/components/basic/VerticalBar';
import { roomTypes } from '../../../utils/client';
import { normalizeThreadTitle } from '../lib/normalizeThreadTitle';
import { useUserId } from '../../../../client/contexts/UserContext';
import { useEndpoint, useMethod } from '../../../../client/contexts/ServerContext';
import { useToastMessageDispatch } from '../../../../client/contexts/ToastMessagesContext';
import ThreadSkeleton from './ThreadSkeleton';
import ThreadView from './ThreadView';

export default function ThreadComponent({ mid, rid, jump, room, ...props }) {
const t = useTranslation();
const channelRoute = useRoute(roomTypes.getConfig(room.t).route.name);
const [mainMessage, setMainMessage] = useState({});
const useThreadMessage = (tmid) => {
const [message, setMessage] = useState(() => Tracker.nonreactive(() => ChatMessage.findOne({ _id: tmid })));
const getMessage = useEndpoint('GET', 'chat.getMessage');

const [expanded, setExpand] = useLocalStorage('expand-threads', false);
useEffect(() => {
const computation = Tracker.autorun(async (computation) => {
const msg = ChatMessage.findOne({ _id: tmid }) || (await getMessage({ msgId: tmid })).message;

const ref = useRef();
const uid = useMemo(() => Meteor.userId(), []);
if (!msg || computation.stopped) {
return;
}

setMessage((prevMsg) => (prevMsg._updatedAt?.getTime() === msg._updatedAt?.getTime() ? prevMsg : msg));
});

const style = useMemo(() => ({
top: 0,
right: 0,
maxWidth: '855px',
...document.dir === 'rtl' ? { borderTopRightRadius: '4px' } : { borderTopLeftRadius: '4px' },
overflow: 'hidden',
bottom: 0,
zIndex: 100,
}), [document.dir]);
return () => {
computation.stop();
};
}, [getMessage, tmid]);

const following = mainMessage.replies && mainMessage.replies.includes(uid);
const actionId = useMemo(() => (following ? 'unfollow' : 'follow'), [following]);
const button = useMemo(() => (actionId === 'follow' ? 'bell-off' : 'bell'), [actionId]);
const actionLabel = t(actionId === 'follow' ? 'Not_Following' : 'Following');
const headerTitle = useMemo(() => normalizeThreadTitle(mainMessage), [mainMessage._updatedAt]);
return message;
};

const expandLabel = expanded ? 'collapse' : 'expand';
const expandIcon = expanded ? 'arrow-collapse' : 'arrow-expand';
function ThreadComponent({
mid,
jump,
room,
subscription,
}) {
const channelRoute = useRoute(roomTypes.getConfig(room.t).route.name);
const threadMessage = useThreadMessage(mid);

const handleExpandButton = useCallback(() => {
setExpand(!expanded);
}, [expanded]);
const ref = useRef();
const uid = useUserId();

const handleFollowButton = useCallback(() => call(actionId === 'follow' ? 'followMessage' : 'unfollowMessage', { mid }), [actionId, mid]);
const handleClose = useCallback(() => {
channelRoute.push(room.t === 'd' ? { rid } : { name: room.name });
}, [channelRoute, room.t, room.name]);
const headerTitle = useMemo(() => (threadMessage ? normalizeThreadTitle(threadMessage) : null), [threadMessage]);
const [expanded, setExpand] = useLocalStorage('expand-threads', false);
const following = threadMessage?.replies?.includes(uid) ?? false;

useEffect(() => {
const tracker = Tracker.autorun(async () => {
const msg = ChatMessage.findOne({ _id: mid }) || (await APIClient.v1.get('chat.getMessage', { msgId: mid })).message;
if (!msg) {
const dispatchToastMessage = useToastMessageDispatch();
const followMessage = useMethod('followMessage');
const unfollowMessage = useMethod('unfollowMessage');

const setFollowing = useCallback(async (following) => {
try {
if (following) {
await followMessage({ mid });
return;
}
setMainMessage(msg);
});
return () => tracker.stop();
}, [mid]);

await unfollowMessage({ mid });
} catch (error) {
dispatchToastMessage({
type: 'error',
message: error,
});
}
}, [dispatchToastMessage, followMessage, unfollowMessage, mid]);

const handleClose = useCallback(() => {
channelRoute.push(room.t === 'd' ? { rid: room._id } : { name: room.name });
}, [channelRoute, room._id, room.t, room.name]);

const viewDataRef = useRef({
mainMessage: threadMessage,
jump,
following,
subscription,
});

useEffect(() => {
viewDataRef.mainMessage = threadMessage;
viewDataRef.jump = jump;
viewDataRef.following = following;
viewDataRef.subscription = subscription;
}, [following, jump, subscription, threadMessage]);

const hasThreadMessage = !!threadMessage;

useEffect(() => {
let view;
(async () => {
view = mainMessage.rid && ref.current && Blaze.renderWithData(Template.thread, { mainMessage: ChatMessage.findOne({ _id: mid }) || (await APIClient.v1.get('chat.getMessage', { msgId: mid })).message, jump, following, ...props }, ref.current);
})();
return () => view && Blaze.remove(view);
}, [mainMessage.rid, mid]);

if (!mainMessage.rid) {
return <>
{expanded && <Modal.Backdrop onClick={handleClose}/> }
<Box width='380px' flexGrow={1} { ...!expanded && { position: 'relative' }}>
<VerticalBar.Skeleton rcx-thread-view width='full' style={style} display='flex' flexDirection='column' position='absolute' { ...!expanded && { width: '380px' } }/>
</Box>
</>;
if (!ref.current || !hasThreadMessage) {
return;
}

const view = Blaze.renderWithData(Template.thread, viewDataRef.current, ref.current);

return () => {
Blaze.remove(view);
};
}, [hasThreadMessage, mid]);

if (!threadMessage) {
return <ThreadSkeleton expanded={expanded} onClose={handleClose} />;
}

return <>
{expanded && <Modal.Backdrop onClick={handleClose}/> }

<Box width='380px' flexGrow={1} { ...!expanded && { position: 'relative' }}>
<VerticalBar rcx-thread-view width='full' style={style} display='flex' flexDirection='column' position='absolute' { ...!expanded && { width: '380px' } }>
<VerticalBar.Header>
<VerticalBar.Icon name='thread' />
<VerticalBar.Text dangerouslySetInnerHTML={{ __html: headerTitle }} />
<VerticalBar.Action aria-label={expandLabel} onClick={handleExpandButton} name={expandIcon}/>
<VerticalBar.Action aria-label={actionLabel} onClick={handleFollowButton} name={button}/>
<VerticalBar.Close onClick={handleClose}/>
</VerticalBar.Header>
<VerticalBar.Content paddingInline={0} flexShrink={1} flexGrow={1} ref={ref}/>
</VerticalBar>
</Box>
</>;
return <ThreadView
ref={ref}
title={headerTitle}
expanded={expanded}
following={following}
onToggleExpand={(expanded) => setExpand(!expanded)}
onToggleFollow={(following) => setFollowing(!following)}
onClose={handleClose}
/>;
}

export default ThreadComponent;
43 changes: 43 additions & 0 deletions app/threads/client/components/ThreadSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import React, { FC, useMemo } from 'react';
import { Modal, Box } from '@rocket.chat/fuselage';

import VerticalBar from '../../../../client/components/basic/VerticalBar';

type ThreadSkeletonProps = {
expanded: boolean;
onClose: () => void;
};

const ThreadSkeleton: FC<ThreadSkeletonProps> = ({ expanded, onClose }) => {
const style = useMemo(() => (document.dir === 'rtl'
? {
left: 0,
borderTopRightRadius: 4,
}
: {
right: 0,
borderTopLeftRadius: 4,
}), []);

return <>
{expanded && <Modal.Backdrop onClick={onClose} />}
<Box width='380px' flexGrow={1} position={expanded ? 'static' : 'relative'}>
<VerticalBar.Skeleton
className='rcx-thread-view'
position='absolute'
display='flex'
flexDirection='column'
width={expanded ? 'full' : 380}
maxWidth={855}
overflow='hidden'
zIndex={100}
insetBlock={0}
// insetInlineEnd={0}
// borderStartStartRadius={4}
style={style} // workaround due to a RTL bug in Fuselage
/>
</Box>
</>;
};

export default ThreadSkeleton;
88 changes: 88 additions & 0 deletions app/threads/client/components/ThreadView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import React, { useCallback, useMemo, forwardRef, FC } from 'react';
import { Modal, Box } from '@rocket.chat/fuselage';

import { useTranslation } from '../../../../client/contexts/TranslationContext';
import VerticalBar from '../../../../client/components/basic/VerticalBar';

type ThreadViewProps = {
title: string;
expanded: boolean;
following: boolean;
onToggleExpand: (expanded: boolean) => void;
onToggleFollow: (following: boolean) => void;
onClose: () => void;
};

const ThreadView: FC<ThreadViewProps> = forwardRef<Element, ThreadViewProps>(({
title,
expanded,
following,
onToggleExpand,
onToggleFollow,
onClose,
}, ref) => {
const style = useMemo(() => (document.dir === 'rtl'
? {
left: 0,
borderTopRightRadius: 4,
}
: {
right: 0,
borderTopLeftRadius: 4,
}), []);

const t = useTranslation();

const expandLabel = expanded ? t('collapse') : t('expand');
const expandIcon = expanded ? 'arrow-collapse' : 'arrow-expand';

const handleExpandActionClick = useCallback(() => {
onToggleExpand(expanded);
}, [expanded, onToggleExpand]);

const followLabel = following ? t('Following') : t('Not_Following');
const followIcon = following ? 'bell' : 'bell-off';

const handleFollowActionClick = useCallback(() => {
onToggleFollow(following);
}, [following, onToggleFollow]);

return <>
{expanded && <Modal.Backdrop onClick={onClose}/>}

<Box width='380px' flexGrow={1} position={expanded ? 'static' : 'relative'}>
<VerticalBar
className='rcx-thread-view'
position='absolute'
display='flex'
flexDirection='column'
width={expanded ? 'full' : 380}
maxWidth={855}
overflow='hidden'
zIndex={100}
insetBlock={0}
// insetInlineEnd={0}
// borderStartStartRadius={4}
style={style} // workaround due to a RTL bug in Fuselage
>
<VerticalBar.Header>
<VerticalBar.Icon name='thread' />
<VerticalBar.Text dangerouslySetInnerHTML={{ __html: title }} />
<VerticalBar.Action aria-label={expandLabel} name={expandIcon} onClick={handleExpandActionClick} />
<VerticalBar.Action aria-label={followLabel} name={followIcon} onClick={handleFollowActionClick} />
<VerticalBar.Close onClick={onClose} />
</VerticalBar.Header>
<VerticalBar.Content
ref={ref}
{...{
flexShrink: 1,
flexGrow: 1,
paddingInline: 0,
}}
/>
</VerticalBar>
</Box>
</>;
});

export default ThreadView;
13 changes: 13 additions & 0 deletions client/admin/apps/AppsContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { createContext } from 'react';

import { App } from './types';

type AppsContextValue = {
apps: App[];
finishedLoading: boolean;
}

export const AppsContext = createContext<AppsContextValue>({
apps: [],
finishedLoading: false,
});
Loading