diff --git a/__tests__/__snapshots__/Storyshots.test.js.snap b/__tests__/__snapshots__/Storyshots.test.js.snap index d0724ff7e..299d099a4 100644 --- a/__tests__/__snapshots__/Storyshots.test.js.snap +++ b/__tests__/__snapshots__/Storyshots.test.js.snap @@ -59,7 +59,72 @@ exports[`Storyshots Avatar list Avatar 1`] = ` Object { "headers": undefined, "priority": "high", - "uri": "https://open.rocket.chat/avatar/Avatar?format=png&size=50", + "uri": "https://open.rocket.chat/avatar/Avatar?format=png&size=100", + } + } + style={ + Object { + "bottom": 0, + "left": 0, + "position": "absolute", + "right": 0, + "top": 0, + } + } + /> + + + + Avatar by roomId + + + + { - if ((!text && !avatar && !emoji) || !server) { + if ((!text && !avatar && !emoji && !rid) || !server) { return null; } @@ -57,7 +58,8 @@ const Avatar = React.memo(({ user, avatar, server, - avatarETag + avatarETag, + rid }); } @@ -108,7 +110,8 @@ Avatar.propTypes = { onPress: PropTypes.func, getCustomEmoji: PropTypes.func, avatarETag: PropTypes.string, - isStatic: PropTypes.bool + isStatic: PropTypes.bool, + rid: PropTypes.string }; Avatar.defaultProps = { diff --git a/app/containers/Avatar/index.js b/app/containers/Avatar/index.js index fff632b85..c5769dfd6 100644 --- a/app/containers/Avatar/index.js +++ b/app/containers/Avatar/index.js @@ -2,6 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { Q } from '@nozbe/watermelondb'; +import isEqual from 'react-fast-compare'; import database from '../../lib/database'; import { getUserSelector } from '../../selectors/login'; @@ -9,6 +10,7 @@ import Avatar from './Avatar'; class AvatarContainer extends React.Component { static propTypes = { + rid: PropTypes.string, text: PropTypes.string, type: PropTypes.string }; @@ -29,9 +31,15 @@ class AvatarContainer extends React.Component { this.mounted = true; } + componentDidUpdate(prevProps) { + if (!isEqual(prevProps, this.props)) { + this.init(); + } + } + componentWillUnmount() { - if (this.userSubscription?.unsubscribe) { - this.userSubscription.unsubscribe(); + if (this.subscription?.unsubscribe) { + this.subscription.unsubscribe(); } } @@ -41,26 +49,34 @@ class AvatarContainer extends React.Component { } init = async() => { - if (this.isDirect) { - const { text } = this.props; - const db = database.active; - const usersCollection = db.collections.get('users'); - try { + const db = database.active; + const usersCollection = db.collections.get('users'); + const subsCollection = db.collections.get('subscriptions'); + + let record; + try { + if (this.isDirect) { + const { text } = this.props; const [user] = await usersCollection.query(Q.where('username', text)).fetch(); - if (user) { - const observable = user.observe(); - this.userSubscription = observable.subscribe((u) => { - const { avatarETag } = u; - if (this.mounted) { - this.setState({ avatarETag }); - } else { - this.state.avatarETag = avatarETag; - } - }); - } - } catch { - // User was not found + record = user; + } else { + const { rid } = this.props; + record = await subsCollection.find(rid); } + } catch { + // Record not found + } + + if (record) { + const observable = record.observe(); + this.subscription = observable.subscribe((r) => { + const { avatarETag } = r; + if (this.mounted) { + this.setState({ avatarETag }); + } else { + this.state.avatarETag = avatarETag; + } + }); } } diff --git a/app/containers/InAppNotification/NotifierComponent.js b/app/containers/InAppNotification/NotifierComponent.js index 8d5016b7e..b69704359 100644 --- a/app/containers/InAppNotification/NotifierComponent.js +++ b/app/containers/InAppNotification/NotifierComponent.js @@ -70,13 +70,13 @@ const NotifierComponent = React.memo(({ notification, isMasterDetail }) => { const { isLandscape } = useOrientation(); const { text, payload } = notification; - const { type } = payload; + const { type, rid } = payload; const name = type === 'd' ? payload.sender.username : payload.name; // if sub is not on local database, title and avatar will be null, so we use payload from notification const { title = name, avatar = name } = notification; const onPress = () => { - const { rid, prid } = payload; + const { prid } = payload; if (!rid) { return; } @@ -111,7 +111,7 @@ const NotifierComponent = React.memo(({ notification, isMasterDetail }) => { background={Touchable.SelectableBackgroundBorderless()} > <> - + {title} {text} diff --git a/app/containers/message/utils.js b/app/containers/message/utils.js index a0a298eb6..383000a41 100644 --- a/app/containers/message/utils.js +++ b/app/containers/message/utils.js @@ -49,6 +49,7 @@ export const SYSTEM_MESSAGES = [ 'room_changed_announcement', 'room_changed_topic', 'room_changed_privacy', + 'room_changed_avatar', 'message_snippeted', 'thread-created' ]; @@ -91,6 +92,8 @@ export const getInfoMessage = ({ return I18n.t('Room_changed_topic', { topic: msg, userBy: username }); } else if (type === 'room_changed_privacy') { return I18n.t('Room_changed_privacy', { type: msg, userBy: username }); + } else if (type === 'room_changed_avatar') { + return I18n.t('Room_changed_avatar', { userBy: username }); } else if (type === 'message_snippeted') { return I18n.t('Created_snippet'); } diff --git a/app/i18n/locales/en.js b/app/i18n/locales/en.js index 18f5f22d4..1221ff9e6 100644 --- a/app/i18n/locales/en.js +++ b/app/i18n/locales/en.js @@ -437,6 +437,7 @@ export default { Roles: 'Roles', Room_actions: 'Room actions', Room_changed_announcement: 'Room announcement changed to: {{announcement}} by {{userBy}}', + Room_changed_avatar: 'Room avatar changed by {{userBy}}', Room_changed_description: 'Room description changed to: {{description}} by {{userBy}}', Room_changed_privacy: 'Room type changed to: {{type}} by {{userBy}}', Room_changed_topic: 'Room topic changed to: {{topic}} by {{userBy}}', diff --git a/app/lib/database/model/Room.js b/app/lib/database/model/Room.js index 32a3a5777..131fbaf0e 100644 --- a/app/lib/database/model/Room.js +++ b/app/lib/database/model/Room.js @@ -25,4 +25,6 @@ export default class Room extends Model { @json('livechat_data', sanitizer) livechatData; @json('tags', sanitizer) tags; + + @field('avatar_etag') avatarETag; } diff --git a/app/lib/database/model/Subscription.js b/app/lib/database/model/Subscription.js index afdd8391f..bc3ac88ab 100644 --- a/app/lib/database/model/Subscription.js +++ b/app/lib/database/model/Subscription.js @@ -115,4 +115,6 @@ export default class Subscription extends Model { @field('encrypted') encrypted; @field('e2e_key_id') e2eKeyId; + + @field('avatar_etag') avatarETag; } diff --git a/app/lib/database/model/migrations.js b/app/lib/database/model/migrations.js index d7fba898a..bcdcf90c9 100644 --- a/app/lib/database/model/migrations.js +++ b/app/lib/database/model/migrations.js @@ -178,6 +178,18 @@ export default schemaMigrations({ { name: 'username', type: 'string', isIndexed: true }, { name: 'avatar_etag', type: 'string', isOptional: true } ] + }), + addColumns({ + table: 'subscriptions', + columns: [ + { name: 'avatar_etag', type: 'string', isOptional: true } + ] + }), + addColumns({ + table: 'rooms', + columns: [ + { name: 'avatar_etag', type: 'string', isOptional: true } + ] }) ] } diff --git a/app/lib/database/schema/app.js b/app/lib/database/schema/app.js index c579ba6ba..cbe886707 100644 --- a/app/lib/database/schema/app.js +++ b/app/lib/database/schema/app.js @@ -52,7 +52,8 @@ export default appSchema({ { name: 'tags', type: 'string', isOptional: true }, { name: 'e2e_key', type: 'string', isOptional: true }, { name: 'encrypted', type: 'boolean', isOptional: true }, - { name: 'e2e_key_id', type: 'string', isOptional: true } + { name: 'e2e_key_id', type: 'string', isOptional: true }, + { name: 'avatar_etag', type: 'string', isOptional: true } ] }), tableSchema({ @@ -67,7 +68,8 @@ export default appSchema({ { name: 'served_by', type: 'string', isOptional: true }, { name: 'livechat_data', type: 'string', isOptional: true }, { name: 'tags', type: 'string', isOptional: true }, - { name: 'e2e_key_id', type: 'string', isOptional: true } + { name: 'e2e_key_id', type: 'string', isOptional: true }, + { name: 'avatar_etag', type: 'string', isOptional: true } ] }), tableSchema({ diff --git a/app/lib/methods/helpers/findSubscriptionsRooms.js b/app/lib/methods/helpers/findSubscriptionsRooms.js index fb3bc6d33..f1714932d 100644 --- a/app/lib/methods/helpers/findSubscriptionsRooms.js +++ b/app/lib/methods/helpers/findSubscriptionsRooms.js @@ -52,7 +52,8 @@ export default async(subscriptions = [], rooms = []) => { tags: s.tags, encrypted: s.encrypted, e2eKeyId: s.e2eKeyId, - E2EKey: s.E2EKey + E2EKey: s.E2EKey, + avatarETag: s.avatarETag })); subscriptions = subscriptions.concat(existingSubs); @@ -80,7 +81,8 @@ export default async(subscriptions = [], rooms = []) => { livechatData: r.livechatData, tags: r.tags, encrypted: r.encrypted, - e2eKeyId: r.e2eKeyId + e2eKeyId: r.e2eKeyId, + avatarETag: r.avatarETag })); rooms = rooms.concat(existingRooms); } catch { diff --git a/app/lib/methods/helpers/mergeSubscriptionsRooms.js b/app/lib/methods/helpers/mergeSubscriptionsRooms.js index c529102f2..5fbb1a743 100644 --- a/app/lib/methods/helpers/mergeSubscriptionsRooms.js +++ b/app/lib/methods/helpers/mergeSubscriptionsRooms.js @@ -30,6 +30,7 @@ export const merge = (subscription, room) => { subscription.broadcast = room.broadcast; subscription.encrypted = room.encrypted; subscription.e2eKeyId = room.e2eKeyId; + subscription.avatarETag = room.avatarETag; if (!subscription.roles || !subscription.roles.length) { subscription.roles = []; } diff --git a/app/lib/methods/subscriptions/rooms.js b/app/lib/methods/subscriptions/rooms.js index eee596e86..9039cce13 100644 --- a/app/lib/methods/subscriptions/rooms.js +++ b/app/lib/methods/subscriptions/rooms.js @@ -84,7 +84,8 @@ const createOrUpdateSubscription = async(subscription, room) => { tags: s.tags, encrypted: s.encrypted, e2eKeyId: s.e2eKeyId, - E2EKey: s.E2EKey + E2EKey: s.E2EKey, + avatarETag: s.avatarETag }; } catch (error) { try { @@ -116,7 +117,8 @@ const createOrUpdateSubscription = async(subscription, room) => { broadcast: r.broadcast, customFields: r.customFields, departmentId: r.departmentId, - livechatData: r.livechatData + livechatData: r.livechatData, + avatarETag: r.avatarETag }; } catch (error) { // Do nothing diff --git a/app/lib/rocketchat.js b/app/lib/rocketchat.js index fc10267ed..76bd5e1dc 100644 --- a/app/lib/rocketchat.js +++ b/app/lib/rocketchat.js @@ -579,6 +579,7 @@ const RocketChat = { rid: sub.rid, name: sub.name, fname: sub.fname, + avatarETag: sub.avatarETag, t: sub.t, search: true }); diff --git a/app/presentation/DirectoryItem/index.js b/app/presentation/DirectoryItem/index.js index c8f41202f..75b944c5e 100644 --- a/app/presentation/DirectoryItem/index.js +++ b/app/presentation/DirectoryItem/index.js @@ -18,7 +18,7 @@ const DirectoryItemLabel = React.memo(({ text, theme }) => { }); const DirectoryItem = ({ - title, description, avatar, onPress, testID, style, rightLabel, type, theme + title, description, avatar, onPress, testID, style, rightLabel, type, rid, theme }) => ( @@ -54,6 +55,7 @@ DirectoryItem.propTypes = { testID: PropTypes.string.isRequired, style: PropTypes.any, rightLabel: PropTypes.string, + rid: PropTypes.string, theme: PropTypes.string }; diff --git a/app/presentation/RoomItem/RoomItem.js b/app/presentation/RoomItem/RoomItem.js index 9acb0284b..da595d883 100644 --- a/app/presentation/RoomItem/RoomItem.js +++ b/app/presentation/RoomItem/RoomItem.js @@ -73,6 +73,7 @@ const RoomItem = ({ userId={userId} token={token} theme={theme} + rid={rid} > {showLastMessage ? ( diff --git a/app/presentation/RoomItem/Wrapper.js b/app/presentation/RoomItem/Wrapper.js index c6638555a..2a0d17bc8 100644 --- a/app/presentation/RoomItem/Wrapper.js +++ b/app/presentation/RoomItem/Wrapper.js @@ -16,6 +16,7 @@ const Wrapper = ({ userId, token, theme, + rid, children }) => ( { + const { rid } = item; + + let record; + if (this.isDirect) { + record = await usersCollection.find(id); + } else { + record = await subsCollection.find(rid); + } + + if (record) { + const observable = record.observe(); + this.avatarSubscription = observable.subscribe((u) => { const { avatarETag } = u; if (this.mounted) { this.setState({ avatarETag }); @@ -130,9 +139,9 @@ class RoomItemContainer extends React.Component { this.state.avatarETag = avatarETag; } }); - } catch { - // User not found } + } catch { + // Record not found } } @@ -220,7 +229,7 @@ class RoomItemContainer extends React.Component { useRealName={useRealName} unread={item.unread} groupMentions={item.groupMentions} - avatarETag={avatarETag} + avatarETag={avatarETag || item.avatarETag} swipeEnabled={swipeEnabled} /> ); diff --git a/app/utils/avatar.js b/app/utils/avatar.js index 369e1914f..0cdc8e577 100644 --- a/app/utils/avatar.js +++ b/app/utils/avatar.js @@ -1,12 +1,18 @@ const formatUrl = (url, size, query) => `${ url }?format=png&size=${ size }${ query }`; export const avatarURL = ({ - type, text, size, user = {}, avatar, server, avatarETag + type, text, size, user = {}, avatar, server, avatarETag, rid }) => { - const room = type === 'd' ? text : `@${ text }`; + let room; + if (type === 'd') { + room = text; + } else if (rid) { + room = `room/${ rid }`; + } else { + room = `@${ text }`; + } - // Avoid requesting several sizes by having only two sizes on cache - const uriSize = size === 100 ? 100 : 50; + const uriSize = size > 100 ? size : 100; const { id, token } = user; let query = ''; diff --git a/app/views/CreateDiscussionView/SelectChannel.js b/app/views/CreateDiscussionView/SelectChannel.js index 0a47a086b..fa7110b94 100644 --- a/app/views/CreateDiscussionView/SelectChannel.js +++ b/app/views/CreateDiscussionView/SelectChannel.js @@ -25,8 +25,13 @@ const SelectChannel = ({ } }, 300); - const getAvatar = (text, type) => avatarURL({ - text, type, user: { id: userId, token }, server + const getAvatar = item => avatarURL({ + text: RocketChat.getRoomAvatar(item), + type: item.t, + user: { id: userId, token }, + server, + avatarETag: item.avatarETag, + rid: item.rid }); return ( @@ -42,7 +47,7 @@ const SelectChannel = ({ options={channels.map(channel => ({ value: channel.rid, text: { text: RocketChat.getRoomTitle(channel) }, - imageUrl: getAvatar(RocketChat.getRoomAvatar(channel), channel.t) + imageUrl: getAvatar(channel) }))} onClose={() => setChannels([])} placeholder={{ text: `${ I18n.t('Select_a_Channel') }...` }} diff --git a/app/views/DirectoryView/index.js b/app/views/DirectoryView/index.js index 6a02092af..e21d2e9d1 100644 --- a/app/views/DirectoryView/index.js +++ b/app/views/DirectoryView/index.js @@ -205,7 +205,8 @@ class DirectoryView extends React.Component { testID: `federation-view-item-${ item.name }`, style, user, - theme + theme, + rid: item._id }; if (type === 'users') { diff --git a/app/views/RoomActionsView/index.js b/app/views/RoomActionsView/index.js index df4c0c0c5..70dce573c 100644 --- a/app/views/RoomActionsView/index.js +++ b/app/views/RoomActionsView/index.js @@ -669,7 +669,12 @@ class RoomActionsView extends React.Component { renderRoomInfo = ({ item }) => { const { room, member } = this.state; - const { name, t, topic } = room; + const { + name, + t, + rid, + topic + } = room; const { theme } = this.props; const avatar = RocketChat.getRoomAvatar(room); @@ -682,6 +687,7 @@ class RoomActionsView extends React.Component { style={styles.avatar} size={50} type={t} + rid={rid} > {t === 'd' && member._id ? : null } diff --git a/app/views/RoomInfoEditView/index.js b/app/views/RoomInfoEditView/index.js index 8b4ffd6a8..92abe031e 100644 --- a/app/views/RoomInfoEditView/index.js +++ b/app/views/RoomInfoEditView/index.js @@ -6,7 +6,9 @@ import { import { connect } from 'react-redux'; import equal from 'deep-equal'; import { BLOCK_CONTEXT } from '@rocket.chat/ui-kit'; +import ImagePicker from 'react-native-image-crop-picker'; import isEqual from 'lodash/isEqual'; +import isEmpty from 'lodash/isEmpty'; import semver from 'semver'; import database from '../../lib/database'; @@ -31,6 +33,8 @@ import { withTheme } from '../../theme'; import { MultiSelect } from '../../containers/UIKit/MultiSelect'; import { MessageTypeValues } from '../../utils/messageTypes'; import SafeAreaView from '../../containers/SafeAreaView'; +import Avatar from '../../containers/Avatar'; +import { CustomIcon } from '../../lib/Icons'; const PERMISSION_SET_READONLY = 'set-readonly'; const PERMISSION_SET_REACT_WHEN_READONLY = 'set-react-when-readonly'; @@ -64,6 +68,7 @@ class RoomInfoEditView extends React.Component { super(props); this.state = { room: {}, + avatar: {}, permissions: {}, name: '', description: '', @@ -136,6 +141,7 @@ class RoomInfoEditView extends React.Component { topic, announcement, t: t === 'p', + avatar: {}, ro, reactWhenReadOnly, joinCode: joinCodeRequired ? this.randomValue : '', @@ -160,7 +166,7 @@ class RoomInfoEditView extends React.Component { formIsChanged = () => { const { - room, name, description, topic, announcement, t, ro, reactWhenReadOnly, joinCode, systemMessages, enableSysMes, encrypted + room, name, description, topic, announcement, t, ro, reactWhenReadOnly, joinCode, systemMessages, enableSysMes, encrypted, avatar } = this.state; const { joinCodeRequired } = room; return !(room.name === name @@ -174,6 +180,7 @@ class RoomInfoEditView extends React.Component { && isEqual(room.sysMes, systemMessages) && enableSysMes === (room.sysMes && room.sysMes.length > 0) && room.encrypted === encrypted + && isEmpty(avatar) ); } @@ -181,7 +188,7 @@ class RoomInfoEditView extends React.Component { logEvent(events.RI_EDIT_SAVE); Keyboard.dismiss(); const { - room, name, description, topic, announcement, t, ro, reactWhenReadOnly, joinCode, systemMessages, encrypted + room, name, description, topic, announcement, t, ro, reactWhenReadOnly, joinCode, systemMessages, encrypted, avatar } = this.state; this.setState({ saving: true }); @@ -201,6 +208,10 @@ class RoomInfoEditView extends React.Component { if (room.name !== name) { params.roomName = name; } + // Avatar + if (!isEmpty(avatar)) { + params.roomAvatar = avatar.data; + } // Description if (room.description !== description) { params.roomDescription = description; @@ -347,6 +358,28 @@ class RoomInfoEditView extends React.Component { ); } + changeAvatar = async() => { + const options = { + cropping: true, + compressImageQuality: 0.8, + cropperAvoidEmptySpaceAroundImage: false, + cropperChooseText: I18n.t('Choose'), + cropperCancelText: I18n.t('Cancel'), + includeBase64: true + }; + + try { + const response = await ImagePicker.openPicker(options); + this.setState({ avatar: { url: response.path, data: `data:image/jpeg;base64,${ response.data }`, service: 'upload' } }); + } catch (e) { + console.log(e); + } + } + + resetAvatar = () => { + this.setState({ avatar: { data: null } }); + } + toggleRoomType = (value) => { logEvent(events.RI_EDIT_TOGGLE_ROOM_TYPE); this.setState(({ encrypted }) => ({ t: value, encrypted: value && encrypted })); @@ -374,7 +407,7 @@ class RoomInfoEditView extends React.Component { render() { const { - name, nameError, description, topic, announcement, t, ro, reactWhenReadOnly, room, joinCode, saving, permissions, archived, enableSysMes, encrypted + name, nameError, description, topic, announcement, t, ro, reactWhenReadOnly, room, joinCode, saving, permissions, archived, enableSysMes, encrypted, avatar } = this.state; const { serverVersion, e2eEnabled, theme } = this.props; const { dangerColor } = themes[theme]; @@ -396,6 +429,20 @@ class RoomInfoEditView extends React.Component { testID='room-info-edit-view-list' {...scrollPersistTaps} > + + + + + + + { this.name = e; }} label={I18n.t('Name')} diff --git a/app/views/RoomInfoEditView/styles.js b/app/views/RoomInfoEditView/styles.js index bf857940f..cff896af5 100644 --- a/app/views/RoomInfoEditView/styles.js +++ b/app/views/RoomInfoEditView/styles.js @@ -72,5 +72,17 @@ export default StyleSheet.create({ }, switchMargin: { marginBottom: 16 + }, + avatarContainer: { + alignItems: 'center', + justifyContent: 'center', + marginBottom: 10 + }, + resetButton: { + padding: 4, + borderRadius: 4, + position: 'absolute', + bottom: -8, + right: -8 } }); diff --git a/app/views/RoomInfoView/index.js b/app/views/RoomInfoView/index.js index 194f2e64a..34960a0bd 100644 --- a/app/views/RoomInfoView/index.js +++ b/app/views/RoomInfoView/index.js @@ -289,6 +289,7 @@ class RoomInfoView extends React.Component { style={styles.avatar} type={this.t} size={100} + rid={room?.rid} > {this.t === 'd' && roomUser._id ? : null} diff --git a/package.json b/package.json index 3b8276f71..fc6425918 100644 --- a/package.json +++ b/package.json @@ -116,7 +116,7 @@ "redux-immutable-state-invariant": "2.1.0", "redux-saga": "1.1.3", "remove-markdown": "^0.3.0", - "reselect": "^4.0.0", + "reselect": "4.0.0", "rn-extensions-share": "^2.4.0", "rn-fetch-blob": "0.12.0", "rn-root-view": "^1.0.3", diff --git a/storybook/stories/Avatar.js b/storybook/stories/Avatar.js index 3a7b195a8..262896f05 100644 --- a/storybook/stories/Avatar.js +++ b/storybook/stories/Avatar.js @@ -35,6 +35,13 @@ const AvatarStories = ({ theme }) => ( server={server} size={56} /> + +