[IMPROVE] Threads (#17416)
Co-authored-by: Diego Sampaio <chinello@gmail.com> Co-authored-by: gabriellsh <40830821+gabriellsh@users.noreply.github.com> Co-authored-by: Tasso Evangelista <tasso.evangelista@rocket.chat>pull/17982/head
parent
6c2f4ff703
commit
b64fb67263
@ -0,0 +1,100 @@ |
||||
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 { 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 { useLocalStorage } from './hooks/useLocalstorage'; |
||||
import { normalizeThreadTitle } from '../lib/normalizeThreadTitle'; |
||||
|
||||
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 [expanded, setExpand] = useLocalStorage('expand-threads', false); |
||||
|
||||
const ref = useRef(); |
||||
const uid = useMemo(() => Meteor.userId(), []); |
||||
|
||||
|
||||
const style = useMemo(() => ({ |
||||
top: 0, |
||||
right: 0, |
||||
maxWidth: '855px', |
||||
...document.dir === 'rtl' ? { borderTopRightRadius: '4px' } : { borderTopLeftRadius: '4px' }, |
||||
overflow: 'hidden', |
||||
bottom: 0, |
||||
zIndex: 100, |
||||
}), [document.dir]); |
||||
|
||||
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]); |
||||
|
||||
const expandLabel = expanded ? 'collapse' : 'expand'; |
||||
const expandIcon = expanded ? 'arrow-collapse' : 'arrow-expand'; |
||||
|
||||
const handleExpandButton = useCallback(() => { |
||||
setExpand(!expanded); |
||||
}, [expanded]); |
||||
|
||||
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]); |
||||
|
||||
useEffect(() => { |
||||
const tracker = Tracker.autorun(async () => { |
||||
const msg = ChatMessage.findOne({ _id: mid }) || (await APIClient.v1.get('chat.getMessage', { msgId: mid })).message; |
||||
if (!msg) { |
||||
return; |
||||
} |
||||
setMainMessage(msg); |
||||
}); |
||||
return () => tracker.stop(); |
||||
}, [mid]); |
||||
|
||||
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> |
||||
</>; |
||||
} |
||||
|
||||
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>{headerTitle}</VerticalBar.Text> |
||||
<VerticalBar.Action aria-label={expandLabel} onClick={handleExpandButton} name={expandIcon}/> |
||||
<VerticalBar.Action aria-label={actionLabel} onClick={handleFollowButton} name={button}/> |
||||
<VerticalBar.Close aria-label={t('Close')} onClick={handleClose}/> |
||||
</VerticalBar.Header> |
||||
<VerticalBar.Content paddingInline={0} flexShrink={1} flexGrow={1} ref={ref}/> |
||||
</VerticalBar> |
||||
</Box> |
||||
</>; |
||||
} |
||||
@ -0,0 +1,266 @@ |
||||
import { Mongo } from 'meteor/mongo'; |
||||
import { Tracker } from 'meteor/tracker'; |
||||
import s from 'underscore.string'; |
||||
import React, { useCallback, useMemo, useState, useEffect, useRef } from 'react'; |
||||
import { Box, Icon, TextInput, Select, Margins, Callout } from '@rocket.chat/fuselage'; |
||||
import { FixedSizeList as List } from 'react-window'; |
||||
import InfiniteLoader from 'react-window-infinite-loader'; |
||||
import { useDebouncedValue, useDebouncedState, useResizeObserver } from '@rocket.chat/fuselage-hooks'; |
||||
import { css } from '@rocket.chat/css-in-js'; |
||||
|
||||
import VerticalBar from '../../../../client/components/basic/VerticalBar'; |
||||
import { useTranslation } from '../../../../client/contexts/TranslationContext'; |
||||
import RawText from '../../../../client/components/basic/RawText'; |
||||
import { useRoute } from '../../../../client/contexts/RouterContext'; |
||||
import { roomTypes } from '../../../utils/client'; |
||||
import { call, renderMessageBody } from '../../../ui-utils/client'; |
||||
import { useUserId, useUser } from '../../../../client/contexts/UserContext'; |
||||
import { Messages } from '../../../models/client'; |
||||
import { useEndpointDataExperimental, ENDPOINT_STATES } from '../../../../client/hooks/useEndpointDataExperimental'; |
||||
import { getConfig } from '../../../ui-utils/client/config'; |
||||
import { useTimeAgo } from '../../../../client/hooks/useTimeAgo'; |
||||
import ThreadListMessage, { MessageSkeleton } from './ThreadListMessage'; |
||||
import { useUserSubscription } from './hooks/useUserSubscription'; |
||||
import { useUserRoom } from './hooks/useUserRoom'; |
||||
import { useLocalStorage } from './hooks/useLocalstorage'; |
||||
import { useSetting } from '../../../../client/contexts/SettingsContext'; |
||||
|
||||
|
||||
function clickableItem(WrappedComponent) { |
||||
const clickable = css` |
||||
cursor: pointer; |
||||
border-bottom: 2px solid #F2F3F5 !important; |
||||
|
||||
&:hover, |
||||
&:focus { |
||||
background: #F7F8FA; |
||||
} |
||||
`;
|
||||
return (props) => <WrappedComponent className={clickable} tabIndex={0} {...props}/>; |
||||
} |
||||
|
||||
function mapProps(WrappedComponent) { |
||||
return ({ msg, username, replies, tcount, ts, ...props }) => <WrappedComponent replies={tcount} participants={replies.length} username={username} msg={msg} ts={ts} {...props}/>; |
||||
} |
||||
|
||||
const Thread = React.memo(mapProps(clickableItem(ThreadListMessage))); |
||||
|
||||
const Skeleton = React.memo(clickableItem(MessageSkeleton)); |
||||
|
||||
const LIST_SIZE = parseInt(getConfig('threadsListSize')) || 25; |
||||
|
||||
const filterProps = ({ msg, u, replies, mentions, tcount, ts, _id, tlm, attachments }) => ({ ..._id && { _id }, attachments, mentions, msg, u, replies, tcount, ts: new Date(ts), tlm: new Date(tlm) }); |
||||
|
||||
const subscriptionFields = { tunread: 1 }; |
||||
const roomFields = { t: 1, name: 1 }; |
||||
|
||||
export function withData(WrappedComponent) { |
||||
return ({ rid, ...props }) => { |
||||
const room = useUserRoom(rid, roomFields); |
||||
const subscription = useUserSubscription(rid, subscriptionFields); |
||||
|
||||
const userId = useUserId(); |
||||
const [type, setType] = useLocalStorage('thread-list-type', 'all'); |
||||
const [text, setText] = useState(''); |
||||
const [total, setTotal] = useState(LIST_SIZE); |
||||
const [threads, setThreads] = useDebouncedState([], 100); |
||||
const Threads = useRef(new Mongo.Collection(null)); |
||||
const ref = useRef(); |
||||
const [pagination, setPagination] = useState({ skip: 0, count: LIST_SIZE }); |
||||
|
||||
const params = useMemo(() => ({ rid: room._id, count: pagination.count, offset: pagination.skip, type, text }), [room._id, pagination.skip, pagination.count, type, text]); |
||||
|
||||
const { data, state, error } = useEndpointDataExperimental('chat.getThreadsList', useDebouncedValue(params, 400)); |
||||
|
||||
const loadMoreItems = useCallback((skip, count) => { |
||||
setPagination({ skip, count: count - skip }); |
||||
|
||||
return new Promise((resolve) => { ref.current = resolve; }); |
||||
}, []); |
||||
|
||||
useEffect(() => () => Threads.current.remove({}, () => {}), [text, type]); |
||||
|
||||
useEffect(() => { |
||||
if (state !== ENDPOINT_STATES.DONE || !data || !data.threads) { |
||||
return; |
||||
} |
||||
|
||||
data.threads.forEach(({ _id, ...message }) => { |
||||
Threads.current.upsert({ _id }, filterProps(message)); |
||||
}); |
||||
|
||||
ref.current && ref.current(); |
||||
|
||||
setTotal(data.total); |
||||
}, [data, state]); |
||||
|
||||
useEffect(() => { |
||||
const cursor = Messages.find({ rid: room._id, tcount: { $exists: true }, _hidden: { $ne: true } }).observe({ |
||||
added: ({ _id, ...message }) => { |
||||
Threads.current.upsert({ _id }, message); |
||||
}, // Update message to re-render DOM
|
||||
changed: ({ _id, ...message }) => { |
||||
Threads.current.update({ _id }, message); |
||||
}, // Update message to re-render DOM
|
||||
removed: ({ _id }) => { |
||||
Threads.current.remove(_id); |
||||
}, |
||||
}); |
||||
return () => cursor.stop(); |
||||
}, [room._id]); |
||||
|
||||
|
||||
useEffect(() => { |
||||
const cursor = Tracker.autorun(() => { |
||||
const query = { |
||||
...type === 'subscribed' && { replies: { $in: [userId] } }, |
||||
}; |
||||
setThreads(Threads.current.find(query, { sort: { tlm: -1 } }).fetch().map(filterProps)); |
||||
}); |
||||
|
||||
return () => cursor.stop(); |
||||
}, [room._id, type, setThreads, userId]); |
||||
|
||||
const handleTextChange = useCallback((e) => { |
||||
setPagination({ skip: 0, count: LIST_SIZE }); |
||||
setText(e.currentTarget.value); |
||||
}, []); |
||||
|
||||
return <WrappedComponent |
||||
{...props} |
||||
unread={subscription && subscription.tunread} |
||||
userId={userId} |
||||
error={error} |
||||
threads={threads} |
||||
total={total} |
||||
loading={state === ENDPOINT_STATES.LOADING} |
||||
loadMoreItems={loadMoreItems} |
||||
room={room} |
||||
text={text} |
||||
setText={handleTextChange} |
||||
type={type} |
||||
setType={setType} |
||||
/>; |
||||
}; |
||||
} |
||||
|
||||
const handleFollowButton = (e) => { |
||||
e.preventDefault(); |
||||
e.stopPropagation(); |
||||
call(![true, 'true'].includes(e.currentTarget.dataset.following) ? 'followMessage' : 'unfollowMessage', { mid: e.currentTarget.dataset.id }); |
||||
}; |
||||
|
||||
export const normalizeThreadMessage = ({ ...message }) => { |
||||
if (message.msg) { |
||||
return renderMessageBody(message).replace(/<br\s?\\?>/g, ' '); |
||||
} |
||||
|
||||
if (message.attachments) { |
||||
const attachment = message.attachments.find((attachment) => attachment.title || attachment.description); |
||||
|
||||
if (attachment && attachment.description) { |
||||
return s.escapeHTML(attachment.description); |
||||
} |
||||
|
||||
if (attachment && attachment.title) { |
||||
return s.escapeHTML(attachment.title); |
||||
} |
||||
} |
||||
}; |
||||
|
||||
export function ThreadList({ total = 10, threads = [], room, unread = [], type, setType, loadMoreItems, loading, onClose, error, userId, text, setText }) { |
||||
const showRealNames = useSetting('UI_Use_Real_Name'); |
||||
const threadsRef = useRef(); |
||||
|
||||
const t = useTranslation(); |
||||
|
||||
const user = useUser(); |
||||
|
||||
const channelRoute = useRoute(roomTypes.getConfig(room.t).route.name); |
||||
|
||||
const onClick = useCallback((e) => { |
||||
const { id: context } = e.currentTarget.dataset; |
||||
channelRoute.push({ |
||||
tab: 'thread', |
||||
context, |
||||
rid: room._id, |
||||
name: room.name, |
||||
}); |
||||
}, [room._id, room.name]); |
||||
|
||||
const formatDate = useTimeAgo(); |
||||
|
||||
const options = useMemo(() => [['all', t('All')], ['following', t('Following')], ['unread', t('Unread')]], []); |
||||
|
||||
threadsRef.current = threads; |
||||
|
||||
const rowRenderer = useCallback(React.memo(function rowRenderer({ data, index, style }) { |
||||
if (!data[index]) { |
||||
return <Skeleton style={style}/>; |
||||
} |
||||
const thread = data[index]; |
||||
const msg = normalizeThreadMessage(thread); |
||||
|
||||
const { name = thread.u.username } = thread.u; |
||||
|
||||
return <Thread |
||||
{ ...thread } |
||||
name={showRealNames ? name : thread.u.username } |
||||
username={ thread.u.username } |
||||
style={style} |
||||
unread={unread.includes(thread._id)} |
||||
mention={thread.mentions && thread.mentions.includes(user.username)} |
||||
all={thread.mentions && thread.mentions.includes('all')} |
||||
following={thread.replies && thread.replies.includes(userId)} |
||||
data-id={thread._id} |
||||
msg={msg} |
||||
t={t} |
||||
formatDate={formatDate} |
||||
handleFollowButton={handleFollowButton} onClick={onClick} |
||||
/>; |
||||
}), [unread, showRealNames]); |
||||
|
||||
const isItemLoaded = useCallback((index) => index < threadsRef.current.length, []); |
||||
const { ref, contentBoxSize: { inlineSize = 378, blockSize = 750 } = {} } = useResizeObserver(); |
||||
|
||||
return <VerticalBar> |
||||
<VerticalBar.Header> |
||||
<Icon name='thread' size='x20'/> |
||||
<Box flexShrink={1} flexGrow={1} withTruncatedText mi='x8'><RawText>{t('Threads')}</RawText></Box> |
||||
<VerticalBar.Close onClick={onClose}/> |
||||
</VerticalBar.Header> |
||||
<VerticalBar.Content paddingInline={0}> |
||||
<Box display='flex' flexDirection='row' p='x24' borderBlockEndWidth='x2' borderBlockEndStyle='solid' borderBlockEndColor='neutral-200'> |
||||
<Box display='flex' flexDirection='row' flexGrow={1} mi='neg-x8'> |
||||
<Margins inline='x8'> |
||||
<TextInput placeholder={t('Search_Messages')} value={text} onChange={setText} addon={<Icon name='magnifier' size='x20'/>}/> |
||||
<Select flexGrow={0} width='110px' onChange={setType} value={type} options={options} /> |
||||
</Margins> |
||||
</Box> |
||||
</Box> |
||||
<Box flexGrow={1} flexShrink={1} ref={ref}> |
||||
{error && <Callout mi='x24' type='danger'>{error.toString()}</Callout>} |
||||
{total === 0 && <Box p='x24'>{t('No_Threads')}</Box>} |
||||
<InfiniteLoader |
||||
isItemLoaded={isItemLoaded} |
||||
itemCount={total} |
||||
loadMoreItems={ loading ? () => {} : loadMoreItems} |
||||
> |
||||
{({ onItemsRendered, ref }) => (<List |
||||
height={blockSize} |
||||
width={inlineSize} |
||||
itemCount={total} |
||||
itemData={threads} |
||||
itemSize={124} |
||||
ref={ref} |
||||
minimumBatchSize={LIST_SIZE} |
||||
onItemsRendered={onItemsRendered} |
||||
>{rowRenderer}</List> |
||||
)} |
||||
</InfiniteLoader> |
||||
</Box> |
||||
</VerticalBar.Content> |
||||
</VerticalBar>; |
||||
} |
||||
|
||||
export default withData(ThreadList); |
||||
@ -0,0 +1,127 @@ |
||||
import React from 'react'; |
||||
import { Box, Margins, Button, Icon, Skeleton } from '@rocket.chat/fuselage'; |
||||
import { css } from '@rocket.chat/css-in-js'; |
||||
|
||||
import UserAvatar from '../../../../client/components/basic/avatar/UserAvatar'; |
||||
import RawText from '../../../../client/components/basic/RawText'; |
||||
|
||||
const borderRadius = css` |
||||
border-radius: 100%; |
||||
`;
|
||||
|
||||
export function NotificationStatus({ t = (e) => e, label, ...props }) { |
||||
return <Box width='x8' aria-label={t(label)} className={[borderRadius]} height='x8' {...props} />; |
||||
} |
||||
|
||||
export function NotificationStatusAll(props) { |
||||
return <NotificationStatus label='mention-all' bg='#F38C39' {...props} />; |
||||
} |
||||
|
||||
export function NotificationStatusMe(props) { |
||||
return <NotificationStatus label='Me' bg='danger-500' {...props} />; |
||||
} |
||||
|
||||
export function NotificationStatusUnread(props) { |
||||
return <NotificationStatus label='Unread' bg='primary-500' {...props} />; |
||||
} |
||||
|
||||
function isIterable(obj) { |
||||
// checks for null and undefined
|
||||
if (obj == null) { |
||||
return false; |
||||
} |
||||
return typeof obj[Symbol.iterator] === 'function'; |
||||
} |
||||
|
||||
const followStyle = css` |
||||
& > .rcx-message__container > .rcx-contextual-message__follow { |
||||
opacity: 0; |
||||
} |
||||
.rcx-contextual-message__follow:focus, |
||||
&:hover > .rcx-message__container > .rcx-contextual-message__follow, |
||||
&:focus > .rcx-message__container > .rcx-contextual-message__follow { |
||||
opacity: 1 |
||||
} |
||||
`;
|
||||
|
||||
export default function ThreadListMessage({ _id, msg, following, username, name, ts, replies, participants, handleFollowButton, unread, mention, all, t = (e) => e, formatDate = (e) => e, tlm, className = [], ...props }) { |
||||
const button = !following ? 'bell-off' : 'bell'; |
||||
const actionLabel = t(!following ? 'Not_Following' : 'Following'); |
||||
|
||||
return <Box rcx-contextual-message pi='x20' pb='x16' pbs='x16' display='flex' {...props} className={[...isIterable(className) ? className : [className], !following && followStyle].filter(Boolean)}> |
||||
<Container mb='neg-x2'> |
||||
<UserAvatar username={username} rcx-message__avatar size='x36'/> |
||||
</Container> |
||||
<Container width='1px' mb='neg-x4' flexGrow={1}> |
||||
<Header> |
||||
<Username title={username}>{name}</Username> |
||||
<Timestamp ts={formatDate(ts)}/> |
||||
</Header> |
||||
<Body><RawText>{msg}</RawText></Body> |
||||
<Box mi='neg-x2' flexDirection='row' display='flex' alignItems='baseline' mbs='x8'> |
||||
<Margins inline='x2'> |
||||
<Box display='flex' alignItems='center' is='span' fontSize='x12' color='neutral-700' fontWeight='600'><Icon name='thread' size='x20' mi='x2'/> {replies} </Box> |
||||
<Box display='flex' alignItems='center' is='span' fontSize='x12' color='neutral-700' fontWeight='600'><Icon name='user' size='x20' mi='x2'/> {participants} </Box> |
||||
<Box display='flex' alignItems='center' is='span' fontSize='x12' color='neutral-700' fontWeight='600' withTruncatedText flexShrink={1}><Icon name='clock' size='x20' mi='x2' /> {formatDate(tlm)} </Box> |
||||
</Margins> |
||||
</Box> |
||||
</Container> |
||||
<Container alignItems='center'> |
||||
<Button rcx-contextual-message__follow small square flexShrink={0} ghost data-following={following} data-id={_id} onClick={handleFollowButton} aria-label={actionLabel}><Icon name={button} size='x20'/></Button> |
||||
{ |
||||
(mention && <NotificationStatusMe t={t} mb='x24'/>) |
||||
|| (all && <NotificationStatusAll t={t} mb='x24'/>) |
||||
|| (unread && <NotificationStatusUnread t={t} mb='x24'/>) |
||||
} |
||||
</Container> |
||||
</Box>; |
||||
} |
||||
|
||||
export function MessageSkeleton(props) { |
||||
return <Box rcx-message pi='x20' pb='x16' pbs='x16' display='flex' {...props}> |
||||
<Container mb='neg-x2'> |
||||
<Skeleton variant='rect' size='x36'/> |
||||
</Container> |
||||
<Container width='1px' mb='neg-x4' flexGrow={1}> |
||||
<Header> |
||||
<Skeleton width='100%'/> |
||||
</Header> |
||||
<Body><Skeleton /><Skeleton /></Body> |
||||
<Box mi='neg-x8' flexDirection='row' display='flex' alignItems='baseline' mb='x8'> |
||||
<Margins inline='x4'> |
||||
<Skeleton /> |
||||
<Skeleton /> |
||||
<Skeleton /> |
||||
</Margins> |
||||
</Box> |
||||
</Container> |
||||
</Box>; |
||||
} |
||||
|
||||
function Container({ children, ...props }) { |
||||
return <Box rcx-message__container display='flex' mi='x4' flexDirection='column' {...props}><Margins block='x2'>{children}</Margins></Box>; |
||||
} |
||||
|
||||
function Header({ children }) { |
||||
return <Box rcx-message__header display='flex' flexGrow={0} flexShrink={1} withTruncatedText><Box mi='neg-x2' display='flex' flexDirection='row' alignItems='baseline' withTruncatedText flexGrow={1 } flexShrink={1}><Margins inline='x2'> {children} </Margins></Box></Box>; |
||||
} |
||||
|
||||
function Username(props) { |
||||
return <Box rcx-message__username color='neutral-800' fontSize='x14' fontWeight='600' flexShrink={1} withTruncatedText {...props}/>; |
||||
} |
||||
|
||||
function Timestamp({ ts }) { |
||||
return <Box rcx-message__time fontSize='c1' color='neutral-600' flexShrink={0} withTruncatedText>{ts.toDateString ? ts.toDateString() : ts }</Box>; |
||||
} |
||||
|
||||
const style = { |
||||
display: '-webkit-box', |
||||
overflow: 'hidden', |
||||
WebkitLineClamp: 2, |
||||
WebkitBoxOrient: 'vertical', |
||||
wordBreak: 'break-all', |
||||
}; |
||||
|
||||
function Body(props) { |
||||
return <Box rcx-message__body flexShrink={1} style={style} lineHeight='1.45' minHeight='40px' {...props}/>; |
||||
} |
||||
@ -0,0 +1,38 @@ |
||||
import { useState, useEffect } from 'react'; |
||||
|
||||
export function useLocalStorage(key, initialValue) { |
||||
const [storedValue, setStoredValue] = useState(() => { |
||||
try { |
||||
const item = window.localStorage.getItem(key); |
||||
return item ? JSON.parse(item) : initialValue; |
||||
} catch (error) { |
||||
console.log('useLocalStorage Error ->', error); |
||||
return initialValue; |
||||
} |
||||
}); |
||||
|
||||
const setValue = (value) => { |
||||
try { |
||||
const valueToStore = value instanceof Function ? value(storedValue) : value; |
||||
|
||||
setStoredValue(valueToStore); |
||||
|
||||
window.localStorage.setItem(key, JSON.stringify(valueToStore)); |
||||
} catch (error) { |
||||
console.log('useLocalStorage setValue Error ->', error); |
||||
} |
||||
}; |
||||
|
||||
useEffect(() => { |
||||
function handleEvent(e) { |
||||
if (e.key !== key) { |
||||
return; |
||||
} |
||||
setStoredValue(JSON.parse(e.newValue)); |
||||
} |
||||
window.addEventListener('storage', handleEvent); |
||||
return () => window.removeEventListener('storage', handleEvent); |
||||
}, []); |
||||
|
||||
return [storedValue, setValue]; |
||||
} |
||||
@ -0,0 +1,4 @@ |
||||
import { useReactiveValue } from '../../../../../client/hooks/useReactiveValue'; |
||||
import { Rooms } from '../../../../models/client'; |
||||
|
||||
export const useUserRoom = (rid, fields) => useReactiveValue(() => Rooms.findOne({ _id: rid }, { fields }), [rid, fields]); |
||||
@ -0,0 +1,4 @@ |
||||
import { useReactiveValue } from '../../../../../client/hooks/useReactiveValue'; |
||||
import { Subscriptions } from '../../../../models/client'; |
||||
|
||||
export const useUserSubscription = (rid, fields) => useReactiveValue(() => Subscriptions.findOne({ rid }, { fields }), [rid, fields]); |
||||
@ -0,0 +1,5 @@ |
||||
<template name="messageBoxFollow"> |
||||
<button class="js-follow rc-button rc-button--primary rc-message-box__join-button"> |
||||
{{_ "Follow"}} |
||||
</button> |
||||
</template> |
||||
@ -0,0 +1,11 @@ |
||||
import { Template } from 'meteor/templating'; |
||||
|
||||
import './messageBoxFollow.html'; |
||||
import { call } from '../../../ui-utils/client'; |
||||
|
||||
Template.messageBoxFollow.events({ |
||||
'click .js-follow'() { |
||||
const { tmid } = this; |
||||
call('followMessage', { mid: tmid }); |
||||
}, |
||||
}); |
||||
@ -1,40 +1,3 @@ |
||||
<template name="threads"> |
||||
{{# with messageContext}} |
||||
{{#if hasNoThreads}} |
||||
<h2 class="thread-empty">{{_ "No_Threads"}}</h2> |
||||
{{/if}} |
||||
{{#unless doDotLoadThreads}} |
||||
<div class="thread-list js-scroll-threads"> |
||||
<ul class="thread"> |
||||
{{#each thread in threads}} |
||||
{{> message |
||||
groupable=false |
||||
msg=thread |
||||
room=room |
||||
hideRoles=true |
||||
subscription=subscription |
||||
customClass="thread-message" |
||||
settings=settings |
||||
templatePrefix='threads-' |
||||
u=u |
||||
showDateSeparator=false |
||||
context="threads" |
||||
timeAgo=true |
||||
ignored=false |
||||
}} |
||||
{{/each}} |
||||
</ul> |
||||
</div> |
||||
{{#if isLoading}} |
||||
<div class="load-more"> |
||||
{{> loading}} |
||||
</div> |
||||
{{/if}} |
||||
{{/unless}} |
||||
{{#if msg}} |
||||
<div class="rc-user-info-container flex-nav"> |
||||
{{> thread mainMessage=msg room=room subscription=subscription settings=settings close=close jump=jump }} |
||||
</div> |
||||
{{/if}} |
||||
{{/with}} |
||||
{{> ThreadsList threads=threads rid=rid onClose=close }} |
||||
</template> |
||||
|
||||
@ -1,175 +1,24 @@ |
||||
import { Mongo } from 'meteor/mongo'; |
||||
import { Tracker } from 'meteor/tracker'; |
||||
import { Template } from 'meteor/templating'; |
||||
import { ReactiveDict } from 'meteor/reactive-dict'; |
||||
import _ from 'underscore'; |
||||
|
||||
import { call } from '../../../ui-utils'; |
||||
import { Messages, Subscriptions } from '../../../models'; |
||||
import { messageContext } from '../../../ui-utils/client/lib/messageContext'; |
||||
import { messageArgs } from '../../../ui-utils/client/lib/messageArgs'; |
||||
import { getConfig } from '../../../ui-utils/client/config'; |
||||
import { upsertMessageBulk } from '../../../ui-utils/client/lib/RoomHistoryManager'; |
||||
import { Template } from 'meteor/templating'; |
||||
import { HTML } from 'meteor/htmljs'; |
||||
|
||||
import './threads.html'; |
||||
import '../threads.css'; |
||||
import { createTemplateForComponent } from '../../../../client/reactAdapters'; |
||||
|
||||
const LIST_SIZE = parseInt(getConfig('threadsListSize')) || 50; |
||||
|
||||
const sort = { tlm: -1 }; |
||||
|
||||
Template.threads.events({ |
||||
'click .js-open-thread'(e, instance) { |
||||
const { msg, jump } = messageArgs(this); |
||||
instance.state.set('mid', msg._id); |
||||
instance.state.set('jump', jump); |
||||
e.preventDefault(); |
||||
e.stopPropagation(); |
||||
return false; |
||||
}, |
||||
'scroll .js-scroll-threads': _.throttle(({ currentTarget: e }, { incLimit }) => { |
||||
if (e.offsetHeight + e.scrollTop >= e.scrollHeight - 50) { |
||||
incLimit && incLimit(); |
||||
} |
||||
}, 500), |
||||
createTemplateForComponent('ThreadsList', () => import('../components/ThreadList'), { |
||||
renderContainerView: () => HTML.DIV({ class: 'contextual-bar' }), // eslint-disable-line new-cap
|
||||
}); |
||||
|
||||
Template.threads.helpers({ |
||||
jump() { |
||||
return Template.instance().state.get('jump'); |
||||
}, |
||||
subscription() { |
||||
return Template.currentData().subscription; |
||||
}, |
||||
doDotLoadThreads() { |
||||
return Template.instance().state.get('close'); |
||||
rid() { |
||||
const { rid } = Template.instance().data; |
||||
return rid; |
||||
}, |
||||
close() { |
||||
const { state, data } = Template.instance(); |
||||
const { data } = Template.instance(); |
||||
const { tabBar } = data; |
||||
return () => (state.get('close') ? tabBar.close() : state.set('mid', null)); |
||||
}, |
||||
msg() { |
||||
return Template.instance().state.get('thread'); |
||||
}, |
||||
isLoading() { |
||||
return Template.instance().state.get('loading'); |
||||
return () => tabBar.close(); |
||||
}, |
||||
hasNoThreads() { |
||||
return !Template.instance().state.get('loading') && Template.instance().Threads.find({ rid: Template.instance().state.get('rid') }, { sort }).count() === 0; |
||||
}, |
||||
threads() { |
||||
return Template.instance().Threads.find({ rid: Template.instance().state.get('rid') }, { sort, limit: Template.instance().state.get('limit') }); |
||||
}, |
||||
messageContext, |
||||
}); |
||||
|
||||
Template.threads.onCreated(async function() { |
||||
this.Threads = new Mongo.Collection(null); |
||||
const { rid, mid, msg } = this.data; |
||||
this.state = new ReactiveDict({ |
||||
rid, |
||||
close: !!mid, |
||||
loading: true, |
||||
mid, |
||||
thread: msg, |
||||
}); |
||||
|
||||
this.rid = rid; |
||||
|
||||
this.incLimit = () => { |
||||
const { rid, limit } = Tracker.nonreactive(() => this.state.all()); |
||||
|
||||
const count = this.Threads.find({ rid }).count(); |
||||
|
||||
if (limit > count) { |
||||
return; |
||||
} |
||||
|
||||
this.state.set('limit', this.state.get('limit') + LIST_SIZE); |
||||
this.loadMore(); |
||||
}; |
||||
|
||||
this.loadMore = _.debounce(async () => { |
||||
const { rid, limit } = Tracker.nonreactive(() => this.state.all()); |
||||
if (this.state.get('loading') === rid) { |
||||
return; |
||||
} |
||||
|
||||
|
||||
this.state.set('loading', rid); |
||||
const messages = await call('getThreadsList', { rid, limit: LIST_SIZE, skip: limit - LIST_SIZE }); |
||||
upsertMessageBulk({ msgs: messages }, this.Threads); |
||||
// threads.forEach(({ _id, ...msg }) => this.Threads.upsert({ _id }, msg));
|
||||
this.state.set('loading', false); |
||||
}, 500); |
||||
|
||||
Tracker.afterFlush(() => { |
||||
this.autorun(async () => { |
||||
const { rid, mid, jump } = Template.currentData(); |
||||
|
||||
this.state.set({ |
||||
close: !!mid, |
||||
mid, |
||||
rid, |
||||
jump, |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
this.autorun(() => { |
||||
if (mid) { |
||||
return; |
||||
} |
||||
const rid = this.state.get('rid'); |
||||
this.rid = rid; |
||||
this.state.set({ |
||||
limit: LIST_SIZE, |
||||
}); |
||||
this.loadMore(); |
||||
}); |
||||
|
||||
this.autorun(() => { |
||||
const rid = this.state.get('rid'); |
||||
this.threadsObserve && this.threadsObserve.stop(); |
||||
this.threadsObserve = Messages.find({ rid, tcount: { $exists: true }, _hidden: { $ne: true } }).observe({ |
||||
added: ({ _id, ...message }) => { |
||||
this.Threads.upsert({ _id }, message); |
||||
}, // Update message to re-render DOM
|
||||
changed: ({ _id, ...message }) => { |
||||
this.Threads.update({ _id }, message); |
||||
}, // Update message to re-render DOM
|
||||
removed: ({ _id }) => { |
||||
this.Threads.remove(_id); |
||||
|
||||
const mid = this.state.get('mid'); |
||||
if (_id === mid) { |
||||
this.state.set('mid', null); |
||||
} |
||||
}, |
||||
}); |
||||
|
||||
const alert = 'Unread'; |
||||
this.subscriptionObserve && this.subscriptionObserve.stop(); |
||||
this.subscriptionObserve = Subscriptions.find({ rid }, { fields: { tunread: 1 } }).observeChanges({ |
||||
added: (_id, { tunread }) => { |
||||
tunread && tunread.length && this.Threads.update({ tmid: { $in: tunread } }, { $set: { alert } }, { multi: true }); |
||||
}, |
||||
changed: (id, { tunread = [] }) => { |
||||
this.Threads.update({ alert, _id: { $nin: tunread } }, { $unset: { alert: 1 } }, { multi: true }); |
||||
tunread && tunread.length && this.Threads.update({ _id: { $in: tunread } }, { $set: { alert } }, { multi: true }); |
||||
}, |
||||
}); |
||||
}); |
||||
|
||||
this.autorun(async () => { |
||||
const mid = this.state.get('mid'); |
||||
return this.state.set('thread', mid && (Messages.findOne({ _id: mid }, { fields: { tcount: 0, tlm: 0, replies: 0, _updatedAt: 0 } }) || this.Threads.findOne({ _id: mid }, { fields: { tcount: 0, tlm: 0, replies: 0, _updatedAt: 0 } }))); |
||||
}); |
||||
}); |
||||
|
||||
Template.threads.onDestroyed(function() { |
||||
const { Threads, threadsObserve, subscriptionObserve } = this; |
||||
Threads.remove({}); |
||||
threadsObserve && threadsObserve.stop(); |
||||
subscriptionObserve && subscriptionObserve.stop(); |
||||
}); |
||||
|
||||
@ -1,7 +1,6 @@ |
||||
import './flextab/threadlist'; |
||||
import './flextab/thread'; |
||||
import './flextab/threads'; |
||||
import './threads.css'; |
||||
import './messageAction/follow'; |
||||
import './messageAction/unfollow'; |
||||
import './messageAction/replyInThread'; |
||||
|
||||
@ -0,0 +1,42 @@ |
||||
import { Meteor } from 'meteor/meteor'; |
||||
import s from 'underscore.string'; |
||||
|
||||
import { filterMarkdown } from '../../../markdown/lib/markdown'; |
||||
import { Users } from '../../../models/client'; |
||||
import { settings } from '../../../settings/client'; |
||||
import { MentionsParser } from '../../../mentions/lib/MentionsParser'; |
||||
|
||||
export const normalizeThreadTitle = ({ ...message }) => { |
||||
if (message.msg) { |
||||
const filteredMessage = filterMarkdown(message.msg); |
||||
if (!message.channels && !message.mentions) { |
||||
return filteredMessage; |
||||
} |
||||
const uid = Meteor.userId(); |
||||
const me = uid && (Users.findOne(uid, { fields: { username: 1 } }) || {}).username; |
||||
const pattern = settings.get('UTF8_Names_Validation'); |
||||
const useRealName = settings.get('UI_Use_Real_Name'); |
||||
|
||||
const instance = new MentionsParser({ |
||||
pattern: () => pattern, |
||||
useRealName: () => useRealName, |
||||
me: () => me, |
||||
userTemplate: ({ label }) => `<strong> ${ label } </strong>`, |
||||
roomTemplate: ({ channel }) => `<strong> ${ channel } </strong>`, |
||||
}); |
||||
|
||||
return instance.parse({ ...message, msg: filteredMessage, html: filteredMessage }).html; |
||||
} |
||||
|
||||
if (message.attachments) { |
||||
const attachment = message.attachments.find((attachment) => attachment.title || attachment.description); |
||||
|
||||
if (attachment && attachment.description) { |
||||
return s.escapeHTML(attachment.description); |
||||
} |
||||
|
||||
if (attachment && attachment.title) { |
||||
return s.escapeHTML(attachment.title); |
||||
} |
||||
} |
||||
}; |
||||
@ -1,19 +0,0 @@ |
||||
import { Meteor } from 'meteor/meteor'; |
||||
|
||||
import { callbacks } from '../../../callbacks/server'; |
||||
import { settings } from '../../../settings'; |
||||
import { readAllThreads } from '../functions'; |
||||
|
||||
const readThreads = (rid, { userId }) => { |
||||
readAllThreads(rid, userId); |
||||
}; |
||||
|
||||
Meteor.startup(function() { |
||||
settings.get('Threads_enabled', function(key, value) { |
||||
if (!value) { |
||||
callbacks.remove('afterReadMessages', 'threads-after-read-messages'); |
||||
return; |
||||
} |
||||
callbacks.add('afterReadMessages', readThreads, callbacks.priority.LOW, 'threads-after-read-messages'); |
||||
}); |
||||
}); |
||||
@ -1,30 +0,0 @@ |
||||
import { Meteor } from 'meteor/meteor'; |
||||
|
||||
import { callbacks } from '../../../callbacks/server'; |
||||
import { settings } from '../../../settings/server'; |
||||
import { Messages } from '../../../models/server'; |
||||
import { undoReply } from '../functions'; |
||||
|
||||
Meteor.startup(function() { |
||||
const fn = function(message) { |
||||
// is a reply from a thread
|
||||
if (message.tmid) { |
||||
undoReply(message); |
||||
} |
||||
|
||||
// is a thread
|
||||
if (message.tcount) { |
||||
Messages.removeThreadRefByThreadId(message._id); |
||||
} |
||||
|
||||
return message; |
||||
}; |
||||
|
||||
settings.get('Threads_enabled', function(key, value) { |
||||
if (!value) { |
||||
callbacks.remove('afterDeleteMessage', 'threads-after-delete-message'); |
||||
return; |
||||
} |
||||
callbacks.add('afterDeleteMessage', fn, callbacks.priority.LOW, 'threads-after-delete-message'); |
||||
}); |
||||
}); |
||||
@ -1,3 +1 @@ |
||||
import './afterdeletemessage'; |
||||
import './afterReadMessages'; |
||||
import './aftersavemessage'; |
||||
|
||||
@ -0,0 +1,61 @@ |
||||
import { ReactiveVar } from 'meteor/reactive-var'; |
||||
import { Template } from 'meteor/templating'; |
||||
import _ from 'underscore'; |
||||
|
||||
import { call, normalizeThreadMessage } from '../../ui-utils/client'; |
||||
import { Messages } from '../../models/client'; |
||||
|
||||
import './messageThread.html'; |
||||
|
||||
const findParentMessage = (() => { |
||||
const waiting = []; |
||||
let resolve; |
||||
let pending = new Promise((r) => { resolve = r; }); |
||||
|
||||
const getMessages = _.debounce(async function() { |
||||
const _tmp = [...waiting]; |
||||
waiting.length = 0; |
||||
resolve(call('getMessages', _tmp)); |
||||
pending = new Promise((r) => { resolve = r; }); |
||||
}, 500); |
||||
|
||||
const get = async (tmid) => { |
||||
getMessages(); |
||||
const messages = await pending; |
||||
return normalizeThreadMessage(messages.find(({ _id }) => _id === tmid)); |
||||
}; |
||||
|
||||
|
||||
return async (tmid) => { |
||||
const message = Messages.findOne({ _id: tmid }); |
||||
|
||||
if (message) { |
||||
return normalizeThreadMessage(message); |
||||
} |
||||
|
||||
if (waiting.indexOf(tmid) === -1) { |
||||
waiting.push(tmid); |
||||
} |
||||
return get(tmid); |
||||
}; |
||||
})(); |
||||
|
||||
Template.messageThread.helpers({ |
||||
parentMessage() { |
||||
const { parentMessage } = Template.instance(); |
||||
if (parentMessage) { |
||||
return parentMessage.get(); |
||||
} |
||||
}, |
||||
}); |
||||
|
||||
Template.messageThread.onCreated(function() { |
||||
this.parentMessage = new ReactiveVar(); |
||||
this.autorun(async () => { |
||||
const { parentMessage, tmid } = Template.currentData(); |
||||
if (parentMessage) { |
||||
return this.parentMessage.set(parentMessage); |
||||
} |
||||
this.parentMessage.set(await findParentMessage(tmid)); |
||||
}); |
||||
}); |
||||
@ -1,36 +1,35 @@ |
||||
import s from 'underscore.string'; |
||||
import md5 from 'md5'; |
||||
|
||||
import { callbacks } from '../../../callbacks'; |
||||
|
||||
const generateKeyDefault = (...args) => args.map((item) => JSON.stringify(item)).join('-'); |
||||
// const generateKeyDefault = (...args) => args.map((item) => JSON.stringify(item)).join('-');
|
||||
|
||||
const mem = (fn, tm = 500, generateKey = generateKeyDefault) => { |
||||
const cache = {}; |
||||
const timeout = {}; |
||||
// const mem = (fn, tm = 500, generateKey = generateKeyDefault) => {
|
||||
// const cache = {};
|
||||
// const timeout = {};
|
||||
|
||||
const invalidateCache = (key) => delete cache[key]; |
||||
return (...args) => { |
||||
const key = generateKey(...args); |
||||
if (!key) { |
||||
return fn(...args); |
||||
} |
||||
if (!cache[key]) { |
||||
cache[key] = fn(...args); |
||||
} |
||||
if (timeout[key]) { |
||||
clearTimeout(timeout[key]); |
||||
} |
||||
timeout[key] = setTimeout(invalidateCache, tm, key); |
||||
return cache[key]; |
||||
}; |
||||
}; |
||||
// const invalidateCache = (key) => delete cache[key];
|
||||
// return (...args) => {
|
||||
// const key = generateKey(...args);
|
||||
// if (!key) {
|
||||
// return fn(...args);
|
||||
// }
|
||||
// if (!cache[key]) {
|
||||
// cache[key] = fn(...args);
|
||||
// }
|
||||
// if (timeout[key]) {
|
||||
// clearTimeout(timeout[key]);
|
||||
// }
|
||||
// timeout[key] = setTimeout(invalidateCache, tm, key);
|
||||
// return cache[key];
|
||||
// };
|
||||
// };
|
||||
|
||||
export const renderMessageBody = mem((message) => { |
||||
export const renderMessageBody = (message) => { |
||||
message.html = s.trim(message.msg) ? s.escapeHTML(message.msg) : ''; |
||||
|
||||
const { tokens, html } = callbacks.run('renderMessage', message); |
||||
|
||||
return (Array.isArray(tokens) ? tokens.reverse() : []) |
||||
.reduce((html, { token, text }) => html.replace(token, () => text), html); |
||||
}, 500, (message) => md5(JSON.stringify(message))); |
||||
}; |
||||
|
||||
@ -1,24 +1,28 @@ |
||||
<template name="contextualBar"> |
||||
{{#if template}} |
||||
<div class="contextual-bar"> |
||||
<div class="contextual-bar-wrap"> |
||||
<header class="contextual-bar__header"> |
||||
{{#with headerData}} |
||||
<div class="contextual-bar__header-data"> |
||||
{{> icon block="contextual-bar__header-icon" icon=icon}} |
||||
<h1 class="contextual-bar__header-title">{{_ label}} |
||||
<sub class="contextual-bar__header-description">{{ description }}</sub> |
||||
</h1> |
||||
</div> |
||||
{{/with}} |
||||
<button class="contextual-bar__header-close js-close" aria-label="{{_ "Close"}}"> |
||||
{{> icon block="contextual-bar__header-close-icon" icon="plus"}} |
||||
</button> |
||||
</header> |
||||
<section class="contextual-bar__content flex-tab {{id}}"> |
||||
{{> Template.dynamic template=template data=flexData}} |
||||
</section> |
||||
{{#if full}} |
||||
{{> Template.dynamic template=template data=flexData}} |
||||
{{else}} |
||||
<div class="contextual-bar"> |
||||
<div class="contextual-bar-wrap"> |
||||
<header class="contextual-bar__header"> |
||||
{{#with headerData}} |
||||
<div class="contextual-bar__header-data"> |
||||
{{> icon block="contextual-bar__header-icon" icon=icon}} |
||||
<h1 class="contextual-bar__header-title">{{_ label}} |
||||
<sub class="contextual-bar__header-description">{{ description }}</sub> |
||||
</h1> |
||||
</div> |
||||
{{/with}} |
||||
<button class="contextual-bar__header-close js-close" aria-label="{{_ "Close"}}"> |
||||
{{> icon block="contextual-bar__header-close-icon" icon="plus"}} |
||||
</button> |
||||
</header> |
||||
<section class="contextual-bar__content flex-tab {{id}}"> |
||||
{{> Template.dynamic template=template data=flexData}} |
||||
</section> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{{/if}} |
||||
{{/if}} |
||||
</template> |
||||
|
||||
@ -0,0 +1,4 @@ |
||||
import { useCallback } from 'react'; |
||||
import moment from 'moment'; |
||||
|
||||
export const useTimeAgo = () => useCallback((time) => moment(time).calendar(null, { sameDay: 'LT', lastWeek: 'dddd LT', sameElse: 'LL' }), []); |
||||
@ -1,29 +1,40 @@ |
||||
import { Meteor } from 'meteor/meteor'; |
||||
import { Tracker } from 'meteor/tracker'; |
||||
|
||||
import { t } from '../../../app/utils'; |
||||
import { modal, MessageAction } from '../../../app/ui-utils'; |
||||
import { messageArgs } from '../../../app/ui-utils/client/lib/messageArgs'; |
||||
import { settings } from '../../../app/settings'; |
||||
|
||||
MessageAction.addButton({ |
||||
id: 'receipt-detail', |
||||
icon: 'info-circled', |
||||
label: 'Info', |
||||
context: ['starred', 'message', 'message-mobile'], |
||||
action() { |
||||
const { msg: message } = messageArgs(this); |
||||
modal.open({ |
||||
title: t('Info'), |
||||
content: 'readReceipts', |
||||
data: { |
||||
messageId: message._id, |
||||
|
||||
Meteor.startup(() => { |
||||
Tracker.autorun(() => { |
||||
const enabled = settings.get('Message_Read_Receipt_Store_Users'); |
||||
|
||||
if (!enabled) { |
||||
return MessageAction.removeButton('receipt-detail'); |
||||
} |
||||
|
||||
MessageAction.addButton({ |
||||
id: 'receipt-detail', |
||||
icon: 'info-circled', |
||||
label: 'Info', |
||||
context: ['starred', 'message', 'message-mobile', 'threads'], |
||||
action() { |
||||
const { msg: message } = messageArgs(this); |
||||
modal.open({ |
||||
title: t('Info'), |
||||
content: 'readReceipts', |
||||
data: { |
||||
messageId: message._id, |
||||
}, |
||||
showConfirmButton: true, |
||||
showCancelButton: false, |
||||
confirmButtonText: t('Close'), |
||||
}); |
||||
}, |
||||
showConfirmButton: true, |
||||
showCancelButton: false, |
||||
confirmButtonText: t('Close'), |
||||
order: 10, |
||||
group: 'menu', |
||||
}); |
||||
}, |
||||
condition() { |
||||
return settings.get('Message_Read_Receipt_Store_Users'); |
||||
}, |
||||
order: 10, |
||||
group: 'menu', |
||||
}); |
||||
}); |
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue