This commit is contained in:
Anant Bhasin 2021-05-28 21:28:00 +05:30
commit 40615a81ea
84 changed files with 20467 additions and 11899 deletions

File diff suppressed because it is too large Load Diff

View File

@ -14,9 +14,10 @@ export function createChannelSuccess(data) {
};
}
export function createChannelFailure(err) {
export function createChannelFailure(err, isTeam) {
return {
type: types.CREATE_CHANNEL.FAILURE,
err
err,
isTeam
};
}

View File

@ -0,0 +1,5 @@
export const MESSAGE_TYPE_LOAD_MORE = 'load_more';
export const MESSAGE_TYPE_LOAD_PREVIOUS_CHUNK = 'load_previous_chunk';
export const MESSAGE_TYPE_LOAD_NEXT_CHUNK = 'load_next_chunk';
export const MESSAGE_TYPE_ANY_LOAD = [MESSAGE_TYPE_LOAD_MORE, MESSAGE_TYPE_LOAD_PREVIOUS_CHUNK, MESSAGE_TYPE_LOAD_NEXT_CHUNK];

View File

@ -1,6 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Text } from 'react-native';
import { Text, View } from 'react-native';
import { themes } from '../../constants/colors';
import { CustomIcon } from '../../lib/Icons';
@ -20,12 +20,19 @@ export const Item = React.memo(({ item, hide, theme }) => {
theme={theme}
>
<CustomIcon name={item.icon} size={20} color={item.danger ? themes[theme].dangerColor : themes[theme].bodyText} />
<View style={styles.titleContainer}>
<Text
numberOfLines={1}
style={[styles.title, { color: item.danger ? themes[theme].dangerColor : themes[theme].bodyText }]}
>
{item.title}
</Text>
</View>
{ item.right ? (
<View style={styles.rightContainer}>
{item.right ? item.right() : null}
</View>
) : null }
</Button>
);
});
@ -34,7 +41,8 @@ Item.propTypes = {
title: PropTypes.string,
icon: PropTypes.string,
danger: PropTypes.bool,
onPress: PropTypes.func
onPress: PropTypes.func,
right: PropTypes.func
}),
hide: PropTypes.func,
theme: PropTypes.string

View File

@ -22,6 +22,9 @@ export default StyleSheet.create({
content: {
paddingTop: 16
},
titleContainer: {
flex: 1
},
title: {
fontSize: 16,
marginLeft: 16,
@ -58,5 +61,8 @@ export default StyleSheet.create({
fontSize: 16,
...sharedStyles.textMedium,
...sharedStyles.textAlignCenter
},
rightContainer: {
paddingLeft: 12
}
});

View File

@ -5,6 +5,7 @@ import PropTypes from 'prop-types';
import { themes } from '../../constants/colors';
import { CustomIcon } from '../../lib/Icons';
import { withTheme } from '../../theme';
import { ICON_SIZE } from './constants';
const styles = StyleSheet.create({
icon: {
@ -23,7 +24,7 @@ const ListIcon = React.memo(({
<CustomIcon
name={name}
color={color ?? themes[theme].auxiliaryText}
size={20}
size={ICON_SIZE}
/>
</View>
));

View File

@ -10,8 +10,9 @@ import sharedStyles from '../../views/Styles';
import { withTheme } from '../../theme';
import I18n from '../../i18n';
import { Icon } from '.';
import { BASE_HEIGHT, PADDING_HORIZONTAL } from './constants';
import { BASE_HEIGHT, ICON_SIZE, PADDING_HORIZONTAL } from './constants';
import { withDimensions } from '../../dimensions';
import { CustomIcon } from '../../lib/Icons';
const styles = StyleSheet.create({
container: {
@ -34,7 +35,15 @@ const styles = StyleSheet.create({
flex: 1,
justifyContent: 'center'
},
textAlertContainer: {
flexDirection: 'row',
alignItems: 'center'
},
alertIcon: {
paddingLeft: 4
},
title: {
flexShrink: 1,
fontSize: 16,
...sharedStyles.textRegular
},
@ -50,7 +59,7 @@ const styles = StyleSheet.create({
});
const Content = React.memo(({
title, subtitle, disabled, testID, left, right, color, theme, translateTitle, translateSubtitle, showActionIndicator, fontScale
title, subtitle, disabled, testID, left, right, color, theme, translateTitle, translateSubtitle, showActionIndicator, fontScale, alert
}) => (
<View style={[styles.container, disabled && styles.disabled, { height: BASE_HEIGHT * fontScale }]} testID={testID}>
{left
@ -61,7 +70,12 @@ const Content = React.memo(({
)
: null}
<View style={styles.textContainer}>
<Text style={[styles.title, { color: color || themes[theme].titleText }]} accessibilityLabel={title} numberOfLines={1}>{translateTitle ? I18n.t(title) : title}</Text>
<View style={styles.textAlertContainer}>
<Text style={[styles.title, { color: color || themes[theme].titleText }]} numberOfLines={1}>{translateTitle ? I18n.t(title) : title}</Text>
{alert ? (
<CustomIcon style={[styles.alertIcon, { color: themes[theme].dangerColor }]} size={ICON_SIZE} name='info' />
) : null}
</View>
{subtitle
? <Text style={[styles.subtitle, { color: themes[theme].auxiliaryText }]} numberOfLines={1}>{translateSubtitle ? I18n.t(subtitle) : subtitle}</Text>
: null
@ -123,7 +137,8 @@ Content.propTypes = {
translateTitle: PropTypes.bool,
translateSubtitle: PropTypes.bool,
showActionIndicator: PropTypes.bool,
fontScale: PropTypes.number
fontScale: PropTypes.number,
alert: PropTypes.bool
};
Content.defaultProps = {

View File

@ -1,2 +1,3 @@
export const PADDING_HORIZONTAL = 12;
export const BASE_HEIGHT = 46;
export const ICON_SIZE = 20;

View File

@ -30,6 +30,7 @@ const RoomTypeIcon = React.memo(({
return <Status style={[iconStyle, { color: STATUS_COLORS[status] ?? STATUS_COLORS.offline }]} size={size} status={status} />;
}
// TODO: move this to a separate function
let icon = 'channel-private';
if (teamMain) {
icon = `teams${ type === 'p' ? '-private' : '' }`;

View File

@ -4,19 +4,18 @@ import { Text, Clipboard } from 'react-native';
import styles from './styles';
import { themes } from '../../constants/colors';
import openLink from '../../utils/openLink';
import { LISTENER } from '../Toast';
import EventEmitter from '../../utils/events';
import I18n from '../../i18n';
const Link = React.memo(({
children, link, theme
children, link, theme, onLinkPress
}) => {
const handlePress = () => {
if (!link) {
return;
}
openLink(link, theme);
onLinkPress(link);
};
const childLength = React.Children.toArray(children).filter(o => o).length;
@ -40,7 +39,8 @@ const Link = React.memo(({
Link.propTypes = {
children: PropTypes.node,
link: PropTypes.string,
theme: PropTypes.string
theme: PropTypes.string,
onLinkPress: PropTypes.func
};
export default Link;

View File

@ -82,7 +82,8 @@ class Markdown extends PureComponent {
preview: PropTypes.bool,
theme: PropTypes.string,
testID: PropTypes.string,
style: PropTypes.array
style: PropTypes.array,
onLinkPress: PropTypes.func
};
constructor(props) {
@ -218,11 +219,12 @@ class Markdown extends PureComponent {
};
renderLink = ({ children, href }) => {
const { theme } = this.props;
const { theme, onLinkPress } = this.props;
return (
<MarkdownLink
link={href}
theme={theme}
onLinkPress={onLinkPress}
>
{children}
</MarkdownLink>

View File

@ -45,7 +45,7 @@ const Content = React.memo((props) => {
} else if (props.isEncrypted) {
content = <Text style={[styles.textInfo, { color: themes[props.theme].auxiliaryText }]}>{I18n.t('Encrypted_message')}</Text>;
} else {
const { baseUrl, user } = useContext(MessageContext);
const { baseUrl, user, onLinkPress } = useContext(MessageContext);
content = (
<Markdown
msg={props.msg}
@ -61,6 +61,7 @@ const Content = React.memo((props) => {
tmid={props.tmid}
useRealName={props.useRealName}
theme={props.theme}
onLinkPress={onLinkPress}
/>
);
}

View File

@ -19,6 +19,7 @@ import Discussion from './Discussion';
import Content from './Content';
import ReadReceipt from './ReadReceipt';
import CallButton from './CallButton';
import { themes } from '../../constants/colors';
const MessageInner = React.memo((props) => {
if (props.type === 'discussion-created') {
@ -120,6 +121,7 @@ const MessageTouchable = React.memo((props) => {
onLongPress={onLongPress}
onPress={onPress}
disabled={(props.isInfo && !props.isThreadReply) || props.archived || props.isTemp}
style={{ backgroundColor: props.highlighted ? themes[props.theme].headerBackground : null }}
>
<View>
<Message {...props} />
@ -134,7 +136,9 @@ MessageTouchable.propTypes = {
isInfo: PropTypes.bool,
isThreadReply: PropTypes.bool,
isTemp: PropTypes.bool,
archived: PropTypes.bool
archived: PropTypes.bool,
highlighted: PropTypes.bool,
theme: PropTypes.string
};
Message.propTypes = {

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { memo, useEffect, useState } from 'react';
import { View } from 'react-native';
import PropTypes from 'prop-types';
@ -8,22 +8,27 @@ import { themes } from '../../constants/colors';
import I18n from '../../i18n';
import Markdown from '../markdown';
const RepliedThread = React.memo(({
const RepliedThread = memo(({
tmid, tmsg, isHeader, fetchThreadName, id, isEncrypted, theme
}) => {
if (!tmid || !isHeader) {
return null;
}
if (!tmsg) {
fetchThreadName(tmid, id);
return null;
const [msg, setMsg] = useState(isEncrypted ? I18n.t('Encrypted_message') : tmsg);
const fetch = async() => {
const threadName = await fetchThreadName(tmid, id);
setMsg(threadName);
};
useEffect(() => {
if (!msg) {
fetch();
}
}, []);
let msg = tmsg;
if (isEncrypted) {
msg = I18n.t('Encrypted_message');
if (!msg) {
return null;
}
return (
@ -45,23 +50,6 @@ const RepliedThread = React.memo(({
</View>
</View>
);
}, (prevProps, nextProps) => {
if (prevProps.tmid !== nextProps.tmid) {
return false;
}
if (prevProps.tmsg !== nextProps.tmsg) {
return false;
}
if (prevProps.isEncrypted !== nextProps.isEncrypted) {
return false;
}
if (prevProps.isHeader !== nextProps.isHeader) {
return false;
}
if (prevProps.theme !== nextProps.theme) {
return false;
}
return true;
});
RepliedThread.propTypes = {

View File

@ -142,10 +142,13 @@ const Reply = React.memo(({
if (!attachment) {
return null;
}
const { baseUrl, user } = useContext(MessageContext);
const { baseUrl, user, jumpToMessage } = useContext(MessageContext);
const onPress = () => {
let url = attachment.title_link || attachment.author_link;
if (attachment.message_link) {
return jumpToMessage(attachment.message_link);
}
if (!url) {
return;
}

View File

@ -80,7 +80,7 @@ const UrlContent = React.memo(({ title, description, theme }) => (
});
const Url = React.memo(({ url, index, theme }) => {
if (!url) {
if (!url || url?.ignoreParse) {
return null;
}

View File

@ -9,6 +9,7 @@ import { SYSTEM_MESSAGES, getMessageTranslation } from './utils';
import { E2E_MESSAGE_TYPE, E2E_STATUS } from '../../lib/encryption/constants';
import messagesStatus from '../../constants/messagesStatus';
import { withTheme } from '../../theme';
import openLink from '../../utils/openLink';
class MessageContainer extends React.Component {
static propTypes = {
@ -33,6 +34,7 @@ class MessageContainer extends React.Component {
autoTranslateLanguage: PropTypes.string,
status: PropTypes.number,
isIgnored: PropTypes.bool,
highlighted: PropTypes.bool,
getCustomEmoji: PropTypes.func,
onLongPress: PropTypes.func,
onReactionPress: PropTypes.func,
@ -50,7 +52,9 @@ class MessageContainer extends React.Component {
blockAction: PropTypes.func,
theme: PropTypes.string,
threadBadgeColor: PropTypes.string,
toggleFollowThread: PropTypes.func
toggleFollowThread: PropTypes.func,
jumpToMessage: PropTypes.func,
onPress: PropTypes.func
}
static defaultProps = {
@ -89,10 +93,15 @@ class MessageContainer extends React.Component {
shouldComponentUpdate(nextProps, nextState) {
const { isManualUnignored } = this.state;
const { theme, threadBadgeColor, isIgnored } = this.props;
const {
theme, threadBadgeColor, isIgnored, highlighted
} = this.props;
if (nextProps.theme !== theme) {
return true;
}
if (nextProps.highlighted !== highlighted) {
return true;
}
if (nextProps.threadBadgeColor !== threadBadgeColor) {
return true;
}
@ -112,10 +121,15 @@ class MessageContainer extends React.Component {
}
onPress = debounce(() => {
const { onPress } = this.props;
if (this.isIgnored) {
return this.onIgnoredMessagePress();
}
if (onPress) {
return onPress();
}
const { item, isThreadRoom } = this.props;
Keyboard.dismiss();
@ -265,12 +279,69 @@ class MessageContainer extends React.Component {
}
}
onLinkPress = (link) => {
const { item, theme, jumpToMessage } = this.props;
const isMessageLink = item?.attachments?.findIndex(att => att?.message_link === link) !== -1;
if (isMessageLink) {
return jumpToMessage(link);
}
openLink(link, theme);
}
render() {
const {
item, user, style, archived, baseUrl, useRealName, broadcast, fetchThreadName, showAttachment, timeFormat, isReadReceiptEnabled, autoTranslateRoom, autoTranslateLanguage, navToRoomInfo, getCustomEmoji, isThreadRoom, callJitsi, blockAction, rid, theme, threadBadgeColor, toggleFollowThread
item,
user,
style,
archived,
baseUrl,
useRealName,
broadcast,
fetchThreadName,
showAttachment,
timeFormat,
isReadReceiptEnabled,
autoTranslateRoom,
autoTranslateLanguage,
navToRoomInfo,
getCustomEmoji,
isThreadRoom,
callJitsi,
blockAction,
rid,
theme,
threadBadgeColor,
toggleFollowThread,
jumpToMessage,
highlighted
} = this.props;
const {
id, msg, ts, attachments, urls, reactions, t, avatar, emoji, u, alias, editedBy, role, drid, dcount, dlm, tmid, tcount, tlm, tmsg, mentions, channels, unread, blocks, autoTranslate: autoTranslateMessage, replies
id,
msg,
ts,
attachments,
urls,
reactions,
t,
avatar,
emoji,
u,
alias,
editedBy,
role,
drid,
dcount,
dlm,
tmid,
tcount,
tlm,
tmsg,
mentions,
channels,
unread,
blocks,
autoTranslate: autoTranslateMessage,
replies
} = item;
let message = msg;
@ -294,6 +365,8 @@ class MessageContainer extends React.Component {
onEncryptedPress: this.onEncryptedPress,
onDiscussionPress: this.onDiscussionPress,
onReactionLongPress: this.onReactionLongPress,
onLinkPress: this.onLinkPress,
jumpToMessage,
threadBadgeColor,
toggleFollowThread,
replies
@ -347,6 +420,7 @@ class MessageContainer extends React.Component {
callJitsi={callJitsi}
blockAction={blockAction}
theme={theme}
highlighted={highlighted}
/>
</MessageContext.Provider>
);

View File

@ -61,6 +61,7 @@
"error-message-editing-blocked": "Message editing is blocked",
"error-message-size-exceeded": "Message size exceeds Message_MaxAllowedSize",
"error-missing-unsubscribe-link": "You must provide the [unsubscribe] link.",
"error-no-owner-channel":"You don't own the channel",
"error-no-tokens-for-this-user": "There are no tokens for this user",
"error-not-allowed": "Not allowed",
"error-not-authorized": "Not authorized",
@ -90,6 +91,7 @@
"alert": "alert",
"alerts": "alerts",
"All_users_in_the_channel_can_write_new_messages": "All users in the channel can write new messages",
"All_users_in_the_team_can_write_new_messages": "All users in the team can write new messages",
"A_meaningful_name_for_the_discussion_room": "A meaningful name for the discussion room",
"All": "All",
"All_Messages": "All Messages",
@ -225,6 +227,7 @@
"Encryption_error_title": "Your encryption password seems wrong",
"Encryption_error_desc": "It wasn't possible to decode your encryption key to be imported.",
"Everyone_can_access_this_channel": "Everyone can access this channel",
"Everyone_can_access_this_team": "Everyone can access this team",
"Error_uploading": "Error uploading",
"Expiration_Days": "Expiration (Days)",
"Favorite": "Favorite",
@ -286,10 +289,12 @@
"Join_our_open_workspace": "Join our open workspace",
"Join_your_workspace": "Join your workspace",
"Just_invited_people_can_access_this_channel": "Just invited people can access this channel",
"Just_invited_people_can_access_this_team": "Just invited people can access this team",
"Language": "Language",
"last_message": "last message",
"Leave_channel": "Leave channel",
"leaving_room": "leaving room",
"Leave": "Leave",
"leave": "leave",
"Legal": "Legal",
"Light": "Light",
@ -435,6 +440,7 @@
"Review_app_unable_store": "Unable to open {{store}}",
"Review_this_app": "Review this app",
"Remove": "Remove",
"remove": "remove",
"Roles": "Roles",
"Room_actions": "Room actions",
"Room_changed_announcement": "Room announcement changed to: {{announcement}} by {{userBy}}",
@ -681,12 +687,9 @@
"No_threads_following": "You are not following any threads",
"No_threads_unread": "There are no unread threads",
"Messagebox_Send_to_channel": "Send to channel",
"Set_as_leader": "Set as leader",
"Set_as_moderator": "Set as moderator",
"Set_as_owner": "Set as owner",
"Remove_as_leader": "Remove as leader",
"Remove_as_moderator": "Remove as moderator",
"Remove_as_owner": "Remove as owner",
"Leader": "Leader",
"Moderator": "Moderator",
"Owner": "Owner",
"Remove_from_room": "Remove from room",
"Ignore": "Ignore",
"Unignore": "Unignore",
@ -716,5 +719,36 @@
"Read_Only_Team": "Read Only Team",
"Broadcast_Team": "Broadcast Team",
"creating_team": "creating team",
"team-name-already-exists": "A team with that name already exists"
"team-name-already-exists": "A team with that name already exists",
"Add_Channel_to_Team": "Add Channel to Team",
"Create_New": "Create New",
"Add_Existing": "Add Existing",
"Add_Existing_Channel": "Add Existing Channel",
"Remove_from_Team": "Remove from Team",
"Auto-join": "Auto-join",
"Remove_Team_Room_Warning": "Woud you like to remove this channel from the team? The channel will be moved back to the workspace",
"Confirmation": "Confirmation",
"invalid-room": "Invalid room",
"You_are_leaving_the_team": "You are leaving the team '{{team}}'",
"Leave_Team": "Leave Team",
"Select_Team_Channels": "Select the Team's channels you would like to leave.",
"Cannot_leave": "Cannot leave",
"Cannot_remove": "Cannot remove",
"Cannot_delete": "Cannot delete",
"Last_owner_team_room": "You are the last owner of this channel. Once you leave the team, the channel will be kept inside the team but you will be managing it from outside.",
"last-owner-can-not-be-removed": "Last owner cannot be removed",
"Remove_User_Teams": "Select channels you want the user to be removed from.",
"Delete_Team": "Delete Team",
"Select_channels_to_delete": "This can't be undone. Once you delete a team, all chat content and configuration will be deleted. \n\nSelect the channels you would like to delete. The ones you decide to keep will be available on your workspace. Notice that public channels will still be public and visible to everyone.",
"You_are_deleting_the_team": "You are deleting this team.",
"Removing_user_from_this_team": "You are removing {{user}} from this team",
"Remove_User_Team_Channels": "Select the channels you want the user to be removed from.",
"Remove_Member": "Remove Member",
"leaving_team": "leaving team",
"removing_team": "removing from team",
"deleting_team": "deleting team",
"member-does-not-exist": "Member does not exist",
"Load_More": "Load More",
"Load_Newer": "Load Newer",
"Load_Older": "Load Older"
}

View File

@ -667,5 +667,6 @@
"Teams": "Times",
"No_team_channels_found": "Nenhum canal encontrado",
"Team_not_found": "Time não encontrado",
"Private_Team": "Equipe Privada"
"Private_Team": "Equipe Privada",
"Add_Existing_Channel": "Adicionar Canal Existente"
}

View File

@ -5,8 +5,10 @@ import {
import { sanitizer } from '../utils';
export const TABLE_NAME = 'messages';
export default class Message extends Model {
static table = 'messages';
static table = TABLE_NAME;
static associations = {
subscriptions: { type: 'belongs_to', key: 'rid' }

View File

@ -4,8 +4,10 @@ import {
} from '@nozbe/watermelondb/decorators';
import { sanitizer } from '../utils';
export const TABLE_NAME = 'subscriptions';
export default class Subscription extends Model {
static table = 'subscriptions';
static table = TABLE_NAME;
static associations = {
messages: { type: 'has_many', foreignKey: 'rid' },

View File

@ -5,8 +5,10 @@ import {
import { sanitizer } from '../utils';
export const TABLE_NAME = 'threads';
export default class Thread extends Model {
static table = 'threads';
static table = TABLE_NAME;
static associations = {
subscriptions: { type: 'belongs_to', key: 'rid' }

View File

@ -5,8 +5,10 @@ import {
import { sanitizer } from '../utils';
export const TABLE_NAME = 'thread_messages';
export default class ThreadMessage extends Model {
static table = 'thread_messages';
static table = TABLE_NAME;
static associations = {
subscriptions: { type: 'belongs_to', key: 'subscription_id' }

View File

@ -0,0 +1,15 @@
import database from '..';
import { TABLE_NAME } from '../model/Message';
const getCollection = db => db.get(TABLE_NAME);
export const getMessageById = async(messageId) => {
const db = database.active;
const messageCollection = getCollection(db);
try {
const result = await messageCollection.find(messageId);
return result;
} catch (error) {
return null;
}
};

View File

@ -0,0 +1,15 @@
import database from '..';
import { TABLE_NAME } from '../model/Subscription';
const getCollection = db => db.get(TABLE_NAME);
export const getSubscriptionByRoomId = async(rid) => {
const db = database.active;
const subCollection = getCollection(db);
try {
const result = await subCollection.find(rid);
return result;
} catch (error) {
return null;
}
};

View File

@ -0,0 +1,15 @@
import database from '..';
import { TABLE_NAME } from '../model/Thread';
const getCollection = db => db.get(TABLE_NAME);
export const getThreadById = async(tmid) => {
const db = database.active;
const threadCollection = getCollection(db);
try {
const result = await threadCollection.find(tmid);
return result;
} catch (error) {
return null;
}
};

View File

@ -0,0 +1,15 @@
import database from '..';
import { TABLE_NAME } from '../model/ThreadMessage';
const getCollection = db => db.get(TABLE_NAME);
export const getThreadMessageById = async(messageId) => {
const db = database.active;
const threadMessageCollection = getCollection(db);
try {
const result = await threadMessageCollection.find(messageId);
return result;
} catch (error) {
return null;
}
};

View File

@ -13,19 +13,24 @@ const PERMISSIONS = [
'add-user-to-any-c-room',
'add-user-to-any-p-room',
'add-user-to-joined-room',
'add-team-channel',
'archive-room',
'auto-translate',
'create-invite-links',
'delete-c',
'delete-message',
'delete-p',
'delete-team',
'edit-message',
'edit-room',
'edit-team-member',
'edit-team-channel',
'force-delete-message',
'mute-user',
'pin-message',
'post-readonly',
'remove-user',
'remove-team-channel',
'set-leader',
'set-moderator',
'set-owner',
@ -38,7 +43,9 @@ const PERMISSIONS = [
'view-privileged-setting',
'view-room-administration',
'view-statistics',
'view-user-administration'
'view-user-administration',
'view-all-teams',
'view-all-team-channels'
];
export async function setPermissions() {

View File

@ -0,0 +1,29 @@
import { getSubscriptionByRoomId } from '../database/services/Subscription';
import RocketChat from '../rocketchat';
const getRoomInfo = async(rid) => {
let result;
result = await getSubscriptionByRoomId(rid);
if (result) {
return {
rid,
name: result.name,
fname: result.fname,
t: result.t
};
}
result = await RocketChat.getRoomInfo(rid);
if (result?.success) {
return {
rid,
name: result.room.name,
fname: result.room.fname,
t: result.room.t
};
}
return null;
};
export default getRoomInfo;

View File

@ -0,0 +1,15 @@
import RocketChat from '../rocketchat';
const getSingleMessage = messageId => new Promise(async(resolve, reject) => {
try {
const result = await RocketChat.getSingleMessage(messageId);
if (result.success) {
return resolve(result.message);
}
return reject();
} catch (e) {
return reject();
}
});
export default getSingleMessage;

View File

@ -0,0 +1,49 @@
import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord';
import database from '../database';
import { getMessageById } from '../database/services/Message';
import { getThreadById } from '../database/services/Thread';
import log from '../../utils/log';
import getSingleMessage from './getSingleMessage';
import { Encryption } from '../encryption';
const buildThreadName = thread => thread.msg || thread?.attachments?.[0]?.title;
const getThreadName = async(rid, tmid, messageId) => {
let tmsg;
try {
const db = database.active;
const threadCollection = db.get('threads');
const messageRecord = await getMessageById(messageId);
const threadRecord = await getThreadById(tmid);
if (threadRecord) {
tmsg = buildThreadName(threadRecord);
await db.action(async() => {
await messageRecord?.update((m) => {
m.tmsg = tmsg;
});
});
} else {
let thread = await getSingleMessage(tmid);
thread = await Encryption.decryptMessage(thread);
tmsg = buildThreadName(thread);
await db.action(async() => {
await db.batch(
threadCollection?.prepareCreate((t) => {
t._raw = sanitizedRaw({ id: thread._id }, threadCollection.schema);
t.subscription.id = rid;
Object.assign(t, thread);
}),
messageRecord?.prepareUpdate((m) => {
m.tmsg = tmsg;
})
);
});
}
} catch (e) {
log(e);
}
return tmsg;
};
export default getThreadName;

View File

@ -1,8 +1,15 @@
import moment from 'moment';
import { MESSAGE_TYPE_LOAD_MORE } from '../../constants/messageTypeLoad';
import log from '../../utils/log';
import { getMessageById } from '../database/services/Message';
import updateMessages from './updateMessages';
import { generateLoadMoreId } from '../utils';
const COUNT = 50;
async function load({ rid: roomId, latest, t }) {
let params = { roomId, count: 50 };
let params = { roomId, count: COUNT };
if (latest) {
params = { ...params, latest: new Date(latest).toISOString() };
}
@ -24,9 +31,20 @@ export default function loadMessagesForRoom(args) {
return new Promise(async(resolve, reject) => {
try {
const data = await load.call(this, args);
if (data && data.length) {
await updateMessages({ rid: args.rid, update: data });
if (data?.length) {
const lastMessage = data[data.length - 1];
const lastMessageRecord = await getMessageById(lastMessage._id);
if (!lastMessageRecord && data.length === COUNT) {
const loadMoreItem = {
_id: generateLoadMoreId(lastMessage._id),
rid: lastMessage.rid,
ts: moment(lastMessage.ts).subtract(1, 'millisecond'),
t: MESSAGE_TYPE_LOAD_MORE,
msg: lastMessage.msg
};
data.push(loadMoreItem);
}
await updateMessages({ rid: args.rid, update: data, loaderItem: args.loaderItem });
return resolve(data);
} else {
return resolve([]);

View File

@ -0,0 +1,42 @@
import EJSON from 'ejson';
import moment from 'moment';
import orderBy from 'lodash/orderBy';
import log from '../../utils/log';
import updateMessages from './updateMessages';
import { getMessageById } from '../database/services/Message';
import { MESSAGE_TYPE_LOAD_NEXT_CHUNK } from '../../constants/messageTypeLoad';
import { generateLoadMoreId } from '../utils';
const COUNT = 50;
export default function loadNextMessages(args) {
return new Promise(async(resolve, reject) => {
try {
const data = await this.methodCallWrapper('loadNextMessages', args.rid, args.ts, COUNT);
let messages = EJSON.fromJSONValue(data?.messages);
messages = orderBy(messages, 'ts');
if (messages?.length) {
const lastMessage = messages[messages.length - 1];
const lastMessageRecord = await getMessageById(lastMessage._id);
if (!lastMessageRecord && messages.length === COUNT) {
const loadMoreItem = {
_id: generateLoadMoreId(lastMessage._id),
rid: lastMessage.rid,
tmid: args.tmid,
ts: moment(lastMessage.ts).add(1, 'millisecond'),
t: MESSAGE_TYPE_LOAD_NEXT_CHUNK
};
messages.push(loadMoreItem);
}
await updateMessages({ rid: args.rid, update: messages, loaderItem: args.loaderItem });
return resolve(messages);
} else {
return resolve([]);
}
} catch (e) {
log(e);
reject(e);
}
});
}

View File

@ -0,0 +1,65 @@
import EJSON from 'ejson';
import moment from 'moment';
import orderBy from 'lodash/orderBy';
import log from '../../utils/log';
import updateMessages from './updateMessages';
import { getMessageById } from '../database/services/Message';
import { MESSAGE_TYPE_LOAD_NEXT_CHUNK, MESSAGE_TYPE_LOAD_PREVIOUS_CHUNK } from '../../constants/messageTypeLoad';
import { generateLoadMoreId } from '../utils';
const COUNT = 50;
export default function loadSurroundingMessages({ messageId, rid }) {
return new Promise(async(resolve, reject) => {
try {
const data = await this.methodCallWrapper('loadSurroundingMessages', { _id: messageId, rid }, COUNT);
let messages = EJSON.fromJSONValue(data?.messages);
messages = orderBy(messages, 'ts');
const message = messages.find(m => m._id === messageId);
const { tmid } = message;
if (messages?.length) {
if (data?.moreBefore) {
const firstMessage = messages[0];
const firstMessageRecord = await getMessageById(firstMessage._id);
if (!firstMessageRecord) {
const loadMoreItem = {
_id: generateLoadMoreId(firstMessage._id),
rid: firstMessage.rid,
tmid,
ts: moment(firstMessage.ts).subtract(1, 'millisecond'),
t: MESSAGE_TYPE_LOAD_PREVIOUS_CHUNK,
msg: firstMessage.msg
};
messages.unshift(loadMoreItem);
}
}
if (data?.moreAfter) {
const lastMessage = messages[messages.length - 1];
const lastMessageRecord = await getMessageById(lastMessage._id);
if (!lastMessageRecord) {
const loadMoreItem = {
_id: generateLoadMoreId(lastMessage._id),
rid: lastMessage.rid,
tmid,
ts: moment(lastMessage.ts).add(1, 'millisecond'),
t: MESSAGE_TYPE_LOAD_NEXT_CHUNK,
msg: lastMessage.msg
};
messages.push(loadMoreItem);
}
}
await updateMessages({ rid, update: messages });
return resolve(messages);
} else {
return resolve([]);
}
} catch (e) {
log(e);
reject(e);
}
});
}

View File

@ -1,5 +1,6 @@
import { Q } from '@nozbe/watermelondb';
import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord';
import EJSON from 'ejson';
import buildMessage from './helpers/buildMessage';
import database from '../database';
@ -7,30 +8,27 @@ import log from '../../utils/log';
import protectedFunction from './helpers/protectedFunction';
import { Encryption } from '../encryption';
async function load({ tmid, offset }) {
async function load({ tmid }) {
try {
// RC 1.0
const result = await this.sdk.get('chat.getThreadMessages', {
tmid, count: 50, offset, sort: { ts: -1 }, query: { _hidden: { $ne: true } }
});
if (!result || !result.success) {
const result = await this.methodCallWrapper('getThreadMessages', { tmid });
if (!result) {
return [];
}
return result.messages;
return EJSON.fromJSONValue(result);
} catch (error) {
console.log(error);
return [];
}
}
export default function loadThreadMessages({ tmid, rid, offset = 0 }) {
export default function loadThreadMessages({ tmid, rid }) {
return new Promise(async(resolve, reject) => {
try {
let data = await load.call(this, { tmid, offset });
let data = await load.call(this, { tmid });
if (data && data.length) {
try {
data = data.map(m => buildMessage(m));
data = data.filter(m => m.tmid).map(m => buildMessage(m));
data = await Encryption.decryptMessages(data);
const db = database.active;
const threadMessagesCollection = db.get('thread_messages');

View File

@ -159,7 +159,7 @@ export default class RoomSubscription {
updateMessage = message => (
new Promise(async(resolve) => {
if (this.rid !== message.rid) {
return;
return resolve();
}
const db = database.active;

View File

@ -6,8 +6,12 @@ import log from '../../utils/log';
import database from '../database';
import protectedFunction from './helpers/protectedFunction';
import { Encryption } from '../encryption';
import { MESSAGE_TYPE_ANY_LOAD } from '../../constants/messageTypeLoad';
import { generateLoadMoreId } from '../utils';
export default function updateMessages({ rid, update = [], remove = [] }) {
export default function updateMessages({
rid, update = [], remove = [], loaderItem
}) {
try {
if (!((update && update.length) || (remove && remove.length))) {
return;
@ -30,7 +34,13 @@ export default function updateMessages({ rid, update = [], remove = [] }) {
const threadCollection = db.get('threads');
const threadMessagesCollection = db.get('thread_messages');
const allMessagesRecords = await msgCollection
.query(Q.where('rid', rid), Q.where('id', Q.oneOf(messagesIds)))
.query(
Q.where('rid', rid),
Q.or(
Q.where('id', Q.oneOf(messagesIds)),
Q.where('t', Q.oneOf(MESSAGE_TYPE_ANY_LOAD))
)
)
.fetch();
const allThreadsRecords = await threadCollection
.query(Q.where('rid', rid), Q.where('id', Q.oneOf(messagesIds)))
@ -55,6 +65,9 @@ export default function updateMessages({ rid, update = [], remove = [] }) {
let threadMessagesToCreate = allThreadMessages.filter(i1 => !allThreadMessagesRecords.find(i2 => i1._id === i2.id));
let threadMessagesToUpdate = allThreadMessagesRecords.filter(i1 => allThreadMessages.find(i2 => i1.id === i2._id));
// filter loaders to delete
let loadersToDelete = allMessagesRecords.filter(i1 => update.find(i2 => i1.id === generateLoadMoreId(i2._id)));
// Create
msgsToCreate = msgsToCreate.map(message => msgCollection.prepareCreate(protectedFunction((m) => {
m._raw = sanitizedRaw({ id: message._id }, msgCollection.schema);
@ -121,6 +134,12 @@ export default function updateMessages({ rid, update = [], remove = [] }) {
threadMessagesToDelete = threadMessagesToDelete.map(tm => tm.prepareDestroyPermanently());
}
// Delete loaders
loadersToDelete = loadersToDelete.map(m => m.prepareDestroyPermanently());
if (loaderItem) {
loadersToDelete.push(loaderItem.prepareDestroyPermanently());
}
const allRecords = [
...msgsToCreate,
...msgsToUpdate,
@ -130,7 +149,8 @@ export default function updateMessages({ rid, update = [], remove = [] }) {
...threadsToDelete,
...threadMessagesToCreate,
...threadMessagesToUpdate,
...threadMessagesToDelete
...threadMessagesToDelete,
...loadersToDelete
];
try {

View File

@ -1,4 +1,5 @@
import { InteractionManager } from 'react-native';
import EJSON from 'ejson';
import {
Rocketchat as RocketchatClient,
settings as RocketChatSettings
@ -41,6 +42,8 @@ import canOpenRoom from './methods/canOpenRoom';
import triggerBlockAction, { triggerSubmitView, triggerCancel } from './methods/actions';
import loadMessagesForRoom from './methods/loadMessagesForRoom';
import loadSurroundingMessages from './methods/loadSurroundingMessages';
import loadNextMessages from './methods/loadNextMessages';
import loadMissedMessages from './methods/loadMissedMessages';
import loadThreadMessages from './methods/loadThreadMessages';
@ -95,10 +98,19 @@ const RocketChat = {
},
canOpenRoom,
createChannel({
name, users, type, readOnly, broadcast, encrypted
name, users, type, readOnly, broadcast, encrypted, teamId
}) {
// RC 0.51.0
return this.methodCallWrapper(type ? 'createPrivateGroup' : 'createChannel', name, users, readOnly, {}, { broadcast, encrypted });
const params = {
name,
members: users,
readOnly,
extraData: {
broadcast,
encrypted,
...(teamId && { teamId })
}
};
return this.post(type ? 'groups.create' : 'channels.create', params);
},
async getWebsocketInfo({ server }) {
const sdk = new RocketchatClient({ host: server, protocol: 'ddp', useSsl: useSsl(server) });
@ -615,6 +627,8 @@ const RocketChat = {
},
loadMissedMessages,
loadMessagesForRoom,
loadSurroundingMessages,
loadNextMessages,
loadThreadMessages,
sendMessage,
getRooms,
@ -648,7 +662,8 @@ const RocketChat = {
avatarETag: sub.avatarETag,
t: sub.t,
encrypted: sub.encrypted,
lastMessage: sub.lastMessage
lastMessage: sub.lastMessage,
...(sub.teamId && { teamId: sub.teamId })
}));
return data;
@ -751,6 +766,38 @@ const RocketChat = {
// RC 3.13.0
return this.post('teams.create', params);
},
addRoomsToTeam({ teamId, rooms }) {
// RC 3.13.0
return this.post('teams.addRooms', { teamId, rooms });
},
removeTeamRoom({ roomId, teamId }) {
// RC 3.13.0
return this.post('teams.removeRoom', { roomId, teamId });
},
leaveTeam({ teamName, rooms }) {
// RC 3.13.0
return this.post('teams.leave', { teamName, rooms });
},
removeTeamMember({
teamId, teamName, userId, rooms
}) {
// RC 3.13.0
return this.post('teams.removeMember', {
teamId, teamName, userId, rooms
});
},
updateTeamRoom({ roomId, isDefault }) {
// RC 3.13.0
return this.post('teams.updateRoom', { roomId, isDefault });
},
deleteTeam({ teamId, roomsToRemove }) {
// RC 3.13.0
return this.post('teams.delete', { teamId, roomsToRemove });
},
teamListRoomsOfUser({ teamId, userId }) {
// RC 3.13.0
return this.sdk.get('teams.listRoomsOfUser', { teamId, userId });
},
joinRoom(roomId, joinCode, type) {
// TODO: join code
// RC 0.48.0
@ -912,9 +959,15 @@ const RocketChat = {
methodCallWrapper(method, ...params) {
const { API_Use_REST_For_DDP_Calls } = reduxStore.getState().settings;
if (API_Use_REST_For_DDP_Calls) {
return this.post(`method.call/${ method }`, { message: JSON.stringify({ method, params }) });
return this.post(`method.call/${ method }`, { message: EJSON.stringify({ method, params }) });
}
return this.methodCall(method, ...params);
const parsedParams = params.map((param) => {
if (param instanceof Date) {
return { $date: new Date(param).getTime() };
}
return param;
});
return this.methodCall(method, ...parsedParams);
},
getUserRoles() {

View File

@ -20,3 +20,5 @@ export const methods = {
};
export const compareServerVersion = (currentServerVersion, versionToCompare, func) => currentServerVersion && func(coerce(currentServerVersion), versionToCompare);
export const generateLoadMoreId = id => `load-more-${ id }`;

View File

@ -10,7 +10,7 @@ export const onNotification = (notification) => {
if (data) {
try {
const {
rid, name, sender, type, host, messageType
rid, name, sender, type, host, messageType, messageId
} = EJSON.parse(data.ejson);
const types = {
@ -24,6 +24,7 @@ export const onNotification = (notification) => {
const params = {
host,
rid,
messageId,
path: `${ types[type] }/${ roomName }`,
isCall: messageType === 'jitsi_call_started'
};

View File

@ -10,6 +10,8 @@ import LastMessage from './LastMessage';
import Title from './Title';
import UpdatedAt from './UpdatedAt';
import Touchable from './Touchable';
import Tag from './Tag';
import I18n from '../../i18n';
const RoomItem = ({
rid,
@ -42,13 +44,16 @@ const RoomItem = ({
testID,
swipeEnabled,
onPress,
onLongPress,
toggleFav,
toggleRead,
hideChannel,
teamMain
teamMain,
autoJoin
}) => (
<Touchable
onPress={onPress}
onLongPress={onLongPress}
width={width}
favorite={favorite}
toggleFav={toggleFav}
@ -88,6 +93,9 @@ const RoomItem = ({
hideUnreadStatus={hideUnreadStatus}
alert={alert}
/>
{
autoJoin ? <Tag name={I18n.t('Auto-join')} /> : null
}
<UpdatedAt
date={date}
theme={theme}
@ -132,6 +140,9 @@ const RoomItem = ({
hideUnreadStatus={hideUnreadStatus}
alert={alert}
/>
{
autoJoin ? <Tag name={I18n.t('Auto-join')} /> : null
}
<UnreadBadge
unread={unread}
userMentions={userMentions}
@ -181,7 +192,9 @@ RoomItem.propTypes = {
toggleFav: PropTypes.func,
toggleRead: PropTypes.func,
onPress: PropTypes.func,
hideChannel: PropTypes.func
onLongPress: PropTypes.func,
hideChannel: PropTypes.func,
autoJoin: PropTypes.bool
};
RoomItem.defaultProps = {

View File

@ -0,0 +1,30 @@
import React from 'react';
import { Text, View } from 'react-native';
import PropTypes from 'prop-types';
import { themes } from '../../constants/colors';
import { useTheme } from '../../theme';
import styles from './styles';
const Tag = React.memo(({ name }) => {
const { theme } = useTheme();
return (
<View style={[styles.tagContainer, { backgroundColor: themes[theme].borderColor }]}>
<Text
style={[
styles.tagText, { color: themes[theme].infoText }
]}
numberOfLines={1}
>
{name}
</Text>
</View>
);
});
Tag.propTypes = {
name: PropTypes.string
};
export default Tag;

View File

@ -1,7 +1,9 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Animated } from 'react-native';
import { PanGestureHandler, State } from 'react-native-gesture-handler';
import {
LongPressGestureHandler, PanGestureHandler, State
} from 'react-native-gesture-handler';
import Touch from '../../utils/touch';
import {
@ -17,6 +19,7 @@ class Touchable extends React.Component {
static propTypes = {
type: PropTypes.string.isRequired,
onPress: PropTypes.func,
onLongPress: PropTypes.func,
testID: PropTypes.string,
width: PropTypes.number,
favorite: PropTypes.bool,
@ -59,6 +62,12 @@ class Touchable extends React.Component {
}
}
onLongPressHandlerStateChange = ({ nativeEvent }) => {
if (nativeEvent.state === State.ACTIVE) {
this.onLongPress();
}
}
_handleRelease = (nativeEvent) => {
const { translationX } = nativeEvent;
@ -203,13 +212,27 @@ class Touchable extends React.Component {
}
};
onLongPress = () => {
const { rowState } = this.state;
const { onLongPress } = this.props;
if (rowState !== 0) {
this.close();
return;
}
if (onLongPress) {
onLongPress();
}
};
render() {
const {
testID, isRead, width, favorite, children, theme, isFocused, swipeEnabled
} = this.props;
return (
<LongPressGestureHandler onHandlerStateChange={this.onLongPressHandlerStateChange}>
<Animated.View>
<PanGestureHandler
minDeltaX={20}
onGestureEvent={this._onGestureEvent}
@ -251,6 +274,8 @@ class Touchable extends React.Component {
</Animated.View>
</PanGestureHandler>
</Animated.View>
</LongPressGestureHandler>
);
}
}

View File

@ -16,7 +16,8 @@ const attrs = [
'theme',
'isFocused',
'forceUpdate',
'showLastMessage'
'showLastMessage',
'autoJoin'
];
class RoomItemContainer extends React.Component {
@ -25,6 +26,7 @@ class RoomItemContainer extends React.Component {
showLastMessage: PropTypes.bool,
id: PropTypes.string,
onPress: PropTypes.func,
onLongPress: PropTypes.func,
username: PropTypes.string,
avatarSize: PropTypes.number,
width: PropTypes.number,
@ -41,7 +43,8 @@ class RoomItemContainer extends React.Component {
getRoomAvatar: PropTypes.func,
getIsGroupChat: PropTypes.func,
getIsRead: PropTypes.func,
swipeEnabled: PropTypes.bool
swipeEnabled: PropTypes.bool,
autoJoin: PropTypes.bool
};
static defaultProps = {
@ -112,6 +115,11 @@ class RoomItemContainer extends React.Component {
return onPress(item);
}
onLongPress = () => {
const { item, onLongPress } = this.props;
return onLongPress(item);
}
render() {
const {
item,
@ -129,7 +137,8 @@ class RoomItemContainer extends React.Component {
showLastMessage,
username,
useRealName,
swipeEnabled
swipeEnabled,
autoJoin
} = this.props;
const name = getRoomTitle(item);
const testID = `rooms-list-view-item-${ name }`;
@ -160,6 +169,7 @@ class RoomItemContainer extends React.Component {
isGroupChat={this.isGroupChat}
isRead={isRead}
onPress={this.onPress}
onLongPress={this.onLongPress}
date={date}
accessibilityLabel={accessibilityLabel}
width={width}
@ -189,6 +199,7 @@ class RoomItemContainer extends React.Component {
tunreadGroup={item.tunreadGroup}
swipeEnabled={swipeEnabled}
teamMain={item.teamMain}
autoJoin={autoJoin}
/>
);
}

View File

@ -96,5 +96,16 @@ export default StyleSheet.create({
height: '100%',
alignItems: 'center',
justifyContent: 'center'
},
tagContainer: {
alignSelf: 'center',
alignItems: 'center',
borderRadius: 4,
marginHorizontal: 4
},
tagText: {
fontSize: 13,
paddingHorizontal: 4,
...sharedStyles.textSemibold
}
});

View File

@ -40,18 +40,26 @@ const handleRequest = function* handleRequest({ data }) {
broadcast,
encrypted
} = data;
logEvent(events.CR_CREATE, {
logEvent(events.CT_CREATE, {
type,
readOnly,
broadcast,
encrypted
});
sub = yield call(createTeam, data);
const result = yield call(createTeam, data);
sub = {
rid: result?.team?.roomId,
...result.team,
t: result.team.type ? 'p' : 'c'
};
} else if (data.group) {
logEvent(events.SELECTED_USERS_CREATE_GROUP);
const result = yield call(createGroupChat);
if (result.success) {
({ room: sub } = result);
sub = {
rid: result.room?._id,
...result.room
};
}
} else {
const {
@ -66,36 +74,29 @@ const handleRequest = function* handleRequest({ data }) {
broadcast,
encrypted
});
sub = yield call(createChannel, data);
const result = yield call(createChannel, data);
sub = {
rid: result?.channel?._id || result?.group?._id,
...result?.channel,
...result?.group
};
}
try {
const db = database.active;
const subCollection = db.get('subscriptions');
yield db.action(async() => {
await subCollection.create((s) => {
s._raw = sanitizedRaw({ id: sub.team ? sub.team.roomId : sub.rid }, subCollection.schema);
s._raw = sanitizedRaw({ id: sub.rid }, subCollection.schema);
Object.assign(s, sub);
});
});
} catch {
// do nothing
}
let successParams = {};
if (data.isTeam) {
successParams = {
...sub.team,
rid: sub.team.roomId,
t: sub.team.type ? 'p' : 'c'
};
} else {
successParams = data;
}
yield put(createChannelSuccess(successParams));
yield put(createChannelSuccess(sub));
} catch (err) {
logEvent(events[data.group ? 'SELECTED_USERS_CREATE_GROUP_F' : 'CR_CREATE_F']);
yield put(createChannelFailure(err));
yield put(createChannelFailure(err, data.isTeam));
}
};
@ -107,10 +108,10 @@ const handleSuccess = function* handleSuccess({ data }) {
goRoom({ item: data, isMasterDetail });
};
const handleFailure = function handleFailure({ err }) {
const handleFailure = function handleFailure({ err, isTeam }) {
setTimeout(() => {
const msg = err.data ? I18n.t(err.data.error) : err.reason || I18n.t('There_was_an_error_while_action', { action: I18n.t('creating_channel') });
showErrorAlert(msg);
const msg = err.data.errorType ? I18n.t(err.data.errorType, { room_name: err.data.details.channel_name }) : err.reason || I18n.t('There_was_an_error_while_action', { action: isTeam ? I18n.t('creating_team') : I18n.t('creating_channel') });
showErrorAlert(msg, isTeam ? I18n.t('Create_Team') : I18n.t('Create_Channel'));
}, 300);
};

View File

@ -60,18 +60,19 @@ const navigate = function* navigate({ params }) {
const isMasterDetail = yield select(state => state.app.isMasterDetail);
const focusedRooms = yield select(state => state.room.rooms);
const jumpToMessageId = params.messageId;
if (focusedRooms.includes(room.rid)) {
// if there's one room on the list or last room is the one
if (focusedRooms.length === 1 || focusedRooms[0] === room.rid) {
yield goRoom({ item, isMasterDetail });
yield goRoom({ item, isMasterDetail, jumpToMessageId });
} else {
popToRoot({ isMasterDetail });
yield goRoom({ item, isMasterDetail });
yield goRoom({ item, isMasterDetail, jumpToMessageId });
}
} else {
popToRoot({ isMasterDetail });
yield goRoom({ item, isMasterDetail });
yield goRoom({ item, isMasterDetail, jumpToMessageId });
}
if (params.isCall) {

View File

@ -71,6 +71,9 @@ import ShareView from '../views/ShareView';
import CreateDiscussionView from '../views/CreateDiscussionView';
import QueueListView from '../ee/omnichannel/views/QueueListView';
import AddChannelTeamView from '../views/AddChannelTeamView';
import AddExistingChannelView from '../views/AddExistingChannelView';
import SelectListView from '../views/SelectListView';
// ChatsStackNavigator
const ChatsStack = createStackNavigator();
@ -91,6 +94,11 @@ const ChatsStackNavigator = () => {
component={RoomActionsView}
options={RoomActionsView.navigationOptions}
/>
<ChatsStack.Screen
name='SelectListView'
component={SelectListView}
options={SelectListView.navigationOptions}
/>
<ChatsStack.Screen
name='RoomInfoView'
component={RoomInfoView}
@ -174,6 +182,21 @@ const ChatsStackNavigator = () => {
component={TeamChannelsView}
options={TeamChannelsView.navigationOptions}
/>
<ChatsStack.Screen
name='CreateChannelView'
component={CreateChannelView}
options={CreateChannelView.navigationOptions}
/>
<ChatsStack.Screen
name='AddChannelTeamView'
component={AddChannelTeamView}
options={AddChannelTeamView.navigationOptions}
/>
<ChatsStack.Screen
name='AddExistingChannelView'
component={AddExistingChannelView}
options={AddExistingChannelView.navigationOptions}
/>
<ChatsStack.Screen
name='MarkdownTableView'
component={MarkdownTableView}

View File

@ -61,6 +61,9 @@ import { setKeyCommands, deleteKeyCommands } from '../../commands';
import ShareView from '../../views/ShareView';
import QueueListView from '../../ee/omnichannel/views/QueueListView';
import AddChannelTeamView from '../../views/AddChannelTeamView';
import AddExistingChannelView from '../../views/AddExistingChannelView';
import SelectListView from '../../views/SelectListView';
// ChatsStackNavigator
const ChatsStack = createStackNavigator();
@ -117,6 +120,11 @@ const ModalStackNavigator = React.memo(({ navigation }) => {
component={RoomInfoView}
options={RoomInfoView.navigationOptions}
/>
<ModalStack.Screen
name='SelectListView'
component={SelectListView}
options={SelectListView.navigationOptions}
/>
<ModalStack.Screen
name='RoomInfoEditView'
component={RoomInfoEditView}
@ -141,6 +149,16 @@ const ModalStackNavigator = React.memo(({ navigation }) => {
component={InviteUsersView}
options={InviteUsersView.navigationOptions}
/>
<ModalStack.Screen
name='AddChannelTeamView'
component={AddChannelTeamView}
options={AddChannelTeamView.navigationOptions}
/>
<ModalStack.Screen
name='AddExistingChannelView'
component={AddExistingChannelView}
options={AddExistingChannelView.navigationOptions}
/>
<ModalStack.Screen
name='InviteUsersEditView'
component={InviteUsersEditView}

View File

@ -14,7 +14,6 @@ const navigate = ({ item, isMasterDetail, ...props }) => {
t: item.t,
prid: item.prid,
room: item,
search: item.search,
visitor: item.visitor,
roomUserId: RocketChat.getUidDirectMessage(item),
...props

View File

@ -99,14 +99,22 @@ export default {
SELECTED_USERS_CREATE_GROUP: 'selected_users_create_group',
SELECTED_USERS_CREATE_GROUP_F: 'selected_users_create_group_f',
// ADD EXISTING CHANNEL VIEW
EXISTING_CHANNEL_ADD_CHANNEL: 'existing_channel_add_channel',
EXISTING_CHANNEL_REMOVE_CHANNEL: 'existing_channel_remove_channel',
// CREATE CHANNEL VIEW
CR_CREATE: 'cr_create',
CT_CREATE: 'ct_create',
CR_CREATE_F: 'cr_create_f',
CT_CREATE_F: 'ct_create_f',
CR_TOGGLE_TYPE: 'cr_toggle_type',
CR_TOGGLE_READ_ONLY: 'cr_toggle_read_only',
CR_TOGGLE_BROADCAST: 'cr_toggle_broadcast',
CR_TOGGLE_ENCRYPTED: 'cr_toggle_encrypted',
CR_REMOVE_USER: 'cr_remove_user',
CT_ADD_ROOM_TO_TEAM: 'ct_add_room_to_team',
CT_ADD_ROOM_TO_TEAM_F: 'ct_add_room_to_team_f',
// CREATE DISCUSSION VIEW
CD_CREATE: 'cd_create',

View File

@ -0,0 +1,75 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import * as List from '../containers/List';
import StatusBar from '../containers/StatusBar';
import { useTheme } from '../theme';
import * as HeaderButton from '../containers/HeaderButton';
import SafeAreaView from '../containers/SafeAreaView';
import I18n from '../i18n';
const setHeader = (navigation, isMasterDetail) => {
const options = {
headerTitle: I18n.t('Add_Channel_to_Team')
};
if (isMasterDetail) {
options.headerLeft = () => <HeaderButton.CloseModal navigation={navigation} />;
}
navigation.setOptions(options);
};
const AddChannelTeamView = ({
navigation, route, isMasterDetail
}) => {
const { teamId, teamChannels } = route.params;
const { theme } = useTheme();
useEffect(() => {
setHeader(navigation, isMasterDetail);
}, []);
return (
<SafeAreaView testID='add-channel-team-view'>
<StatusBar />
<List.Container>
<List.Separator />
<List.Item
title='Create_New'
onPress={() => (isMasterDetail
? navigation.navigate('SelectedUsersViewCreateChannel', { nextAction: () => navigation.navigate('CreateChannelView', { teamId }) })
: navigation.navigate('SelectedUsersView', { nextAction: () => navigation.navigate('ChatsStackNavigator', { screen: 'CreateChannelView', params: { teamId } }) }))
}
testID='add-channel-team-view-create-channel'
left={() => <List.Icon name='team' />}
right={() => <List.Icon name='chevron-right' />}
theme={theme}
/>
<List.Separator />
<List.Item
title='Add_Existing'
onPress={() => navigation.navigate('AddExistingChannelView', { teamId, teamChannels })}
testID='add-channel-team-view-create-channel'
left={() => <List.Icon name='channel-public' />}
right={() => <List.Icon name='chevron-right' />}
theme={theme}
/>
<List.Separator />
</List.Container>
</SafeAreaView>
);
};
AddChannelTeamView.propTypes = {
route: PropTypes.object,
navigation: PropTypes.object,
isMasterDetail: PropTypes.bool
};
const mapStateToProps = state => ({
isMasterDetail: state.app.isMasterDetail
});
export default connect(mapStateToProps)(AddChannelTeamView);

View File

@ -0,0 +1,215 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
View, FlatList
} from 'react-native';
import { connect } from 'react-redux';
import { Q } from '@nozbe/watermelondb';
import * as List from '../containers/List';
import database from '../lib/database';
import RocketChat from '../lib/rocketchat';
import I18n from '../i18n';
import log, { events, logEvent } from '../utils/log';
import SearchBox from '../containers/SearchBox';
import * as HeaderButton from '../containers/HeaderButton';
import StatusBar from '../containers/StatusBar';
import { themes } from '../constants/colors';
import { withTheme } from '../theme';
import SafeAreaView from '../containers/SafeAreaView';
import Loading from '../containers/Loading';
import { animateNextTransition } from '../utils/layoutAnimation';
import { goRoom } from '../utils/goRoom';
import { showErrorAlert } from '../utils/info';
import debounce from '../utils/debounce';
const QUERY_SIZE = 50;
class AddExistingChannelView extends React.Component {
static propTypes = {
navigation: PropTypes.object,
route: PropTypes.object,
theme: PropTypes.string,
isMasterDetail: PropTypes.bool,
addTeamChannelPermission: PropTypes.array
};
constructor(props) {
super(props);
this.query();
this.teamId = props.route?.params?.teamId;
this.state = {
search: [],
channels: [],
selected: [],
loading: false
};
this.setHeader();
}
setHeader = () => {
const { navigation, isMasterDetail } = this.props;
const { selected } = this.state;
const options = {
headerTitle: I18n.t('Add_Existing_Channel')
};
if (isMasterDetail) {
options.headerLeft = () => <HeaderButton.CloseModal navigation={navigation} />;
}
options.headerRight = () => selected.length > 0 && (
<HeaderButton.Container>
<HeaderButton.Item title={I18n.t('Create')} onPress={this.submit} testID='add-existing-channel-view-submit' />
</HeaderButton.Container>
);
navigation.setOptions(options);
}
query = async(stringToSearch = '') => {
try {
const { addTeamChannelPermission } = this.props;
const db = database.active;
const channels = await db.collections
.get('subscriptions')
.query(
Q.where('team_id', ''),
Q.where('t', Q.oneOf(['c', 'p'])),
Q.where('name', Q.like(`%${ stringToSearch }%`)),
Q.experimentalTake(QUERY_SIZE),
Q.experimentalSortBy('room_updated_at', Q.desc)
)
.fetch();
const asyncFilter = async(channelsArray) => {
const results = await Promise.all(channelsArray.map(async(channel) => {
if (channel.prid) {
return false;
}
const permissions = await RocketChat.hasPermission([addTeamChannelPermission], channel.rid);
if (!permissions[0]) {
return false;
}
return true;
}));
return channelsArray.filter((_v, index) => results[index]);
};
const channelFiltered = await asyncFilter(channels);
this.setState({ channels: channelFiltered });
} catch (e) {
log(e);
}
}
onSearchChangeText = debounce((text) => {
this.query(text);
}, 300)
dismiss = () => {
const { navigation } = this.props;
return navigation.pop();
}
submit = async() => {
const { selected } = this.state;
const { isMasterDetail } = this.props;
this.setState({ loading: true });
try {
logEvent(events.CT_ADD_ROOM_TO_TEAM);
const result = await RocketChat.addRoomsToTeam({ rooms: selected, teamId: this.teamId });
if (result.success) {
this.setState({ loading: false });
goRoom({ item: result, isMasterDetail });
}
} catch (e) {
showErrorAlert(I18n.t(e.data.error), I18n.t('Add_Existing_Channel'), () => {});
logEvent(events.CT_ADD_ROOM_TO_TEAM_F);
this.setState({ loading: false });
}
}
renderHeader = () => {
const { theme } = this.props;
return (
<View style={{ backgroundColor: themes[theme].auxiliaryBackground }}>
<SearchBox onChangeText={text => this.onSearchChangeText(text)} testID='add-existing-channel-view-search' />
</View>
);
}
isChecked = (rid) => {
const { selected } = this.state;
return selected.includes(rid);
}
toggleChannel = (rid) => {
const { selected } = this.state;
animateNextTransition();
if (!this.isChecked(rid)) {
logEvent(events.EXISTING_CHANNEL_ADD_CHANNEL);
this.setState({ selected: [...selected, rid] }, () => this.setHeader());
} else {
logEvent(events.EXISTING_CHANNEL_REMOVE_CHANNEL);
const filterSelected = selected.filter(el => el !== rid);
this.setState({ selected: filterSelected }, () => this.setHeader());
}
}
renderItem = ({ item }) => {
const isChecked = this.isChecked(item.rid);
// TODO: reuse logic inside RoomTypeIcon
const icon = item.t === 'p' && !item.teamId ? 'channel-private' : 'channel-public';
return (
<List.Item
title={RocketChat.getRoomTitle(item)}
translateTitle={false}
onPress={() => this.toggleChannel(item.rid)}
testID='add-existing-channel-view-item'
left={() => <List.Icon name={icon} />}
right={() => (isChecked ? <List.Icon name='check' /> : null)}
/>
);
}
renderList = () => {
const { search, channels } = this.state;
const { theme } = this.props;
return (
<FlatList
data={search.length > 0 ? search : channels}
extraData={this.state}
keyExtractor={item => item._id}
ListHeaderComponent={this.renderHeader}
renderItem={this.renderItem}
ItemSeparatorComponent={List.Separator}
contentContainerStyle={{ backgroundColor: themes[theme].backgroundColor }}
keyboardShouldPersistTaps='always'
/>
);
}
render() {
const { loading } = this.state;
return (
<SafeAreaView testID='add-existing-channel-view'>
<StatusBar />
{this.renderList()}
<Loading visible={loading} />
</SafeAreaView>
);
}
}
const mapStateToProps = state => ({
isMasterDetail: state.app.isMasterDetail,
addTeamChannelPermission: state.permissions['add-team-channel']
});
export default connect(mapStateToProps)(withTheme(AddExistingChannelView));

View File

@ -83,13 +83,15 @@ class CreateChannelView extends React.Component {
id: PropTypes.string,
token: PropTypes.string
}),
theme: PropTypes.string
theme: PropTypes.string,
teamId: PropTypes.string
};
constructor(props) {
super(props);
const { route } = this.props;
const isTeam = route?.params?.isTeam || false;
this.teamId = route?.params?.teamId;
this.state = {
channelName: '',
type: true,
@ -180,7 +182,7 @@ class CreateChannelView extends React.Component {
// create channel or team
create({
name: channelName, users, type, readOnly, broadcast, encrypted, isTeam
name: channelName, users, type, readOnly, broadcast, encrypted, isTeam, teamId: this.teamId
});
Review.pushPositiveEvent();

View File

@ -16,6 +16,7 @@ import { withTheme } from '../../theme';
import { getUserSelector } from '../../selectors/login';
import { withActionSheet } from '../../containers/ActionSheet';
import SafeAreaView from '../../containers/SafeAreaView';
import getThreadName from '../../lib/methods/getThreadName';
class MessagesView extends React.Component {
static propTypes = {
@ -26,7 +27,8 @@ class MessagesView extends React.Component {
customEmojis: PropTypes.object,
theme: PropTypes.string,
showActionSheet: PropTypes.func,
useRealName: PropTypes.bool
useRealName: PropTypes.bool,
isMasterDetail: PropTypes.bool
}
constructor(props) {
@ -81,6 +83,32 @@ class MessagesView extends React.Component {
navigation.navigate('RoomInfoView', navParam);
}
jumpToMessage = async({ item }) => {
const { navigation, isMasterDetail } = this.props;
let params = {
rid: this.rid,
jumpToMessageId: item._id,
t: this.t,
room: this.room
};
if (item.tmid) {
if (isMasterDetail) {
navigation.navigate('DrawerNavigator');
} else {
navigation.pop(2);
}
params = {
...params,
tmid: item.tmid,
name: await getThreadName(this.rid, item.tmid, item._id),
t: 'thread'
};
navigation.push('RoomView', params);
} else {
navigation.navigate('RoomView', params);
}
}
defineMessagesViewContent = (name) => {
const {
user, baseUrl, theme, useRealName
@ -93,11 +121,13 @@ class MessagesView extends React.Component {
timeFormat: 'MMM Do YYYY, h:mm:ss a',
isEdited: !!item.editedAt,
isHeader: true,
isThreadRoom: true,
attachments: item.attachments || [],
useRealName,
showAttachment: this.showAttachment,
getCustomEmoji: this.getCustomEmoji,
navToRoomInfo: this.navToRoomInfo
navToRoomInfo: this.navToRoomInfo,
onPress: () => this.jumpToMessage({ item })
});
return ({
@ -315,7 +345,8 @@ const mapStateToProps = state => ({
baseUrl: state.server.server,
user: getUserSelector(state),
customEmojis: state.customEmojis,
useRealName: state.settings.UI_Use_Real_Name
useRealName: state.settings.UI_Use_Real_Name,
isMasterDetail: state.app.isMasterDetail
});
export default connect(mapStateToProps)(withTheme(withActionSheet(MessagesView)));

View File

@ -60,7 +60,7 @@ class NewMessageView extends React.Component {
id: PropTypes.string,
token: PropTypes.string
}),
createChannel: PropTypes.func,
create: PropTypes.func,
maxUsers: PropTypes.number,
theme: PropTypes.string,
isMasterDetail: PropTypes.bool
@ -124,9 +124,9 @@ class NewMessageView extends React.Component {
createGroupChat = () => {
logEvent(events.NEW_MSG_CREATE_GROUP_CHAT);
const { createChannel, maxUsers, navigation } = this.props;
const { create, maxUsers, navigation } = this.props;
navigation.navigate('SelectedUsersViewCreateChannel', {
nextAction: () => createChannel({ group: true }),
nextAction: () => create({ group: true }),
buttonText: I18n.t('Create'),
maxUsers
});

View File

@ -30,7 +30,7 @@ class ReadReceiptView extends React.Component {
static propTypes = {
route: PropTypes.object,
Message_TimeFormat: PropTypes.string,
Message_TimeAndDateFormat: PropTypes.string,
theme: PropTypes.string
}
@ -94,8 +94,8 @@ class ReadReceiptView extends React.Component {
}
renderItem = ({ item }) => {
const { Message_TimeFormat, theme } = this.props;
const time = moment(item.ts).format(Message_TimeFormat);
const { theme, Message_TimeAndDateFormat } = this.props;
const time = moment(item.ts).format(Message_TimeAndDateFormat);
if (!item?.user?.username) {
return null;
}
@ -156,7 +156,7 @@ class ReadReceiptView extends React.Component {
}
const mapStateToProps = state => ({
Message_TimeFormat: state.settings.Message_TimeFormat
Message_TimeAndDateFormat: state.settings.Message_TimeAndDateFormat
});
export default connect(mapStateToProps)(withTheme(ReadReceiptView));

View File

@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
View, Text, Alert, Share, Switch
View, Text, Share, Switch
} from 'react-native';
import { connect } from 'react-redux';
import isEmpty from 'lodash/isEmpty';
@ -53,6 +53,7 @@ class RoomActionsView extends React.Component {
theme: PropTypes.string,
fontScale: PropTypes.number,
serverVersion: PropTypes.string,
isMasterDetail: PropTypes.bool,
addUserToJoinedRoomPermission: PropTypes.array,
addUserToAnyCRoomPermission: PropTypes.array,
addUserToAnyPRoomPermission: PropTypes.array,
@ -395,22 +396,73 @@ class RoomActionsView extends React.Component {
const { room } = this.state;
const { leaveRoom } = this.props;
Alert.alert(
I18n.t('Are_you_sure_question_mark'),
I18n.t('Are_you_sure_you_want_to_leave_the_room', { room: RocketChat.getRoomTitle(room) }),
[
{
text: I18n.t('Cancel'),
style: 'cancel'
},
{
text: I18n.t('Yes_action_it', { action: I18n.t('leave') }),
style: 'destructive',
showConfirmationAlert({
message: I18n.t('Are_you_sure_you_want_to_leave_the_room', { room: RocketChat.getRoomTitle(room) }),
confirmationText: I18n.t('Yes_action_it', { action: I18n.t('leave') }),
onPress: () => leaveRoom(room.rid, room.t)
});
}
]
handleLeaveTeam = async(selected) => {
try {
const { room } = this.state;
const { navigation, isMasterDetail } = this.props;
const result = await RocketChat.leaveTeam({ teamName: room.name, ...(selected && { rooms: selected }) });
if (result.success) {
if (isMasterDetail) {
navigation.navigate('DrawerNavigator');
} else {
navigation.navigate('RoomsListView');
}
}
} catch (e) {
log(e);
showErrorAlert(
e.data.error
? I18n.t(e.data.error)
: I18n.t('There_was_an_error_while_action', { action: I18n.t('leaving_team') }),
I18n.t('Cannot_leave')
);
}
}
leaveTeam = async() => {
const { room } = this.state;
const { navigation } = this.props;
try {
const result = await RocketChat.teamListRoomsOfUser({ teamId: room.teamId, userId: room.u._id });
if (result.rooms?.length) {
const teamChannels = result.rooms.map(r => ({
rid: r._id,
name: r.name,
teamId: r.teamId,
alert: r.isLastOwner
}));
navigation.navigate('SelectListView', {
title: 'Leave_Team',
data: teamChannels,
infoText: 'Select_Team_Channels',
nextAction: data => this.handleLeaveTeam(data),
showAlert: () => showErrorAlert(I18n.t('Last_owner_team_room'), I18n.t('Cannot_leave'))
});
} else {
showConfirmationAlert({
message: I18n.t('You_are_leaving_the_team', { team: RocketChat.getRoomTitle(room) }),
confirmationText: I18n.t('Yes_action_it', { action: I18n.t('leave') }),
onPress: () => this.handleLeaveTeam()
});
}
} catch (e) {
showConfirmationAlert({
message: I18n.t('You_are_leaving_the_team', { team: RocketChat.getRoomTitle(room) }),
confirmationText: I18n.t('Yes_action_it', { action: I18n.t('leave') }),
onPress: () => this.handleLeaveTeam()
});
}
}
renderRoomInfo = () => {
const { room, member } = this.state;
@ -568,9 +620,9 @@ class RoomActionsView extends React.Component {
<List.Section>
<List.Separator />
<List.Item
title='Leave_channel'
title='Leave'
onPress={() => this.onPressTouchable({
event: this.leaveChannel
event: room.teamMain ? this.leaveTeam : this.leaveChannel
})}
testID='room-actions-leave-channel'
left={() => <List.Icon name='logout' color={themes[theme].dangerColor} />}
@ -588,7 +640,7 @@ class RoomActionsView extends React.Component {
room, membersCount, canViewMembers, canAddUser, canInviteUser, joined, canAutoTranslate, canForwardGuest, canReturnQueue
} = this.state;
const {
rid, t, encrypted
rid, t
} = room;
const isGroupChat = RocketChat.isGroupChat(room);
@ -713,24 +765,6 @@ class RoomActionsView extends React.Component {
)
: null}
{['c', 'p', 'd'].includes(t)
? (
<>
<List.Item
title='Search'
onPress={() => this.onPressTouchable({
route: 'SearchMessagesView',
params: { rid, encrypted }
})}
testID='room-actions-search'
left={() => <List.Icon name='search' />}
showActionIndicator
/>
<List.Separator />
</>
)
: null}
{['c', 'p', 'd'].includes(t)
? (
<>
@ -880,6 +914,7 @@ const mapStateToProps = state => ({
jitsiEnabled: state.settings.Jitsi_Enabled || false,
encryptionEnabled: state.encryption.enabled,
serverVersion: state.server.version,
isMasterDetail: state.app.isMasterDetail,
addUserToJoinedRoomPermission: state.permissions['add-user-to-joined-room'],
addUserToAnyCRoomPermission: state.permissions['add-user-to-any-c-room'],
addUserToAnyPRoomPermission: state.permissions['add-user-to-any-p-room'],

View File

@ -8,15 +8,16 @@ import { BLOCK_CONTEXT } from '@rocket.chat/ui-kit';
import ImagePicker from 'react-native-image-crop-picker';
import { dequal } from 'dequal';
import isEmpty from 'lodash/isEmpty';
import { compareServerVersion, methods } from '../../lib/utils';
import { Q } from '@nozbe/watermelondb';
import { compareServerVersion, methods } from '../../lib/utils';
import database from '../../lib/database';
import { deleteRoom as deleteRoomAction } from '../../actions/room';
import KeyboardView from '../../presentation/KeyboardView';
import sharedStyles from '../Styles';
import styles from './styles';
import scrollPersistTaps from '../../utils/scrollPersistTaps';
import { showErrorAlert } from '../../utils/info';
import { showConfirmationAlert, showErrorAlert } from '../../utils/info';
import { LISTENER } from '../../containers/Toast';
import EventEmitter from '../../utils/events';
import RocketChat from '../../lib/rocketchat';
@ -41,6 +42,7 @@ const PERMISSION_ARCHIVE = 'archive-room';
const PERMISSION_UNARCHIVE = 'unarchive-room';
const PERMISSION_DELETE_C = 'delete-c';
const PERMISSION_DELETE_P = 'delete-p';
const PERMISSION_DELETE_TEAM = 'delete-team';
class RoomInfoEditView extends React.Component {
static navigationOptions = () => ({
@ -48,6 +50,7 @@ class RoomInfoEditView extends React.Component {
})
static propTypes = {
navigation: PropTypes.object,
route: PropTypes.object,
deleteRoom: PropTypes.func,
serverVersion: PropTypes.string,
@ -58,7 +61,9 @@ class RoomInfoEditView extends React.Component {
archiveRoomPermission: PropTypes.array,
unarchiveRoomPermission: PropTypes.array,
deleteCPermission: PropTypes.array,
deletePPermission: PropTypes.array
deletePPermission: PropTypes.array,
deleteTeamPermission: PropTypes.array,
isMasterDetail: PropTypes.bool
};
constructor(props) {
@ -100,7 +105,8 @@ class RoomInfoEditView extends React.Component {
archiveRoomPermission,
unarchiveRoomPermission,
deleteCPermission,
deletePPermission
deletePPermission,
deleteTeamPermission
} = this.props;
const rid = route.params?.rid;
if (!rid) {
@ -122,7 +128,8 @@ class RoomInfoEditView extends React.Component {
archiveRoomPermission,
unarchiveRoomPermission,
deleteCPermission,
deletePPermission
deletePPermission,
...(this.room.teamMain ? [deleteTeamPermission] : [])
], rid);
this.setState({
@ -132,7 +139,8 @@ class RoomInfoEditView extends React.Component {
[PERMISSION_ARCHIVE]: result[2],
[PERMISSION_UNARCHIVE]: result[3],
[PERMISSION_DELETE_C]: result[4],
[PERMISSION_DELETE_P]: result[5]
[PERMISSION_DELETE_P]: result[5],
...(this.room.teamMain && { [PERMISSION_DELETE_TEAM]: result[6] })
}
});
} catch (e) {
@ -284,6 +292,72 @@ class RoomInfoEditView extends React.Component {
}, 100);
}
handleDeleteTeam = async(selected) => {
const { navigation, isMasterDetail } = this.props;
const { room } = this.state;
try {
const result = await RocketChat.deleteTeam({ teamId: room.teamId, ...(selected && { roomsToRemove: selected }) });
if (result.success) {
if (isMasterDetail) {
navigation.navigate('DrawerNavigator');
} else {
navigation.navigate('RoomsListView');
}
}
} catch (e) {
log(e);
showErrorAlert(
e.data.error
? I18n.t(e.data.error)
: I18n.t('There_was_an_error_while_action', { action: I18n.t('deleting_team') }),
I18n.t('Cannot_delete')
);
}
}
deleteTeam = async() => {
const { room } = this.state;
const { navigation } = this.props;
try {
const db = database.active;
const subCollection = db.get('subscriptions');
const teamChannels = await subCollection.query(
Q.where('team_id', room.teamId),
Q.where('team_main', null)
);
if (teamChannels.length) {
navigation.navigate('SelectListView', {
title: 'Delete_Team',
data: teamChannels,
infoText: 'Select_channels_to_delete',
nextAction: (selected) => {
showConfirmationAlert({
message: I18n.t('You_are_deleting_the_team', { team: RocketChat.getRoomTitle(room) }),
confirmationText: I18n.t('Yes_action_it', { action: I18n.t('delete') }),
onPress: () => this.handleDeleteTeam(selected)
});
}
});
} else {
showConfirmationAlert({
message: I18n.t('You_are_deleting_the_team', { team: RocketChat.getRoomTitle(room) }),
confirmationText: I18n.t('Yes_action_it', { action: I18n.t('delete') }),
onPress: () => this.handleDeleteTeam()
});
}
} catch (e) {
log(e);
showErrorAlert(
e.data.error
? I18n.t(e.data.error)
: I18n.t('There_was_an_error_while_action', { action: I18n.t('deleting_team') }),
I18n.t('Cannot_delete')
);
}
}
delete = () => {
const { room } = this.state;
const { deleteRoom } = this.props;
@ -339,9 +413,16 @@ class RoomInfoEditView extends React.Component {
hasDeletePermission = () => {
const { room, permissions } = this.state;
return (
room.t === 'p' ? permissions[PERMISSION_DELETE_P] : permissions[PERMISSION_DELETE_C]
);
if (room.teamMain) {
return permissions[PERMISSION_DELETE_TEAM];
}
if (room.t === 'p') {
return permissions[PERMISSION_DELETE_P];
}
return permissions[PERMISSION_DELETE_C];
}
hasArchivePermission = () => {
@ -513,9 +594,9 @@ class RoomInfoEditView extends React.Component {
<SwitchContainer
value={t}
leftLabelPrimary={I18n.t('Public')}
leftLabelSecondary={I18n.t('Everyone_can_access_this_channel')}
leftLabelSecondary={room.teamMain ? I18n.t('Everyone_can_access_this_team') : I18n.t('Everyone_can_access_this_channel')}
rightLabelPrimary={I18n.t('Private')}
rightLabelSecondary={I18n.t('Just_invited_people_can_access_this_channel')}
rightLabelSecondary={room.teamMain ? I18n.t('Just_invited_people_can_access_this_team') : I18n.t('Just_invited_people_can_access_this_channel')}
onValueChange={this.toggleRoomType}
theme={theme}
testID='room-info-edit-view-t'
@ -523,7 +604,7 @@ class RoomInfoEditView extends React.Component {
<SwitchContainer
value={ro}
leftLabelPrimary={I18n.t('Collaborative')}
leftLabelSecondary={I18n.t('All_users_in_the_channel_can_write_new_messages')}
leftLabelSecondary={room.teamMain ? I18n.t('All_users_in_the_team_can_write_new_messages') : I18n.t('All_users_in_the_channel_can_write_new_messages')}
rightLabelPrimary={I18n.t('Read_Only')}
rightLabelSecondary={I18n.t('Only_authorized_users_can_write_new_messages')}
onValueChange={this.toggleReadOnly}
@ -647,7 +728,7 @@ class RoomInfoEditView extends React.Component {
{ borderColor: dangerColor },
!this.hasDeletePermission() && sharedStyles.opacity5
]}
onPress={this.delete}
onPress={room.teamMain ? this.deleteTeam : this.delete}
disabled={!this.hasDeletePermission()}
testID='room-info-edit-view-delete'
>
@ -678,7 +759,9 @@ const mapStateToProps = state => ({
archiveRoomPermission: state.permissions[PERMISSION_ARCHIVE],
unarchiveRoomPermission: state.permissions[PERMISSION_UNARCHIVE],
deleteCPermission: state.permissions[PERMISSION_DELETE_C],
deletePPermission: state.permissions[PERMISSION_DELETE_P]
deletePPermission: state.permissions[PERMISSION_DELETE_P],
deleteTeamPermission: state.permissions[PERMISSION_DELETE_TEAM],
isMasterDetail: state.app.isMasterDetail
});
const mapDispatchToProps = dispatch => ({

View File

@ -214,7 +214,7 @@ class RoomInfoView extends React.Component {
}
const permissions = await RocketChat.hasPermission([editRoomPermission], room.rid);
if (permissions[0] && !room.prid) {
if (permissions[0]) {
this.setState({ showEdit: true }, () => this.setHeader());
}
}

View File

@ -23,9 +23,10 @@ import { withTheme } from '../../theme';
import { themes } from '../../constants/colors';
import { getUserSelector } from '../../selectors/login';
import { withActionSheet } from '../../containers/ActionSheet';
import { showConfirmationAlert } from '../../utils/info';
import { showConfirmationAlert, showErrorAlert } from '../../utils/info';
import SafeAreaView from '../../containers/SafeAreaView';
import { goRoom } from '../../utils/goRoom';
import { CustomIcon } from '../../lib/Icons';
const PAGE_SIZE = 25;
@ -34,6 +35,9 @@ const PERMISSION_SET_LEADER = 'set-leader';
const PERMISSION_SET_OWNER = 'set-owner';
const PERMISSION_SET_MODERATOR = 'set-moderator';
const PERMISSION_REMOVE_USER = 'remove-user';
const PERMISSION_EDIT_TEAM_MEMBER = 'edit-team-member';
const PERMISION_VIEW_ALL_TEAMS = 'view-all-teams';
const PERMISSION_VIEW_ALL_TEAM_CHANNELS = 'view-all-team-channels';
class RoomMembersView extends React.Component {
static propTypes = {
@ -55,7 +59,10 @@ class RoomMembersView extends React.Component {
setLeaderPermission: PropTypes.array,
setOwnerPermission: PropTypes.array,
setModeratorPermission: PropTypes.array,
removeUserPermission: PropTypes.array
removeUserPermission: PropTypes.array,
editTeamMemberPermission: PropTypes.array,
viewAllTeamChannelsPermission: PropTypes.array,
viewAllTeamsPermission: PropTypes.array
}
constructor(props) {
@ -94,10 +101,11 @@ class RoomMembersView extends React.Component {
const { room } = this.state;
const {
muteUserPermission, setLeaderPermission, setOwnerPermission, setModeratorPermission, removeUserPermission
muteUserPermission, setLeaderPermission, setOwnerPermission, setModeratorPermission, removeUserPermission, editTeamMemberPermission, viewAllTeamChannelsPermission, viewAllTeamsPermission
} = this.props;
const result = await RocketChat.hasPermission([
muteUserPermission, setLeaderPermission, setOwnerPermission, setModeratorPermission, removeUserPermission
muteUserPermission, setLeaderPermission, setOwnerPermission, setModeratorPermission, removeUserPermission, ...(room.teamMain ? [editTeamMemberPermission, viewAllTeamChannelsPermission, viewAllTeamsPermission] : [])
], room.rid);
this.permissions = {
@ -105,7 +113,12 @@ class RoomMembersView extends React.Component {
[PERMISSION_SET_LEADER]: result[1],
[PERMISSION_SET_OWNER]: result[2],
[PERMISSION_SET_MODERATOR]: result[3],
[PERMISSION_REMOVE_USER]: result[4]
[PERMISSION_REMOVE_USER]: result[4],
...(room.teamMain ? {
[PERMISSION_EDIT_TEAM_MEMBER]: result[5],
[PERMISSION_VIEW_ALL_TEAM_CHANNELS]: result[6],
[PERMISION_VIEW_ALL_TEAMS]: result[7]
} : {})
};
const hasSinglePermission = Object.values(this.permissions).some(p => !!p);
@ -137,6 +150,7 @@ class RoomMembersView extends React.Component {
onSearchChangeText = protectedFunction((text) => {
const { members } = this.state;
let membersFiltered = [];
text = text.trim();
if (members && members.length > 0 && text) {
membersFiltered = members.filter(m => m.username.toLowerCase().match(text.toLowerCase()) || m.name.toLowerCase().match(text.toLowerCase()));
@ -163,9 +177,80 @@ class RoomMembersView extends React.Component {
}
}
handleRemoveFromTeam = async(selectedUser) => {
try {
const { navigation } = this.props;
const { room } = this.state;
const result = await RocketChat.teamListRoomsOfUser({ teamId: room.teamId, userId: selectedUser._id });
if (result.rooms?.length) {
const teamChannels = result.rooms.map(r => ({
rid: r._id,
name: r.name,
teamId: r.teamId,
alert: r.isLastOwner
}));
navigation.navigate('SelectListView', {
title: 'Remove_Member',
infoText: 'Remove_User_Team_Channels',
data: teamChannels,
nextAction: selected => this.removeFromTeam(selectedUser, selected),
showAlert: () => showErrorAlert(I18n.t('Last_owner_team_room'), I18n.t('Cannot_remove'))
});
} else {
showConfirmationAlert({
message: I18n.t('Removing_user_from_this_team', { user: selectedUser.username }),
confirmationText: I18n.t('Yes_action_it', { action: I18n.t('remove') }),
onPress: () => this.removeFromTeam(selectedUser)
});
}
} catch (e) {
showConfirmationAlert({
message: I18n.t('Removing_user_from_this_team', { user: selectedUser.username }),
confirmationText: I18n.t('Yes_action_it', { action: I18n.t('remove') }),
onPress: () => this.removeFromTeam(selectedUser)
});
}
}
removeFromTeam = async(selectedUser, selected) => {
try {
const { members, membersFiltered, room } = this.state;
const { navigation } = this.props;
const userId = selectedUser._id;
const result = await RocketChat.removeTeamMember({
teamId: room.teamId,
teamName: room.name,
userId,
...(selected && { rooms: selected })
});
if (result.success) {
const message = I18n.t('User_has_been_removed_from_s', { s: RocketChat.getRoomTitle(room) });
EventEmitter.emit(LISTENER, { message });
const newMembers = members.filter(member => member._id !== userId);
const newMembersFiltered = membersFiltered.filter(member => member._id !== userId);
this.setState({
members: newMembers,
membersFiltered: newMembersFiltered
});
navigation.navigate('RoomMembersView');
}
} catch (e) {
log(e);
showErrorAlert(
e.data.error
? I18n.t(e.data.error)
: I18n.t('There_was_an_error_while_action', { action: I18n.t('removing_team') }),
I18n.t('Cannot_remove')
);
}
}
onPressUser = (selectedUser) => {
const { room } = this.state;
const { showActionSheet, user } = this.props;
const { showActionSheet, user, theme } = this.props;
const options = [{
icon: 'message',
@ -173,39 +258,6 @@ class RoomMembersView extends React.Component {
onPress: () => this.navToDirectMessage(selectedUser)
}];
// Owner
if (this.permissions['set-owner']) {
const userRoleResult = this.roomRoles.find(r => r.u._id === selectedUser._id);
const isOwner = userRoleResult?.roles.includes('owner');
options.push({
icon: 'shield-check',
title: I18n.t(isOwner ? 'Remove_as_owner' : 'Set_as_owner'),
onPress: () => this.handleOwner(selectedUser, !isOwner)
});
}
// Leader
if (this.permissions['set-leader']) {
const userRoleResult = this.roomRoles.find(r => r.u._id === selectedUser._id);
const isLeader = userRoleResult?.roles.includes('leader');
options.push({
icon: 'shield-alt',
title: I18n.t(isLeader ? 'Remove_as_leader' : 'Set_as_leader'),
onPress: () => this.handleLeader(selectedUser, !isLeader)
});
}
// Moderator
if (this.permissions['set-moderator']) {
const userRoleResult = this.roomRoles.find(r => r.u._id === selectedUser._id);
const isModerator = userRoleResult?.roles.includes('moderator');
options.push({
icon: 'shield',
title: I18n.t(isModerator ? 'Remove_as_moderator' : 'Set_as_moderator'),
onPress: () => this.handleModerator(selectedUser, !isModerator)
});
}
// Ignore
if (selectedUser._id !== user.id) {
const { ignored } = room;
@ -236,8 +288,54 @@ class RoomMembersView extends React.Component {
});
}
// Owner
if (this.permissions['set-owner']) {
const userRoleResult = this.roomRoles.find(r => r.u._id === selectedUser._id);
const isOwner = userRoleResult?.roles.includes('owner');
options.push({
icon: 'shield-check',
title: I18n.t('Owner'),
onPress: () => this.handleOwner(selectedUser, !isOwner),
right: () => <CustomIcon name={isOwner ? 'checkbox-checked' : 'checkbox-unchecked'} size={20} color={isOwner ? themes[theme].tintActive : themes[theme].auxiliaryTintColor} />
});
}
// Leader
if (this.permissions['set-leader']) {
const userRoleResult = this.roomRoles.find(r => r.u._id === selectedUser._id);
const isLeader = userRoleResult?.roles.includes('leader');
options.push({
icon: 'shield-alt',
title: I18n.t('Leader'),
onPress: () => this.handleLeader(selectedUser, !isLeader),
right: () => <CustomIcon name={isLeader ? 'checkbox-checked' : 'checkbox-unchecked'} size={20} color={isLeader ? themes[theme].tintActive : themes[theme].auxiliaryTintColor} />
});
}
// Moderator
if (this.permissions['set-moderator']) {
const userRoleResult = this.roomRoles.find(r => r.u._id === selectedUser._id);
const isModerator = userRoleResult?.roles.includes('moderator');
options.push({
icon: 'shield',
title: I18n.t('Moderator'),
onPress: () => this.handleModerator(selectedUser, !isModerator),
right: () => <CustomIcon name={isModerator ? 'checkbox-checked' : 'checkbox-unchecked'} size={20} color={isModerator ? themes[theme].tintActive : themes[theme].auxiliaryTintColor} />
});
}
// Remove from team
if (this.permissions['edit-team-member']) {
options.push({
icon: 'logout',
danger: true,
title: I18n.t('Remove_from_Team'),
onPress: () => this.handleRemoveFromTeam(selectedUser)
});
}
// Remove from room
if (this.permissions['remove-user']) {
if (this.permissions['remove-user'] && !room.teamMain) {
options.push({
icon: 'logout',
title: I18n.t('Remove_from_room'),
@ -477,7 +575,10 @@ const mapStateToProps = state => ({
setLeaderPermission: state.permissions[PERMISSION_SET_LEADER],
setOwnerPermission: state.permissions[PERMISSION_SET_OWNER],
setModeratorPermission: state.permissions[PERMISSION_SET_MODERATOR],
removeUserPermission: state.permissions[PERMISSION_REMOVE_USER]
removeUserPermission: state.permissions[PERMISSION_REMOVE_USER],
editTeamMemberPermission: state.permissions[PERMISSION_EDIT_TEAM_MEMBER],
viewAllTeamChannelsPermission: state.permissions[PERMISSION_VIEW_ALL_TEAM_CHANNELS],
viewAllTeamsPermission: state.permissions[PERMISION_VIEW_ALL_TEAMS]
});
export default connect(mapStateToProps)(withActionSheet(withTheme(RoomMembersView)));

View File

@ -0,0 +1,42 @@
import React from 'react';
import { FlatList, StyleSheet } from 'react-native';
import Animated from 'react-native-reanimated';
import PropTypes from 'prop-types';
import { isIOS } from '../../../utils/deviceInfo';
import scrollPersistTaps from '../../../utils/scrollPersistTaps';
const AnimatedFlatList = Animated.createAnimatedComponent(FlatList);
const styles = StyleSheet.create({
list: {
flex: 1
},
contentContainer: {
paddingTop: 10
}
});
const List = ({ listRef, ...props }) => (
<AnimatedFlatList
testID='room-view-messages'
ref={listRef}
keyExtractor={item => item.id}
contentContainerStyle={styles.contentContainer}
style={styles.list}
inverted
removeClippedSubviews={isIOS}
initialNumToRender={7}
onEndReachedThreshold={0.5}
maxToRenderPerBatch={5}
windowSize={10}
{...props}
{...scrollPersistTaps}
/>
);
List.propTypes = {
listRef: PropTypes.object
};
export default List;

View File

@ -0,0 +1,75 @@
import React, { useCallback, useState } from 'react';
import { View, StyleSheet } from 'react-native';
import PropTypes from 'prop-types';
import Animated, {
call, cond, greaterOrEq, useCode
} from 'react-native-reanimated';
import { themes } from '../../../constants/colors';
import { CustomIcon } from '../../../lib/Icons';
import { useTheme } from '../../../theme';
import Touch from '../../../utils/touch';
import { hasNotch } from '../../../utils/deviceInfo';
const SCROLL_LIMIT = 200;
const SEND_TO_CHANNEL_HEIGHT = 40;
const styles = StyleSheet.create({
container: {
position: 'absolute',
right: 15
},
button: {
borderRadius: 25
},
content: {
width: 50,
height: 50,
borderRadius: 25,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center'
}
});
const NavBottomFAB = ({ y, onPress, isThread }) => {
const { theme } = useTheme();
const [show, setShow] = useState(false);
const handleOnPress = useCallback(() => onPress());
const toggle = v => setShow(v);
useCode(() => cond(greaterOrEq(y, SCROLL_LIMIT),
call([y], () => toggle(true)),
call([y], () => toggle(false))),
[y]);
if (!show) {
return null;
}
let bottom = hasNotch ? 100 : 60;
if (isThread) {
bottom += SEND_TO_CHANNEL_HEIGHT;
}
return (
<Animated.View style={[styles.container, { bottom }]}>
<Touch
onPress={handleOnPress}
theme={theme}
style={[styles.button, { backgroundColor: themes[theme].backgroundColor }]}
>
<View style={[styles.content, { borderColor: themes[theme].borderColor }]}>
<CustomIcon name='chevron-down' color={themes[theme].auxiliaryTintColor} size={36} />
</View>
</Touch>
</Animated.View>
);
};
NavBottomFAB.propTypes = {
y: Animated.Value,
onPress: PropTypes.func,
isThread: PropTypes.bool
};
export default NavBottomFAB;

View File

@ -1,30 +1,39 @@
import React from 'react';
import { FlatList, RefreshControl } from 'react-native';
import { RefreshControl } from 'react-native';
import PropTypes from 'prop-types';
import { Q } from '@nozbe/watermelondb';
import moment from 'moment';
import { dequal } from 'dequal';
import { Value, event } from 'react-native-reanimated';
import styles from './styles';
import database from '../../lib/database';
import scrollPersistTaps from '../../utils/scrollPersistTaps';
import RocketChat from '../../lib/rocketchat';
import log from '../../utils/log';
import EmptyRoom from './EmptyRoom';
import { isIOS } from '../../utils/deviceInfo';
import { animateNextTransition } from '../../utils/layoutAnimation';
import ActivityIndicator from '../../containers/ActivityIndicator';
import { themes } from '../../constants/colors';
import database from '../../../lib/database';
import RocketChat from '../../../lib/rocketchat';
import log from '../../../utils/log';
import EmptyRoom from '../EmptyRoom';
import { animateNextTransition } from '../../../utils/layoutAnimation';
import ActivityIndicator from '../../../containers/ActivityIndicator';
import { themes } from '../../../constants/colors';
import List from './List';
import NavBottomFAB from './NavBottomFAB';
import debounce from '../../../utils/debounce';
const QUERY_SIZE = 50;
class List extends React.Component {
const onScroll = ({ y }) => event(
[
{
nativeEvent: {
contentOffset: { y }
}
}
],
{ useNativeDriver: true }
);
class ListContainer extends React.Component {
static propTypes = {
onEndReached: PropTypes.func,
renderFooter: PropTypes.func,
renderRow: PropTypes.func,
rid: PropTypes.string,
t: PropTypes.string,
tmid: PropTypes.string,
theme: PropTypes.string,
loading: PropTypes.bool,
@ -36,34 +45,28 @@ class List extends React.Component {
showMessageInMainThread: PropTypes.bool
};
// this.state.loading works for this.onEndReached and RoomView.init
static getDerivedStateFromProps(props, state) {
if (props.loading !== state.loading) {
return {
loading: props.loading
};
}
return null;
}
constructor(props) {
super(props);
console.time(`${ this.constructor.name } init`);
console.time(`${ this.constructor.name } mount`);
this.count = 0;
this.needsFetch = false;
this.mounted = false;
this.animated = false;
this.jumping = false;
this.state = {
loading: true,
end: false,
messages: [],
refreshing: false
refreshing: false,
highlightedMessage: null
};
this.y = new Value(0);
this.onScroll = onScroll({ y: this.y });
this.query();
this.unsubscribeFocus = props.navigation.addListener('focus', () => {
this.animated = true;
});
this.viewabilityConfig = {
itemVisiblePercentThreshold: 10
};
console.timeEnd(`${ this.constructor.name } init`);
}
@ -73,17 +76,17 @@ class List extends React.Component {
}
shouldComponentUpdate(nextProps, nextState) {
const { loading, end, refreshing } = this.state;
const { refreshing, highlightedMessage } = this.state;
const {
hideSystemMessages, theme, tunread, ignored
hideSystemMessages, theme, tunread, ignored, loading
} = this.props;
if (theme !== nextProps.theme) {
return true;
}
if (loading !== nextState.loading) {
if (loading !== nextProps.loading) {
return true;
}
if (end !== nextState.end) {
if (highlightedMessage !== nextState.highlightedMessage) {
return true;
}
if (refreshing !== nextState.refreshing) {
@ -116,32 +119,14 @@ class List extends React.Component {
if (this.unsubscribeFocus) {
this.unsubscribeFocus();
}
this.clearHighlightedMessageTimeout();
console.countReset(`${ this.constructor.name }.render calls`);
}
fetchData = async() => {
const {
loading, end, messages, latest = messages[messages.length - 1]?.ts
} = this.state;
if (loading || end) {
return;
}
this.setState({ loading: true });
const { rid, t, tmid } = this.props;
try {
let result;
if (tmid) {
// `offset` is `messages.length - 1` because we append thread start to `messages` obj
result = await RocketChat.loadThreadMessages({ tmid, rid, offset: messages.length - 1 });
} else {
result = await RocketChat.loadMessagesForRoom({ rid, t, latest });
}
this.setState({ end: result.length < QUERY_SIZE, loading: false, latest: result[result.length - 1]?.ts }, () => this.loadMoreMessages(result));
} catch (e) {
this.setState({ loading: false });
log(e);
clearHighlightedMessageTimeout = () => {
if (this.highlightedMessageTimeout) {
clearTimeout(this.highlightedMessageTimeout);
this.highlightedMessageTimeout = false;
}
}
@ -198,9 +183,6 @@ class List extends React.Component {
this.unsubscribeMessages();
this.messagesSubscription = this.messagesObservable
.subscribe((messages) => {
if (messages.length <= this.count) {
this.needsFetch = true;
}
if (tmid && this.thread) {
messages = [...messages, this.thread];
}
@ -211,6 +193,7 @@ class List extends React.Component {
} else {
this.state.messages = messages;
}
// TODO: move it away from here
this.readThreads();
});
}
@ -221,7 +204,7 @@ class List extends React.Component {
this.query();
}
readThreads = async() => {
readThreads = debounce(async() => {
const { tmid } = this.props;
if (tmid) {
@ -231,39 +214,9 @@ class List extends React.Component {
// Do nothing
}
}
}
}, 300)
onEndReached = async() => {
if (this.needsFetch) {
this.needsFetch = false;
await this.fetchData();
}
this.query();
}
loadMoreMessages = (result) => {
const { end } = this.state;
if (end) {
return;
}
// handle servers with version < 3.0.0
let { hideSystemMessages = [] } = this.props;
if (!Array.isArray(hideSystemMessages)) {
hideSystemMessages = [];
}
if (!hideSystemMessages.length) {
return;
}
const hasReadableMessages = result.filter(message => !message.t || (message.t && !hideSystemMessages.includes(message.t))).length > 0;
// if this batch doesn't contain any messages that will be displayed, we'll request a new batch
if (!hasReadableMessages) {
this.onEndReached();
}
}
onEndReached = () => this.query()
onRefresh = () => this.setState({ refreshing: true }, async() => {
const { messages } = this.state;
@ -272,7 +225,7 @@ class List extends React.Component {
if (messages.length) {
try {
if (tmid) {
await RocketChat.loadThreadMessages({ tmid, rid, offset: messages.length - 1 });
await RocketChat.loadThreadMessages({ tmid, rid });
} else {
await RocketChat.loadMissedMessages({ rid, lastOpen: moment().subtract(7, 'days').toDate() });
}
@ -284,7 +237,6 @@ class List extends React.Component {
this.setState({ refreshing: false });
})
// eslint-disable-next-line react/sort-comp
update = () => {
if (this.animated) {
animateNextTransition();
@ -306,9 +258,53 @@ class List extends React.Component {
return null;
}
handleScrollToIndexFailed = (params) => {
const { listRef } = this.props;
listRef.current.getNode().scrollToIndex({ index: params.highestMeasuredFrameIndex, animated: false });
}
jumpToMessage = messageId => new Promise(async(resolve) => {
this.jumping = true;
const { messages } = this.state;
const { listRef } = this.props;
const index = messages.findIndex(item => item.id === messageId);
if (index > -1) {
listRef.current.getNode().scrollToIndex({ index, viewPosition: 0.5 });
await new Promise(res => setTimeout(res, 300));
if (!this.viewableItems.map(vi => vi.key).includes(messageId)) {
if (!this.jumping) {
return resolve();
}
await setTimeout(() => resolve(this.jumpToMessage(messageId)), 300);
return;
}
this.setState({ highlightedMessage: messageId });
this.clearHighlightedMessageTimeout();
this.highlightedMessageTimeout = setTimeout(() => {
this.setState({ highlightedMessage: null });
}, 10000);
await setTimeout(() => resolve(), 300);
} else {
listRef.current.getNode().scrollToIndex({ index: messages.length - 1, animated: false });
if (!this.jumping) {
return resolve();
}
await setTimeout(() => resolve(this.jumpToMessage(messageId)), 300);
}
});
// this.jumping is checked in between operations to make sure we're not stuck
cancelJumpToMessage = () => {
this.jumping = false;
}
jumpToBottom = () => {
const { listRef } = this.props;
listRef.current.getNode().scrollToOffset({ offset: -100 });
}
renderFooter = () => {
const { loading } = this.state;
const { rid, theme } = this.props;
const { rid, theme, loading } = this.props;
if (loading && rid) {
return <ActivityIndicator theme={theme} />;
}
@ -316,36 +312,34 @@ class List extends React.Component {
}
renderItem = ({ item, index }) => {
const { messages } = this.state;
const { messages, highlightedMessage } = this.state;
const { renderRow } = this.props;
return renderRow(item, messages[index + 1]);
return renderRow(item, messages[index + 1], highlightedMessage);
}
onViewableItemsChanged = ({ viewableItems }) => {
this.viewableItems = viewableItems;
}
render() {
console.count(`${ this.constructor.name }.render calls`);
const { rid, listRef } = this.props;
const { rid, tmid, listRef } = this.props;
const { messages, refreshing } = this.state;
const { theme } = this.props;
return (
<>
<EmptyRoom rid={rid} length={messages.length} mounted={this.mounted} theme={theme} />
<FlatList
testID='room-view-messages'
ref={listRef}
keyExtractor={item => item.id}
<List
onScroll={this.onScroll}
scrollEventThrottle={16}
listRef={listRef}
data={messages}
extraData={this.state}
renderItem={this.renderItem}
contentContainerStyle={styles.contentContainer}
style={styles.list}
inverted
removeClippedSubviews={isIOS}
initialNumToRender={7}
onEndReached={this.onEndReached}
onEndReachedThreshold={0.5}
maxToRenderPerBatch={5}
windowSize={10}
ListFooterComponent={this.renderFooter}
onScrollToIndexFailed={this.handleScrollToIndexFailed}
onViewableItemsChanged={this.onViewableItemsChanged}
viewabilityConfig={this.viewabilityConfig}
refreshControl={(
<RefreshControl
refreshing={refreshing}
@ -353,11 +347,11 @@ class List extends React.Component {
tintColor={themes[theme].auxiliaryText}
/>
)}
{...scrollPersistTaps}
/>
<NavBottomFAB y={this.y} onPress={this.jumpToBottom} isThread={!!tmid} />
</>
);
}
}
export default List;
export default ListContainer;

View File

@ -0,0 +1,62 @@
/* eslint-disable import/no-extraneous-dependencies, import/no-unresolved, import/extensions, react/prop-types, react/destructuring-assignment */
import React from 'react';
import { ScrollView } from 'react-native';
import { storiesOf } from '@storybook/react-native';
import LoadMore from './index';
import { longText } from '../../../../storybook/utils';
import { ThemeContext } from '../../../theme';
import {
Message, StoryProvider, MessageDecorator
} from '../../../../storybook/stories/Message';
import { themes } from '../../../constants/colors';
import { MESSAGE_TYPE_LOAD_MORE, MESSAGE_TYPE_LOAD_NEXT_CHUNK, MESSAGE_TYPE_LOAD_PREVIOUS_CHUNK } from '../../../constants/messageTypeLoad';
const stories = storiesOf('LoadMore', module);
// FIXME: for some reason, this promise never resolves on Storybook (it works on the app, so maybe the issue isn't on the component)
const load = () => new Promise(res => setTimeout(res, 1000));
stories.add('basic', () => (
<>
<LoadMore load={load} />
<LoadMore load={load} runOnRender />
<LoadMore load={load} type={MESSAGE_TYPE_LOAD_PREVIOUS_CHUNK} />
<LoadMore load={load} type={MESSAGE_TYPE_LOAD_NEXT_CHUNK} />
</>
));
const ThemeStory = ({ theme }) => (
<ThemeContext.Provider
value={{ theme }}
>
<ScrollView style={{ backgroundColor: themes[theme].backgroundColor }}>
<LoadMore load={load} type={MESSAGE_TYPE_LOAD_PREVIOUS_CHUNK} />
<Message msg='Hey!' theme={theme} />
<Message msg={longText} theme={theme} isHeader={false} />
<Message msg='Older message' theme={theme} isHeader={false} />
<LoadMore load={load} type={MESSAGE_TYPE_LOAD_NEXT_CHUNK} />
<LoadMore load={load} type={MESSAGE_TYPE_LOAD_MORE} />
<Message msg={longText} theme={theme} />
<Message msg='This is the third message' isHeader={false} theme={theme} />
<Message msg='This is the second message' isHeader={false} theme={theme} />
<Message msg='This is the first message' theme={theme} />
</ScrollView>
</ThemeContext.Provider>
);
stories
.addDecorator(StoryProvider)
.addDecorator(MessageDecorator)
.add('light theme', () => <ThemeStory theme='light' />);
stories
.addDecorator(StoryProvider)
.addDecorator(MessageDecorator)
.add('dark theme', () => <ThemeStory theme='dark' />);
stories
.addDecorator(StoryProvider)
.addDecorator(MessageDecorator)
.add('black theme', () => <ThemeStory theme='black' />);

View File

@ -0,0 +1,76 @@
import React, { useEffect, useCallback, useState } from 'react';
import { Text, StyleSheet, ActivityIndicator } from 'react-native';
import PropTypes from 'prop-types';
import { themes } from '../../../constants/colors';
import { MESSAGE_TYPE_LOAD_NEXT_CHUNK, MESSAGE_TYPE_LOAD_PREVIOUS_CHUNK } from '../../../constants/messageTypeLoad';
import { useTheme } from '../../../theme';
import Touch from '../../../utils/touch';
import sharedStyles from '../../Styles';
import I18n from '../../../i18n';
const styles = StyleSheet.create({
button: {
paddingVertical: 16,
alignItems: 'center',
justifyContent: 'center'
},
text: {
fontSize: 16,
...sharedStyles.textMedium
}
});
const LoadMore = ({ load, type, runOnRender }) => {
const { theme } = useTheme();
const [loading, setLoading] = useState(false);
const handleLoad = useCallback(async() => {
try {
if (loading) {
return;
}
setLoading(true);
await load();
} finally {
setLoading(false);
}
}, [loading]);
useEffect(() => {
if (runOnRender) {
handleLoad();
}
}, []);
let text = 'Load_More';
if (type === MESSAGE_TYPE_LOAD_NEXT_CHUNK) {
text = 'Load_Newer';
}
if (type === MESSAGE_TYPE_LOAD_PREVIOUS_CHUNK) {
text = 'Load_Older';
}
return (
<Touch
onPress={handleLoad}
style={styles.button}
theme={theme}
enabled={!loading}
>
{
loading
? <ActivityIndicator color={themes[theme].auxiliaryText} />
: <Text style={[styles.text, { color: themes[theme].titleText }]}>{I18n.t(text)}</Text>
}
</Touch>
);
};
LoadMore.propTypes = {
load: PropTypes.func,
type: PropTypes.string,
runOnRender: PropTypes.bool
};
export default LoadMore;

View File

@ -142,12 +142,12 @@ class RightButtonsContainer extends Component {
goSearchView = () => {
logEvent(events.ROOM_GO_SEARCH);
const {
rid, navigation, isMasterDetail
rid, t, navigation, isMasterDetail
} = this.props;
if (isMasterDetail) {
navigation.navigate('ModalStackNavigator', { screen: 'SearchMessagesView', params: { rid, showCloseModal: true } });
} else {
navigation.navigate('SearchMessagesView', { rid });
navigation.navigate('SearchMessagesView', { rid, t });
}
}

View File

@ -2,8 +2,8 @@ import React from 'react';
import PropTypes from 'prop-types';
import { Text, View, InteractionManager } from 'react-native';
import { connect } from 'react-redux';
import parse from 'url-parse';
import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord';
import moment from 'moment';
import * as Haptics from 'expo-haptics';
import { Q } from '@nozbe/watermelondb';
@ -17,7 +17,6 @@ import {
import List from './List';
import database from '../../lib/database';
import RocketChat from '../../lib/rocketchat';
import { Encryption } from '../../lib/encryption';
import Message from '../../containers/message';
import MessageActions from '../../containers/MessageActions';
import MessageErrorActions from '../../containers/MessageErrorActions';
@ -35,6 +34,7 @@ import RightButtons from './RightButtons';
import StatusBar from '../../containers/StatusBar';
import Separator from './Separator';
import { themes } from '../../constants/colors';
import { MESSAGE_TYPE_ANY_LOAD, MESSAGE_TYPE_LOAD_MORE } from '../../constants/messageTypeLoad';
import debounce from '../../utils/debounce';
import ReactionsModal from '../../containers/ReactionsModal';
import { LISTENER } from '../../containers/Toast';
@ -64,6 +64,12 @@ import { getHeaderTitlePosition } from '../../containers/Header';
import { E2E_MESSAGE_TYPE, E2E_STATUS } from '../../lib/encryption/constants';
import { takeInquiry } from '../../ee/omnichannel/lib';
import Loading from '../../containers/Loading';
import LoadMore from './LoadMore';
import RoomServices from './services';
import { goRoom } from '../../utils/goRoom';
import getThreadName from '../../lib/methods/getThreadName';
import getRoomInfo from '../../lib/methods/getRoomInfo';
const stateAttrsUpdate = [
'joined',
@ -76,7 +82,8 @@ const stateAttrsUpdate = [
'replying',
'reacting',
'readOnly',
'member'
'member',
'showingBlockingLoader'
];
const roomAttrsUpdate = ['f', 'ro', 'blocked', 'blocker', 'archived', 'tunread', 'muted', 'ignored', 'jitsiTimeout', 'announcement', 'sysMes', 'topic', 'name', 'fname', 'roles', 'bannerClosed', 'visitor', 'joinCodeRequired'];
@ -117,11 +124,11 @@ class RoomView extends React.Component {
const selectedMessage = props.route.params?.message;
const name = props.route.params?.name;
const fname = props.route.params?.fname;
const search = props.route.params?.search;
const prid = props.route.params?.prid;
const room = props.route.params?.room ?? {
rid: this.rid, t: this.t, name, fname, prid
};
this.jumpToMessageId = props.route.params?.jumpToMessageId;
const roomUserId = props.route.params?.roomUserId ?? RocketChat.getUidDirectMessage(room);
this.state = {
joined: true,
@ -133,6 +140,7 @@ class RoomView extends React.Component {
selectedMessage: selectedMessage || {},
canAutoTranslate: false,
loading: true,
showingBlockingLoader: false,
editing: false,
replying: !!selectedMessage,
replyWithMention: false,
@ -151,13 +159,10 @@ class RoomView extends React.Component {
this.setReadOnly();
if (search) {
this.updateRoom();
}
this.messagebox = React.createRef();
this.list = React.createRef();
this.joinCode = React.createRef();
this.flatList = React.createRef();
this.mounted = false;
// we don't need to subscribe to threads
@ -181,6 +186,9 @@ class RoomView extends React.Component {
EventEmitter.addEventListener('connected', this.handleConnected);
}
}
if (this.jumpToMessageId) {
this.jumpToMessage(this.jumpToMessageId);
}
if (isIOS && this.rid) {
this.updateUnreadCount();
}
@ -195,7 +203,9 @@ class RoomView extends React.Component {
shouldComponentUpdate(nextProps, nextState) {
const { state } = this;
const { roomUpdate, member } = state;
const { appState, theme, insets } = this.props;
const {
appState, theme, insets, route
} = this.props;
if (theme !== nextProps.theme) {
return true;
}
@ -212,12 +222,19 @@ class RoomView extends React.Component {
if (!dequal(nextProps.insets, insets)) {
return true;
}
if (!dequal(nextProps.route?.params, route?.params)) {
return true;
}
return roomAttrsUpdate.some(key => !dequal(nextState.roomUpdate[key], roomUpdate[key]));
}
componentDidUpdate(prevProps, prevState) {
const { roomUpdate } = this.state;
const { appState, insets } = this.props;
const { appState, insets, route } = this.props;
if (route?.params?.jumpToMessageId !== prevProps.route?.params?.jumpToMessageId) {
this.jumpToMessage(route?.params?.jumpToMessageId);
}
if (appState === 'foreground' && appState !== prevProps.appState && this.rid) {
// Fire List.query() just to keep observables working
@ -417,34 +434,15 @@ class RoomView extends React.Component {
this.setState({ readOnly });
}
updateRoom = async() => {
const db = database.active;
try {
const subCollection = db.get('subscriptions');
const sub = await subCollection.find(this.rid);
const { room } = await RocketChat.getRoomInfo(this.rid);
await db.action(async() => {
await sub.update((s) => {
Object.assign(s, room);
});
});
} catch {
// do nothing
}
}
init = async() => {
try {
this.setState({ loading: true });
const { room, joined } = this.state;
if (this.tmid) {
await this.getThreadMessages();
await RoomServices.getThreadMessages(this.tmid, this.rid);
} else {
const newLastOpen = new Date();
await this.getMessages(room);
await RoomServices.getMessages(room);
// if room is joined
if (joined) {
@ -453,7 +451,7 @@ class RoomView extends React.Component {
} else {
this.setLastOpen(null);
}
RocketChat.readMessages(room.rid, newLastOpen, true).catch(e => console.log(e));
RoomServices.readMessages(room.rid, newLastOpen, true).catch(e => console.log(e));
}
}
@ -660,26 +658,69 @@ class RoomView extends React.Component {
});
};
onThreadPress = debounce(async(item) => {
const { roomUserId } = this.state;
const { navigation } = this.props;
if (item.tmid) {
if (!item.tmsg) {
await this.fetchThreadName(item.tmid, item.id);
onThreadPress = debounce(item => this.navToThread(item), 1000, true)
shouldNavigateToRoom = (message) => {
if (message.tmid && message.tmid === this.tmid) {
return false;
}
let name = item.tmsg;
if (item.t === E2E_MESSAGE_TYPE && item.e2e !== E2E_STATUS.DONE) {
name = I18n.t('Encrypted_message');
if (!message.tmid && message.rid === this.rid) {
return false;
}
return true;
}
jumpToMessageByUrl = async(messageUrl) => {
if (!messageUrl) {
return;
}
try {
this.setState({ showingBlockingLoader: true });
const parsedUrl = parse(messageUrl, true);
const messageId = parsedUrl.query.msg;
await this.jumpToMessage(messageId);
this.setState({ showingBlockingLoader: false });
} catch (e) {
this.setState({ showingBlockingLoader: false });
log(e);
}
}
jumpToMessage = async(messageId) => {
try {
this.setState({ showingBlockingLoader: true });
const message = await RoomServices.getMessageInfo(messageId);
if (!message) {
return;
}
if (this.shouldNavigateToRoom(message)) {
if (message.rid !== this.rid) {
this.navToRoom(message);
} else {
this.navToThread(message);
}
} else {
/**
* if it's from server, we don't have it saved locally and so we fetch surroundings
* we test if it's not from threads because we're fetching from threads currently with `getThreadMessages`
*/
if (message.fromServer && !message.tmid) {
await RocketChat.loadSurroundingMessages({ messageId, rid: this.rid });
}
await Promise.race([
this.list.current.jumpToMessage(message.id),
new Promise(res => setTimeout(res, 5000))
]);
this.list.current.cancelJumpToMessage();
}
} catch (e) {
log(e);
} finally {
this.setState({ showingBlockingLoader: false });
}
navigation.push('RoomView', {
rid: item.subscription.id, tmid: item.tmid, name, t: 'thread', roomUserId
});
} else if (item.tlm) {
navigation.push('RoomView', {
rid: item.subscription.id, tmid: item.id, name: makeThreadName(item), t: 'thread', roomUserId
});
}
}, 1000, true)
replyBroadcast = (message) => {
const { replyBroadcast } = this.props;
@ -718,17 +759,6 @@ class RoomView extends React.Component {
});
};
getMessages = () => {
const { room } = this.state;
if (room.lastOpen) {
return RocketChat.loadMissedMessages(room);
} else {
return RocketChat.loadMessagesForRoom(room);
}
}
getThreadMessages = () => RocketChat.loadThreadMessages({ tmid: this.tmid, rid: this.rid })
getCustomEmoji = (name) => {
const { customEmojis } = this.props;
const emoji = customEmojis[name];
@ -767,45 +797,7 @@ class RoomView extends React.Component {
}
}
// eslint-disable-next-line react/sort-comp
fetchThreadName = async(tmid, messageId) => {
try {
const db = database.active;
const threadCollection = db.get('threads');
const messageCollection = db.get('messages');
const messageRecord = await messageCollection.find(messageId);
let threadRecord;
try {
threadRecord = await threadCollection.find(tmid);
} catch (error) {
console.log('Thread not found. We have to search for it.');
}
if (threadRecord) {
await db.action(async() => {
await messageRecord.update((m) => {
m.tmsg = threadRecord.msg || (threadRecord.attachments && threadRecord.attachments.length && threadRecord.attachments[0].title);
});
});
} else {
let { message: thread } = await RocketChat.getSingleMessage(tmid);
thread = await Encryption.decryptMessage(thread);
await db.action(async() => {
await db.batch(
threadCollection.prepareCreate((t) => {
t._raw = sanitizedRaw({ id: thread._id }, threadCollection.schema);
t.subscription.id = this.rid;
Object.assign(t, thread);
}),
messageRecord.prepareUpdate((m) => {
m.tmsg = thread.msg || (thread.attachments && thread.attachments.length && thread.attachments[0].title);
})
);
});
}
} catch (e) {
// log(e);
}
}
getThreadName = (tmid, messageId) => getThreadName(this.rid, tmid, messageId)
toggleFollowThread = async(isFollowingThread, tmid) => {
try {
@ -836,6 +828,38 @@ class RoomView extends React.Component {
}
}
navToThread = async(item) => {
const { roomUserId } = this.state;
const { navigation } = this.props;
if (item.tmid) {
let name = item.tmsg;
if (!name) {
name = await this.getThreadName(item.tmid, item.id);
}
if (item.t === E2E_MESSAGE_TYPE && item.e2e !== E2E_STATUS.DONE) {
name = I18n.t('Encrypted_message');
}
return navigation.push('RoomView', {
rid: this.rid, tmid: item.tmid, name, t: 'thread', roomUserId, jumpToMessageId: item.id
});
}
if (item.tlm) {
return navigation.push('RoomView', {
rid: this.rid, tmid: item.id, name: makeThreadName(item), t: 'thread', roomUserId
});
}
}
navToRoom = async(message) => {
const { navigation, isMasterDetail } = this.props;
const roomInfo = await getRoomInfo(message.rid);
return goRoom({
item: roomInfo, isMasterDetail, navigationMethod: navigation.push, jumpToMessageId: message.id
});
}
callJitsi = () => {
const { room } = this.state;
const { jitsiTimeout } = room;
@ -900,7 +924,11 @@ class RoomView extends React.Component {
return room?.ignored?.includes?.(message?.u?._id) ?? false;
}
renderItem = (item, previousItem) => {
onLoadMoreMessages = loaderItem => RoomServices.getMoreMessages({
rid: this.rid, tmid: this.tmid, t: this.t, loaderItem
})
renderItem = (item, previousItem, highlightedMessage) => {
const { room, lastOpen, canAutoTranslate } = this.state;
const {
user, Message_GroupingPeriod, Message_TimeFormat, useRealName, baseUrl, Message_Read_Receipt_Enabled, theme
@ -920,7 +948,11 @@ class RoomView extends React.Component {
}
}
const message = (
let content = null;
if (MESSAGE_TYPE_ANY_LOAD.includes(item.t)) {
content = <LoadMore load={() => this.onLoadMoreMessages(item)} type={item.t} runOnRender={item.t === MESSAGE_TYPE_LOAD_MORE && !previousItem} />;
} else {
content = (
<Message
item={item}
user={user}
@ -931,7 +963,7 @@ class RoomView extends React.Component {
isThreadRoom={!!this.tmid}
isIgnored={this.isIgnored(item)}
previousItem={previousItem}
fetchThreadName={this.fetchThreadName}
fetchThreadName={this.getThreadName}
onReactionPress={this.onReactionPress}
onReactionLongPress={this.onReactionLongPress}
onLongPress={this.onMessageLongPress}
@ -955,13 +987,16 @@ class RoomView extends React.Component {
blockAction={this.blockAction}
threadBadgeColor={this.getBadgeColor(item?.id)}
toggleFollowThread={this.toggleFollowThread}
jumpToMessage={this.jumpToMessageByUrl}
highlighted={highlightedMessage === item.id}
/>
);
}
if (showUnreadSeparator || dateSeparator) {
return (
<>
{message}
{content}
<Separator
ts={dateSeparator}
unread={showUnreadSeparator}
@ -971,7 +1006,7 @@ class RoomView extends React.Component {
);
}
return message;
return content;
}
renderFooter = () => {
@ -1057,12 +1092,10 @@ class RoomView extends React.Component {
);
}
setListRef = ref => this.flatList = ref;
render() {
console.count(`${ this.constructor.name }.render calls`);
const {
room, reactionsModalVisible, selectedMessage, loading, reacting
room, reactionsModalVisible, selectedMessage, loading, reacting, showingBlockingLoader
} = this.state;
const {
user, baseUrl, theme, navigation, Hide_System_Messages, width, height
@ -1087,7 +1120,7 @@ class RoomView extends React.Component {
/>
<List
ref={this.list}
listRef={this.setListRef}
listRef={this.flatList}
rid={rid}
t={t}
tmid={this.tmid}
@ -1127,6 +1160,7 @@ class RoomView extends React.Component {
t={t}
theme={theme}
/>
<Loading visible={showingBlockingLoader} />
</SafeAreaView>
);
}

View File

@ -0,0 +1,41 @@
import { getMessageById } from '../../../lib/database/services/Message';
import { getThreadMessageById } from '../../../lib/database/services/ThreadMessage';
import getSingleMessage from '../../../lib/methods/getSingleMessage';
const getMessageInfo = async(messageId) => {
let result;
result = await getMessageById(messageId);
if (result) {
return {
id: result.id,
rid: result.subscription.id,
tmid: result.tmid,
msg: result.msg
};
}
result = await getThreadMessageById(messageId);
if (result) {
return {
id: result.id,
rid: result.subscription.id,
tmid: result.rid,
msg: result.msg
};
}
result = await getSingleMessage(messageId);
if (result) {
return {
id: result._id,
rid: result.rid,
tmid: result.tmid,
msg: result.msg,
fromServer: true
};
}
return null;
};
export default getMessageInfo;

View File

@ -0,0 +1,10 @@
import RocketChat from '../../../lib/rocketchat';
const getMessages = (room) => {
if (room.lastOpen) {
return RocketChat.loadMissedMessages(room);
} else {
return RocketChat.loadMessagesForRoom(room);
}
};
export default getMessages;

View File

@ -0,0 +1,19 @@
import { MESSAGE_TYPE_LOAD_MORE, MESSAGE_TYPE_LOAD_NEXT_CHUNK, MESSAGE_TYPE_LOAD_PREVIOUS_CHUNK } from '../../../constants/messageTypeLoad';
import RocketChat from '../../../lib/rocketchat';
const getMoreMessages = ({
rid, t, tmid, loaderItem
}) => {
if ([MESSAGE_TYPE_LOAD_MORE, MESSAGE_TYPE_LOAD_PREVIOUS_CHUNK].includes(loaderItem.t)) {
return RocketChat.loadMessagesForRoom({
rid, t, latest: loaderItem.ts, loaderItem
});
}
if (loaderItem.t === MESSAGE_TYPE_LOAD_NEXT_CHUNK) {
return RocketChat.loadNextMessages({
rid, tmid, ts: loaderItem.ts, loaderItem
});
}
};
export default getMoreMessages;

View File

@ -0,0 +1,6 @@
import RocketChat from '../../../lib/rocketchat';
// unlike getMessages, sync isn't required for threads, because loadMissedMessages does it already
const getThreadMessages = (tmid, rid) => RocketChat.loadThreadMessages({ tmid, rid });
export default getThreadMessages;

View File

@ -0,0 +1,13 @@
import getMessages from './getMessages';
import getMoreMessages from './getMoreMessages';
import getThreadMessages from './getThreadMessages';
import readMessages from './readMessages';
import getMessageInfo from './getMessageInfo';
export default {
getMessages,
getMoreMessages,
getThreadMessages,
readMessages,
getMessageInfo
};

View File

@ -0,0 +1,5 @@
import RocketChat from '../../../lib/rocketchat';
const readMessages = (rid, newLastOpen) => RocketChat.readMessages(rid, newLastOpen, true);
export default readMessages;

View File

@ -9,12 +9,6 @@ export default StyleSheet.create({
safeAreaView: {
flex: 1
},
list: {
flex: 1
},
contentContainer: {
paddingTop: 10
},
readOnly: {
justifyContent: 'flex-end',
alignItems: 'center',

View File

@ -23,6 +23,8 @@ import SafeAreaView from '../../containers/SafeAreaView';
import * as HeaderButton from '../../containers/HeaderButton';
import database from '../../lib/database';
import { sanitizeLikeString } from '../../lib/database/utils';
import getThreadName from '../../lib/methods/getThreadName';
import getRoomInfo from '../../lib/methods/getRoomInfo';
class SearchMessagesView extends React.Component {
static navigationOptions = ({ navigation, route }) => {
@ -54,9 +56,14 @@ class SearchMessagesView extends React.Component {
searchText: ''
};
this.rid = props.route.params?.rid;
this.t = props.route.params?.t;
this.encrypted = props.route.params?.encrypted;
}
async componentDidMount() {
this.room = await getRoomInfo(this.rid);
}
shouldComponentUpdate(nextProps, nextState) {
const { loading, searchText, messages } = this.state;
const { theme } = this.props;
@ -126,6 +133,11 @@ class SearchMessagesView extends React.Component {
return null;
}
showAttachment = (attachment) => {
const { navigation } = this.props;
navigation.navigate('AttachmentView', { attachment });
}
navToRoomInfo = (navParam) => {
const { navigation, user } = this.props;
if (navParam.rid === user.id) {
@ -134,6 +146,28 @@ class SearchMessagesView extends React.Component {
navigation.navigate('RoomInfoView', navParam);
}
jumpToMessage = async({ item }) => {
const { navigation } = this.props;
let params = {
rid: this.rid,
jumpToMessageId: item._id,
t: this.t,
room: this.room
};
if (item.tmid) {
navigation.pop();
params = {
...params,
tmid: item.tmid,
name: await getThreadName(this.rid, item.tmid, item._id),
t: 'thread'
};
navigation.push('RoomView', params);
} else {
navigation.navigate('RoomView', params);
}
}
renderEmpty = () => {
const { theme } = this.props;
return (
@ -152,13 +186,16 @@ class SearchMessagesView extends React.Component {
item={item}
baseUrl={baseUrl}
user={user}
timeFormat='LLL'
timeFormat='MMM Do YYYY, h:mm:ss a'
isHeader
showAttachment={() => {}}
isThreadRoom
showAttachment={this.showAttachment}
getCustomEmoji={this.getCustomEmoji}
navToRoomInfo={this.navToRoomInfo}
useRealName={useRealName}
theme={theme}
onPress={() => this.jumpToMessage({ item })}
jumpToMessage={() => this.jumpToMessage({ item })}
/>
);
}

141
app/views/SelectListView.js Normal file
View File

@ -0,0 +1,141 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
View, StyleSheet, FlatList, Text
} from 'react-native';
import { connect } from 'react-redux';
import * as List from '../containers/List';
import sharedStyles from './Styles';
import I18n from '../i18n';
import * as HeaderButton from '../containers/HeaderButton';
import StatusBar from '../containers/StatusBar';
import { themes } from '../constants/colors';
import { withTheme } from '../theme';
import SafeAreaView from '../containers/SafeAreaView';
import { animateNextTransition } from '../utils/layoutAnimation';
const styles = StyleSheet.create({
buttonText: {
fontSize: 16,
margin: 16,
...sharedStyles.textRegular
}
});
class SelectListView extends React.Component {
static propTypes = {
navigation: PropTypes.object,
route: PropTypes.object,
theme: PropTypes.string,
isMasterDetail: PropTypes.bool
};
constructor(props) {
super(props);
const data = props.route?.params?.data;
this.title = props.route?.params?.title;
this.infoText = props.route?.params?.infoText;
this.nextAction = props.route?.params?.nextAction;
this.showAlert = props.route?.params?.showAlert;
this.state = {
data,
selected: []
};
this.setHeader();
}
setHeader = () => {
const { navigation, isMasterDetail } = this.props;
const { selected } = this.state;
const options = {
headerTitle: I18n.t(this.title)
};
if (isMasterDetail) {
options.headerLeft = () => <HeaderButton.CloseModal navigation={navigation} />;
}
options.headerRight = () => (
<HeaderButton.Container>
<HeaderButton.Item title={I18n.t('Next')} onPress={() => this.nextAction(selected)} testID='select-list-view-submit' />
</HeaderButton.Container>
);
navigation.setOptions(options);
}
renderInfoText = () => {
const { theme } = this.props;
return (
<View style={{ backgroundColor: themes[theme].backgroundColor }}>
<Text style={[styles.buttonText, { color: themes[theme].bodyText }]}>{I18n.t(this.infoText)}</Text>
</View>
);
}
isChecked = (rid) => {
const { selected } = this.state;
return selected.includes(rid);
}
toggleItem = (rid) => {
const { selected } = this.state;
animateNextTransition();
if (!this.isChecked(rid)) {
this.setState({ selected: [...selected, rid] }, () => this.setHeader());
} else {
const filterSelected = selected.filter(el => el !== rid);
this.setState({ selected: filterSelected }, () => this.setHeader());
}
}
renderItem = ({ item }) => {
const { theme } = this.props;
const icon = item.t === 'p' ? 'channel-private' : 'channel-public';
const checked = this.isChecked(item.rid) ? 'check' : null;
return (
<>
<List.Separator />
<List.Item
title={item.name}
translateTitle={false}
testID={`select-list-view-item-${ item.name }`}
onPress={() => (item.alert ? this.showAlert() : this.toggleItem(item.rid))}
alert={item.alert}
left={() => <List.Icon name={icon} color={themes[theme].controlText} />}
right={() => (checked ? <List.Icon name={checked} color={themes[theme].actionTintColor} /> : null)}
/>
</>
);
}
render() {
const { data } = this.state;
const { theme } = this.props;
return (
<SafeAreaView testID='select-list-view'>
<StatusBar />
<FlatList
data={data}
extraData={this.state}
keyExtractor={item => item.rid}
renderItem={this.renderItem}
ListHeaderComponent={this.renderInfoText}
contentContainerStyle={{ backgroundColor: themes[theme].backgroundColor }}
keyboardShouldPersistTaps='always'
/>
</SafeAreaView>
);
}
}
const mapStateToProps = state => ({
isMasterDetail: state.app.isMasterDetail
});
export default connect(mapStateToProps)(withTheme(SelectListView));

View File

@ -1,11 +1,10 @@
import React from 'react';
import { Keyboard } from 'react-native';
import { Keyboard, Alert } from 'react-native';
import PropTypes from 'prop-types';
import { Q } from '@nozbe/watermelondb';
import { withSafeAreaInsets } from 'react-native-safe-area-context';
import { connect } from 'react-redux';
import { FlatList } from 'react-native-gesture-handler';
import { HeaderBackButton } from '@react-navigation/stack';
import StatusBar from '../containers/StatusBar';
import RoomHeader from '../containers/RoomHeader';
@ -23,13 +22,22 @@ import RoomItem, { ROW_HEIGHT } from '../presentation/RoomItem';
import RocketChat from '../lib/rocketchat';
import { withDimensions } from '../dimensions';
import { isIOS } from '../utils/deviceInfo';
import { themes } from '../constants/colors';
import debounce from '../utils/debounce';
import { showErrorAlert } from '../utils/info';
import { goRoom } from '../utils/goRoom';
import I18n from '../i18n';
import { withActionSheet } from '../containers/ActionSheet';
import { deleteRoom as deleteRoomAction } from '../actions/room';
import { CustomIcon } from '../lib/Icons';
import { themes } from '../constants/colors';
const API_FETCH_COUNT = 25;
const PERMISSION_DELETE_C = 'delete-c';
const PERMISSION_DELETE_P = 'delete-p';
const PERMISSION_EDIT_TEAM_CHANNEL = 'edit-team-channel';
const PERMISSION_REMOVE_TEAM_CHANNEL = 'remove-team-channel';
const PERMISSION_ADD_TEAM_CHANNEL = 'add-team-channel';
const getItemLayout = (data, index) => ({
length: data.length,
@ -47,7 +55,14 @@ class TeamChannelsView extends React.Component {
theme: PropTypes.string,
useRealName: PropTypes.bool,
width: PropTypes.number,
StoreLastMessage: PropTypes.bool
StoreLastMessage: PropTypes.bool,
addTeamChannelPermission: PropTypes.array,
editTeamChannelPermission: PropTypes.array,
removeTeamChannelPermission: PropTypes.array,
deleteCPermission: PropTypes.array,
deletePPermission: PropTypes.array,
showActionSheet: PropTypes.func,
deleteRoom: PropTypes.func
}
constructor(props) {
@ -60,9 +75,11 @@ class TeamChannelsView extends React.Component {
isSearching: false,
searchText: '',
search: [],
end: false
end: false,
showCreate: false
};
this.loadTeam();
this.setHeader();
}
componentDidMount() {
@ -70,6 +87,9 @@ class TeamChannelsView extends React.Component {
}
loadTeam = async() => {
const { addTeamChannelPermission } = this.props;
const { loading, data } = this.state;
const db = database.active;
try {
const subCollection = db.get('subscriptions');
@ -82,6 +102,15 @@ class TeamChannelsView extends React.Component {
if (!this.team) {
throw new Error();
}
const permissions = await RocketChat.hasPermission([addTeamChannelPermission], this.team.rid);
if (permissions[0]) {
this.setState({ showCreate: true }, () => this.setHeader());
}
if (loading && data.length) {
this.setState({ loading: false });
}
} catch {
const { navigation } = this.props;
navigation.pop();
@ -115,14 +144,11 @@ class TeamChannelsView extends React.Component {
loadingMore: false,
end: result.rooms.length < API_FETCH_COUNT
};
const rooms = result.rooms.map((room) => {
const record = this.teamChannels?.find(c => c.rid === room._id);
return record ?? room;
});
if (isSearching) {
newState.search = [...search, ...rooms];
newState.search = [...search, ...result.rooms];
} else {
newState.data = [...data, ...rooms];
newState.data = [...data, ...result.rooms];
}
this.setState(newState);
@ -135,18 +161,16 @@ class TeamChannelsView extends React.Component {
}
}, 300)
getHeader = () => {
const { isSearching } = this.state;
const {
navigation, isMasterDetail, insets, theme
} = this.props;
setHeader = () => {
const { isSearching, showCreate, data } = this.state;
const { navigation, isMasterDetail, insets } = this.props;
const { team } = this;
if (!team) {
return;
}
const headerTitlePosition = getHeaderTitlePosition({ insets, numIconsRight: 1 });
const headerTitlePosition = getHeaderTitlePosition({ insets, numIconsRight: 2 });
if (isSearching) {
return {
@ -188,27 +212,16 @@ class TeamChannelsView extends React.Component {
if (isMasterDetail) {
options.headerLeft = () => <HeaderButton.CloseModal navigation={navigation} />;
} else {
options.headerLeft = () => (
<HeaderBackButton
labelVisible={false}
onPress={() => navigation.pop()}
tintColor={themes[theme].headerTintColor}
/>
);
}
options.headerRight = () => (
<HeaderButton.Container>
{ showCreate
? <HeaderButton.Item iconName='create' onPress={() => navigation.navigate('AddChannelTeamView', { teamId: this.teamId, teamChannels: data })} />
: null}
<HeaderButton.Item iconName='search' onPress={this.onSearchPress} />
</HeaderButton.Container>
);
return options;
}
setHeader = () => {
const { navigation } = this.props;
const options = this.getHeader();
navigation.setOptions(options);
}
@ -287,6 +300,124 @@ class TeamChannelsView extends React.Component {
}
}, 1000, true);
toggleAutoJoin = async(item) => {
try {
const { data } = this.state;
const result = await RocketChat.updateTeamRoom({ roomId: item._id, isDefault: !item.teamDefault });
if (result.success) {
const newData = data.map((i) => {
if (i._id === item._id) {
i.teamDefault = !i.teamDefault;
}
return i;
});
this.setState({ data: newData });
}
} catch (e) {
log(e);
}
}
remove = (item) => {
Alert.alert(
I18n.t('Confirmation'),
I18n.t('Remove_Team_Room_Warning'),
[
{
text: I18n.t('Cancel'),
style: 'cancel'
},
{
text: I18n.t('Yes_action_it', { action: I18n.t('remove') }),
style: 'destructive',
onPress: () => this.removeRoom(item)
}
],
{ cancelable: false }
);
}
removeRoom = async(item) => {
try {
const { data } = this.state;
const result = await RocketChat.removeTeamRoom({ roomId: item._id, teamId: this.team.teamId });
if (result.success) {
const newData = data.filter(room => result.room._id !== room._id);
this.setState({ data: newData });
}
} catch (e) {
log(e);
}
}
delete = (item) => {
const { deleteRoom } = this.props;
Alert.alert(
I18n.t('Are_you_sure_question_mark'),
I18n.t('Delete_Room_Warning'),
[
{
text: I18n.t('Cancel'),
style: 'cancel'
},
{
text: I18n.t('Yes_action_it', { action: I18n.t('delete') }),
style: 'destructive',
onPress: () => deleteRoom(item._id, item.t)
}
],
{ cancelable: false }
);
}
showChannelActions = async(item) => {
logEvent(events.ROOM_SHOW_BOX_ACTIONS);
const {
showActionSheet, editTeamChannelPermission, deleteCPermission, deletePPermission, theme, removeTeamChannelPermission
} = this.props;
const isAutoJoinChecked = item.teamDefault;
const autoJoinIcon = isAutoJoinChecked ? 'checkbox-checked' : 'checkbox-unchecked';
const autoJoinIconColor = isAutoJoinChecked ? themes[theme].tintActive : themes[theme].auxiliaryTintColor;
const options = [];
const permissionsTeam = await RocketChat.hasPermission([editTeamChannelPermission], this.team.rid);
if (permissionsTeam[0]) {
options.push({
title: I18n.t('Auto-join'),
icon: item.t === 'p' ? 'channel-private' : 'channel-public',
onPress: () => this.toggleAutoJoin(item),
right: () => <CustomIcon name={autoJoinIcon} size={20} color={autoJoinIconColor} />
});
}
const permissionsRemoveTeam = await RocketChat.hasPermission([removeTeamChannelPermission], this.team.rid);
if (permissionsRemoveTeam[0]) {
options.push({
title: I18n.t('Remove_from_Team'),
icon: 'close',
danger: true,
onPress: () => this.remove(item)
});
}
const permissionsChannel = await RocketChat.hasPermission([item.t === 'c' ? deleteCPermission : deletePPermission], item._id);
if (permissionsChannel[0]) {
options.push({
title: I18n.t('Delete'),
icon: 'delete',
danger: true,
onPress: () => this.delete(item)
});
}
if (options.length === 0) {
return;
}
showActionSheet({ options });
}
renderItem = ({ item }) => {
const {
StoreLastMessage,
@ -302,10 +433,12 @@ class TeamChannelsView extends React.Component {
showLastMessage={StoreLastMessage}
onPress={this.onPressItem}
width={width}
onLongPress={this.showChannelActions}
useRealName={useRealName}
getRoomTitle={this.getRoomTitle}
getRoomAvatar={this.getRoomAvatar}
swipeEnabled={false}
autoJoin={item.teamDefault}
/>
);
};
@ -365,7 +498,16 @@ const mapStateToProps = state => ({
user: getUserSelector(state),
useRealName: state.settings.UI_Use_Real_Name,
isMasterDetail: state.app.isMasterDetail,
StoreLastMessage: state.settings.Store_Last_Message
StoreLastMessage: state.settings.Store_Last_Message,
addTeamChannelPermission: state.permissions[PERMISSION_ADD_TEAM_CHANNEL],
editTeamChannelPermission: state.permissions[PERMISSION_EDIT_TEAM_CHANNEL],
removeTeamChannelPermission: state.permissions[PERMISSION_REMOVE_TEAM_CHANNEL],
deleteCPermission: state.permissions[PERMISSION_DELETE_C],
deletePPermission: state.permissions[PERMISSION_DELETE_P]
});
export default connect(mapStateToProps)(withDimensions(withSafeAreaInsets(withTheme(TeamChannelsView))));
const mapDispatchToProps = dispatch => ({
deleteRoom: (rid, t) => dispatch(deleteRoomAction(rid, t))
});
export default connect(mapStateToProps, mapDispatchToProps)(withDimensions(withSafeAreaInsets(withTheme(withActionSheet(TeamChannelsView)))));

View File

@ -1,6 +1,10 @@
const axios = require('axios').default;
const data = require('../data');
const { TEAM_TYPE } = require('../../app/definition/ITeam');
const TEAM_TYPE = {
PUBLIC: 0,
PRIVATE: 1
};
let server = data.server

View File

@ -131,8 +131,8 @@ describe('Discussion', () => {
await expect(element(by.id('room-info-view'))).toExist();
});
it('should not have edit button', async() => {
await expect(element(by.id('room-info-view-edit-button'))).toBeNotVisible();
it('should have edit button', async() => {
await expect(element(by.id('room-info-view-edit-button'))).toBeVisible();
});
});
});

View File

@ -23,6 +23,20 @@ stories.add('title and subtitle', () => (
</List.Container>
));
stories.add('alert', () => (
<List.Container>
<List.Separator />
<List.Item title='Chats' alert />
<List.Separator />
<List.Item title={longText} translateTitle={false} translateSubtitle={false} alert />
<List.Separator />
<List.Item title='Chats' right={() => <List.Icon name='emoji' />} alert />
<List.Separator />
<List.Item title={longText} translateTitle={false} translateSubtitle={false} right={() => <List.Icon name='emoji' />} alert />
<List.Separator />
</List.Container>
));
stories.add('pressable', () => (
<List.Container>
<List.Separator />

View File

@ -40,7 +40,7 @@ const getCustomEmoji = (content) => {
return customEmoji;
};
const messageDecorator = story => (
export const MessageDecorator = story => (
<MessageContext.Provider
value={{
user,
@ -60,7 +60,7 @@ const messageDecorator = story => (
</MessageContext.Provider>
);
const Message = props => (
export const Message = props => (
<MessageComponent
baseUrl={baseUrl}
user={user}
@ -74,12 +74,14 @@ const Message = props => (
/>
);
export const StoryProvider = story => <Provider store={store}>{story()}</Provider>;
const MessageScrollView = story => <ScrollView style={{ backgroundColor: themes[_theme].backgroundColor }}>{story()}</ScrollView>;
const stories = storiesOf('Message', module)
.addDecorator(story => <Provider store={store}>{story()}</Provider>)
.addDecorator(story => <ScrollView style={{ backgroundColor: themes[_theme].backgroundColor }}>{story()}</ScrollView>)
.addDecorator(messageDecorator);
.addDecorator(StoryProvider)
.addDecorator(MessageScrollView)
.addDecorator(MessageDecorator);
stories.add('Basic', () => (
<>

View File

@ -3,7 +3,6 @@ import React from 'react';
import { ScrollView, Dimensions } from 'react-native';
import { storiesOf } from '@storybook/react-native';
import { Provider } from 'react-redux';
// import moment from 'moment';
import { themes } from '../../app/constants/colors';
import RoomItemComponent from '../../app/presentation/RoomItem/RoomItem';
@ -94,6 +93,15 @@ stories.add('Alerts', () => (
</>
));
stories.add('Tag', () => (
<>
<RoomItem autoJoin />
<RoomItem showLastMessage autoJoin />
<RoomItem name={longText} autoJoin />
<RoomItem name={longText} autoJoin showLastMessage />
</>
));
stories.add('Last Message', () => (
<>
<RoomItem

View File

@ -14,6 +14,7 @@ import '../../app/views/ThreadMessagesView/Item.stories.js';
import './Avatar';
import '../../app/containers/BackgroundContainer/index.stories.js';
import '../../app/containers/RoomHeader/RoomHeader.stories.js';
import '../../app/views/RoomView/LoadMore/LoadMore.stories';
// Change here to see themed storybook
export const theme = 'light';