[RN] Add chat functionality

Co-authored-by: DimaG <dgeorgiev06@gmail.com>
This commit is contained in:
Bettenbuk Zoltan 2019-01-13 20:34:38 +01:00 committed by Saúl Ibarra Corretgé
parent 82f714b608
commit 8a241ba2b7
43 changed files with 1015 additions and 199 deletions

View File

@ -124,6 +124,7 @@ android.libraryVariants.all { def variant ->
// Bundle sounds
//
copy {
from("${projectDir}/../../sounds/incomingMessage.wav")
from("${projectDir}/../../sounds/joined.wav")
from("${projectDir}/../../sounds/left.wav")
from("${projectDir}/../../sounds/outgoingRinging.wav")

View File

@ -25,6 +25,9 @@
-moz-osx-font-smoothing: grayscale;
}
.icon-chat-unread:before {
content: "\e0b7";
}
.icon-arrow_back:before {
content: "\e5c4";
}

Binary file not shown.

View File

@ -7,6 +7,7 @@
<font-face units-per-em="1024" ascent="1024" descent="0" />
<missing-glyph horiz-adv-x="1024" />
<glyph unicode="&#x20;" d="" />
<glyph unicode="&#xe0b7;" glyph-name="chat-unread" d="M768 682v86h-512v-86h512zM598 426v86h-342v-86h342zM256 640v-86h512v86h-512zM854 938c46 0 84-38 84-84v-512c0-46-38-86-84-86h-598l-170-170v768c0 46 38 84 84 84h684z" />
<glyph unicode="&#xe0cd;" glyph-name="phone" d="M282 564c62-120 162-220 282-282l94 94c12 12 30 16 44 10 48-16 100-24 152-24 24 0 42-18 42-42v-150c0-24-18-42-42-42-400 0-726 326-726 726 0 24 18 42 42 42h150c24 0 42-18 42-42 0-54 8-104 24-152 4-14 2-32-10-44z" />
<glyph unicode="&#xe145;" glyph-name="add" d="M810 470h-256v-256h-84v256h-256v84h256v256h84v-256h256v-84z" />
<glyph unicode="&#xe1aa;" glyph-name="bluetooth" d="M550 328l-80 82v-162zM470 776v-162l80 82zM670 696l-184-184 184-184-244-242h-42v324l-196-196-60 60 238 238-238 238 60 60 196-196v324h42zM834 738c40-64 62-142 62-222 0-84-24-160-66-226l-50 50c26 52 42 110 42 172s-16 120-42 172zM608 512l98 98c12-30 20-64 20-98s-8-70-20-100z" />

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@ -32,6 +32,7 @@
6C31EDCA20C06D530089C899 /* recordingOff.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 6C31EDC920C06D530089C899 /* recordingOff.mp3 */; };
75635B0A20751D6D00F29C9F /* joined.wav in Resources */ = {isa = PBXBuildFile; fileRef = 75635B0820751D6D00F29C9F /* joined.wav */; };
75635B0B20751D6D00F29C9F /* left.wav in Resources */ = {isa = PBXBuildFile; fileRef = 75635B0920751D6D00F29C9F /* left.wav */; };
87FE6F3321E52437004A5DC7 /* incomingMessage.wav in Resources */ = {isa = PBXBuildFile; fileRef = 87FE6F3221E52437004A5DC7 /* incomingMessage.wav */; };
A4414AE020B37F1A003546E6 /* rejected.wav in Resources */ = {isa = PBXBuildFile; fileRef = A4414ADF20B37F1A003546E6 /* rejected.wav */; };
A4A934E9212F3ADB001E9388 /* Dropbox.m in Sources */ = {isa = PBXBuildFile; fileRef = A4A934E8212F3ADB001E9388 /* Dropbox.m */; };
B386B85720981A75000DEF7A /* InviteController.m in Sources */ = {isa = PBXBuildFile; fileRef = B386B85020981A74000DEF7A /* InviteController.m */; };
@ -85,6 +86,7 @@
6C31EDC920C06D530089C899 /* recordingOff.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; name = recordingOff.mp3; path = ../../sounds/recordingOff.mp3; sourceTree = "<group>"; };
75635B0820751D6D00F29C9F /* joined.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; name = joined.wav; path = ../../sounds/joined.wav; sourceTree = "<group>"; };
75635B0920751D6D00F29C9F /* left.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; name = left.wav; path = ../../sounds/left.wav; sourceTree = "<group>"; };
87FE6F3221E52437004A5DC7 /* incomingMessage.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; name = incomingMessage.wav; path = ../../sounds/incomingMessage.wav; sourceTree = "<group>"; };
98E09B5C73D9036B4ED252FC /* Pods-JitsiMeet.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JitsiMeet.debug.xcconfig"; path = "../Pods/Target Support Files/Pods-JitsiMeet/Pods-JitsiMeet.debug.xcconfig"; sourceTree = "<group>"; };
9C77CA3CC919B081F1A52982 /* Pods-JitsiMeet.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JitsiMeet.release.xcconfig"; path = "../Pods/Target Support Files/Pods-JitsiMeet/Pods-JitsiMeet.release.xcconfig"; sourceTree = "<group>"; };
A4414ADF20B37F1A003546E6 /* rejected.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; name = rejected.wav; path = ../../sounds/rejected.wav; sourceTree = "<group>"; };
@ -125,6 +127,7 @@
0BCA49681EC4BBE500B793EE /* Resources */ = {
isa = PBXGroup;
children = (
87FE6F3221E52437004A5DC7 /* incomingMessage.wav */,
0BC4B8681F8C01E100CE8B21 /* CallKitIcon.png */,
C6245F5B2053091D0040BE68 /* image-resize@2x.png */,
C6245F5C2053091D0040BE68 /* image-resize@3x.png */,
@ -335,6 +338,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
87FE6F3321E52437004A5DC7 /* incomingMessage.wav in Resources */,
0B49424520AD8DBD00BD2DE0 /* outgoingStart.wav in Resources */,
6C31EDCA20C06D530089C899 /* recordingOff.mp3 in Resources */,
A4414AE020B37F1A003546E6 /* rejected.wav in Resources */,

View File

@ -53,8 +53,9 @@
"messagebox": "Enter text...",
"nickname": {
"popover": "Choose a nickname",
"title": "Enter a nickname in the box below"
}
"title": "Enter a nickname to use chat"
},
"title": "Chat"
},
"connection": {
"ATTACHED": "Attached",
@ -642,7 +643,7 @@
"Settings": "Settings",
"sharedvideo": "Share a YouTube video",
"sharedVideoMutedPopup": "Your shared video has been muted so that you can talk to the other members.",
"shareRoom": "Share room",
"shareRoom": "Invite someone",
"shortcuts": "View shortcuts",
"sip": "Call SIP number",
"speakerStats": "Speaker stats",

80
package-lock.json generated
View File

@ -2166,6 +2166,15 @@
}
}
},
"@expo/react-native-action-sheet": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@expo/react-native-action-sheet/-/react-native-action-sheet-1.1.2.tgz",
"integrity": "sha512-//2EvHVBFVGSAzuJvG0I1UoQVzGJBo2f1CkO+RMnEWdR0FeWYmV7+pCThIroL1czRm/oOtoMxiGS6FgXt6QgVA==",
"requires": {
"hoist-non-react-statics": "^2.2.2",
"prop-types": "^15.5.10"
}
},
"@jitsi/sdp-interop": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/@jitsi/sdp-interop/-/sdp-interop-0.1.13.tgz",
@ -2193,7 +2202,7 @@
},
"@segment/top-domain": {
"version": "3.0.0",
"resolved": "http://registry.npmjs.org/@segment/top-domain/-/top-domain-3.0.0.tgz",
"resolved": "https://registry.npmjs.org/@segment/top-domain/-/top-domain-3.0.0.tgz",
"integrity": "sha1-AuWlpP1CqfbPiGsF6C8QQBKjw6c=",
"requires": {
"component-cookie": "^1.1.2",
@ -3142,6 +3151,14 @@
"util.promisify": "^1.0.0"
}
},
"babel-plugin-check-es2015-constants": {
"version": "6.22.0",
"resolved": "https://registry.npmjs.org/babel-plugin-check-es2015-constants/-/babel-plugin-check-es2015-constants-6.22.0.tgz",
"integrity": "sha1-NRV7EBQm/S/9PaP3XH0ekYNbv4o=",
"requires": {
"babel-runtime": "^6.22.0"
}
},
"babel-plugin-syntax-trailing-function-commas": {
"version": "7.0.0-beta.0",
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-trailing-function-commas/-/babel-plugin-syntax-trailing-function-commas-7.0.0-beta.0.tgz",
@ -4112,7 +4129,7 @@
"dependencies": {
"debug": {
"version": "2.2.0",
"resolved": "http://registry.npmjs.org/debug/-/debug-2.2.0.tgz",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz",
"integrity": "sha1-+HBX6ZWxofauaklgZkE3vFbwOdo=",
"requires": {
"ms": "0.7.1"
@ -4120,7 +4137,7 @@
},
"ms": {
"version": "0.7.1",
"resolved": "http://registry.npmjs.org/ms/-/ms-0.7.1.tgz",
"resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz",
"integrity": "sha1-nNE8A62/8ltl7/3nzoZO6VIBcJg="
}
}
@ -8368,6 +8385,11 @@
"resolved": "https://registry.npmjs.org/keycode/-/keycode-2.1.9.tgz",
"integrity": "sha1-lkojxU5IiUBbSGGlyfBIDUUUHfo="
},
"keymirror": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/keymirror/-/keymirror-0.1.1.tgz",
"integrity": "sha1-kYiJ6hP40KQufFVyUO7nE63JXDU="
},
"killable": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz",
@ -11444,11 +11466,37 @@
"jssha": "^2.2.0"
}
},
"react-native-communications": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/react-native-communications/-/react-native-communications-2.2.1.tgz",
"integrity": "sha1-eIO1ayCgAu63kMET+GFuqGksp5U="
},
"react-native-fast-image": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/react-native-fast-image/-/react-native-fast-image-5.1.1.tgz",
"integrity": "sha512-kEzgZxbbXYhy27u5GnhrKitn+XDBFAHSDUJdYC6llMi5cDPjgcqhOAQABj0K+ga5pn+/xPZLmD882rrUGiwVVA=="
},
"react-native-gifted-chat": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/react-native-gifted-chat/-/react-native-gifted-chat-0.6.0.tgz",
"integrity": "sha512-KYI/okKUZmjcJM3I6BP10KG1WNkCKBZhY8N47wk407dr+KqLS4+LR13UKo7j3f++5SrX2Ex+7vYvIQ2pBdzCiA==",
"requires": {
"@expo/react-native-action-sheet": "^1.0.1",
"moment": "^2.19.0",
"react-native-communications": "2.2.1",
"react-native-lightbox": "^0.7.0",
"react-native-parsed-text": "^0.0.20",
"react-native-video": "^3.2.1",
"uuid": "3.3.0"
},
"dependencies": {
"uuid": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.0.tgz",
"integrity": "sha512-ijO9N2xY/YaOqQ5yz5c4sy2ZjWmA6AR6zASb/gdpeKZ8+948CxwfMW9RrKVk5may6ev8c0/Xguu32e2Llelpqw=="
}
}
},
"react-native-google-signin": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/react-native-google-signin/-/react-native-google-signin-1.0.2.tgz",
@ -11464,11 +11512,28 @@
"resolved": "https://registry.npmjs.org/react-native-keep-awake/-/react-native-keep-awake-4.0.0.tgz",
"integrity": "sha512-0Fotox+eLXQooeibVs3P60yASYUWjtRw9MZNmbuHt5UZQrgUrAKsE4jm7gTr4tPU1m1RkwGzcgUFpcOkh/ec7g=="
},
"react-native-lightbox": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/react-native-lightbox/-/react-native-lightbox-0.7.0.tgz",
"integrity": "sha512-HS3T4WlCd0Gb3us2d6Jse5m6KjNhngnKm35Wapq30WtQa9s+/VMmtuktbGPGaWtswcDyOj6qByeJBw9W80iPCA==",
"requires": {
"prop-types": "^15.5.10"
}
},
"react-native-linear-gradient": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/react-native-linear-gradient/-/react-native-linear-gradient-2.5.3.tgz",
"integrity": "sha512-XdusrOXXlkI+yQpUW7YLeiq9cZiBwkvQX4XEkHPVrJ9H47gsKmdgBwObkZBzBQUP0dKK/Sg6aVpETEis4w43bQ=="
},
"react-native-parsed-text": {
"version": "0.0.20",
"resolved": "https://registry.npmjs.org/react-native-parsed-text/-/react-native-parsed-text-0.0.20.tgz",
"integrity": "sha512-n77hYu64Tr3oclzIXBXXaiLh1WbMKdA2Y0x6bX/yqwxAM4afcObENY5VrNB+EsTBJBEDqrypA9D1p2cLEIHkuQ==",
"requires": {
"babel-plugin-check-es2015-constants": "6.22.0",
"prop-types": "^15.5.10"
}
},
"react-native-sound": {
"version": "github:jitsi/react-native-sound#e4260ed7f641eeb0377d76eac7987aba72e1cf08",
"from": "github:jitsi/react-native-sound#e4260ed7f641eeb0377d76eac7987aba72e1cf08"
@ -11524,6 +11589,15 @@
}
}
},
"react-native-video": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/react-native-video/-/react-native-video-3.2.1.tgz",
"integrity": "sha512-Xansfoo/to80FwhM1HKlf7pCxDZ5RtV+kG3piCVvsNAhPY4GGwiOGUH9y3Y+mFQIDEWcY8I9j16lsFYAbnue3g==",
"requires": {
"keymirror": "0.1.1",
"prop-types": "^15.5.10"
}
},
"react-native-webrtc": {
"version": "github:jitsi/react-native-webrtc#c1be0cb1c6e8a83dfd406e478082a5ff205a97ec",
"from": "github:jitsi/react-native-webrtc#c1be0cb1c6e8a83dfd406e478082a5ff205a97ec",

View File

@ -66,6 +66,7 @@
"react-native-calendar-events": "1.6.4",
"react-native-callstats": "3.53.4",
"react-native-fast-image": "5.1.1",
"react-native-gifted-chat": "0.6.0",
"react-native-google-signin": "1.0.2",
"react-native-immersive": "2.0.0",
"react-native-keep-awake": "4.0.0",

View File

@ -1,3 +1,5 @@
// @flow
/**
* The type of the action which signals to add a new chat message.
*
@ -14,7 +16,7 @@
export const ADD_MESSAGE = Symbol('ADD_MESSAGE');
/**
* The type of the action which signals to remove all saved chat messages.
* The type of the action which signals to clear messages in Redux.
*
* {
* type: CLEAR_MESSAGES

View File

@ -1,3 +1,5 @@
// @flow
import {
ADD_MESSAGE,
CLEAR_MESSAGES,
@ -27,7 +29,7 @@ import {
* timestamp: string,
* }}
*/
export function addMessage(messageDetails) {
export function addMessage(messageDetails: Object) {
return {
type: ADD_MESSAGE,
...messageDetails
@ -35,7 +37,7 @@ export function addMessage(messageDetails) {
}
/**
* Removes all stored chat messages.
* Clears the chat messages in Redux.
*
* @returns {{
* type: CLEAR_MESSAGES
@ -56,7 +58,7 @@ export function clearMessages() {
* message: string
* }}
*/
export function sendMessage(message) {
export function sendMessage(message: string) {
return {
type: SEND_MESSAGE,
message

View File

@ -0,0 +1,113 @@
// @flow
import { Component } from 'react';
import { getLocalParticipant } from '../../base/participants';
import { sendMessage, toggleChat } from '../actions';
/**
* The type of the React {@code Component} props of {@code AbstractChat}.
*/
export type Props = {
/**
* True if the chat window should be rendered.
*/
_isOpen: boolean,
/**
* All the chat messages in the conference.
*/
_messages: Array<Object>,
/**
* Function to send a text message.
*
* @protected
*/
_onSendMessage: Function,
/**
* Function to toggle the chat window.
*/
_onToggleChat: Function,
/**
* Whether or not to block chat access with a nickname input form.
*/
_showNamePrompt: boolean,
/**
* The Redux dispatch function.
*/
dispatch: Dispatch<*>,
/**
* Function to be used to translate i18n labels.
*/
t: Function
};
/**
* Implements an abstract chat panel.
*/
export default class AbstractChat<P: Props> extends Component<P> {}
/**
* Maps redux actions to the props of the component.
*
* @param {Function} dispatch - The redux action {@code dispatch} function.
* @returns {{
* _onSendMessage: Function,
* _onToggleChat: Function
* }}
* @private
*/
export function _mapDispatchToProps(dispatch: Dispatch<*>) {
return {
/**
* Toggles the chat window.
*
* @returns {Function}
*/
_onToggleChat() {
dispatch(toggleChat());
},
/**
* Sends a text message.
*
* @private
* @param {string} text - The text message to be sent.
* @returns {void}
* @type {Function}
*/
_onSendMessage(text: string) {
dispatch(sendMessage(text));
}
};
}
/**
* Maps (parts of) the redux state to {@link Chat} React {@code Component}
* props.
*
* @param {Object} state - The redux store/state.
* @private
* @returns {{
* _isOpen: boolean,
* _messages: Array<Object>,
* _showNamePrompt: boolean
* }}
*/
export function _mapStateToProps(state: Object) {
const { isOpen, messages } = state['features/chat'];
const _localParticipant = getLocalParticipant(state);
return {
_isOpen: isOpen,
_messages: messages,
_showNamePrompt: !_localParticipant.name
};
}

View File

@ -0,0 +1,48 @@
// @flow
import { PureComponent } from 'react';
import { getAvatarURLByParticipantId } from '../../base/participants';
/**
* The type of the React {@code Component} props of {@code AbstractChatMessage}.
*/
export type Props = {
/**
* The URL of the avatar of the participant.
*/
_avatarURL: string,
/**
* The representation of a chat message.
*/
message: Object,
/**
* Invoked to receive translated strings.
*/
t: Function
};
/**
* Abstract component to display a chat message.
*/
export default class AbstractChatMessage<P: Props> extends PureComponent<P> {}
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @param {Props} ownProps - The own props of the component.
* @returns {{
* _avatarURL: string
* }}
*/
export function _mapStateToProps(state: Object, ownProps: Props) {
const { message } = ownProps;
return {
_avatarURL: getAvatarURLByParticipantId(state, message.user._id)
};
}

View File

@ -1,2 +0,0 @@
export Chat from './Chat';
export ChatCounter from './ChatCounter';

View File

@ -0,0 +1,3 @@
// @flow
export * from './native';

View File

@ -0,0 +1,3 @@
// @flow
export * from './web';

View File

@ -0,0 +1,168 @@
// @flow
import React from 'react';
import { SafeAreaView } from 'react-native';
import { GiftedChat } from 'react-native-gifted-chat';
import { connect } from 'react-redux';
import { translate } from '../../../base/i18n';
import { BackButton, Header, HeaderLabel, Modal } from '../../../base/react';
import AbstractChat, {
_mapDispatchToProps,
_mapStateToProps as _abstractMapStateToProps,
type Props as AbstractProps
} from '../AbstractChat';
import ChatMessage from './ChatMessage';
import styles from './styles';
type Props = AbstractProps & {
/**
* True if the chat window should have a solid BG render.
*/
_solidBackground: boolean
}
/**
* Implements a React native component that renders the chat window (modal) of
* the mobile client.
*/
class Chat extends AbstractChat<Props> {
/**
* Initializes a new instance.
*
* @inheritdoc
*/
constructor(props: Props) {
super(props);
this._onSend = this._onSend.bind(this);
this._renderMessage = this._renderMessage.bind(this);
this._transformMessage = this._transformMessage.bind(this);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
*/
render() {
const messages = this.props._messages.map(this._transformMessage);
const modalStyle = [
styles.modalBackdrop
];
if (this.props._solidBackground) {
// We only use a transparent background, when we are in a video
// meeting to give a user a glympse of what's happening. Otherwise
// we use a non-transparent background.
modalStyle.push(styles.solidModalBackdrop);
}
return (
<Modal
onRequestClose = { this.props._onToggleChat }
visible = { this.props._isOpen }>
<Header>
<BackButton onPress = { this.props._onToggleChat } />
<HeaderLabel labelKey = 'chat.title' />
</Header>
<SafeAreaView style = { modalStyle }>
<GiftedChat
messages = { messages }
onSend = { this._onSend }
renderMessage = { this._renderMessage } />
</SafeAreaView>
</Modal>
);
}
_onSend: (Array<Object>) => void;
/**
* Callback to trigger a message send action.
*
* @param {string} message - The chat message to display.
* @returns {void}
*/
_onSend([ message ]) {
this.props._onSendMessage(message.text);
}
_renderMessage: Object => React$Element<*>
/**
* Renders a single message.
*
* @param {Object} messageProps - The message props object to be rendered.
* @returns {React$Element<*>}
*/
_renderMessage(messageProps) {
const { currentMessage } = messageProps;
return (
<ChatMessage message = { currentMessage } />
);
}
_transformMessage: (Object, number) => Object;
/**
* Transforms a Jitsi message object to a format that gifted-chat can
* handle.
*
* @param {Object} message - The chat message in our internal format.
* @param {number} index - The index of the message in the array.
* @returns {Object}
*/
_transformMessage(message, index) {
const system = message.messageType === 'error';
return (
{
_id: index,
createdAt: new Date(message.timestamp),
messageType: message.messageType,
system,
text: system
? this.props.t('chat.error', {
error: message.error,
originalText: message.message
})
: message.message,
user: {
_id: message.id,
name: message.displayName
}
}
);
}
}
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @returns {{
* _solidBackground: boolean
* }}
*/
function _mapStateToProps(state) {
const abstractReduxProps = _abstractMapStateToProps(state);
return {
...abstractReduxProps,
// Gifted chat requires the messages to be reverse ordered.
_messages: [
...abstractReduxProps._messages
].reverse(),
_solidBackground: state['features/base/conference'].audioOnly
};
}
export default translate(connect(_mapStateToProps, _mapDispatchToProps)(Chat));

View File

@ -0,0 +1,129 @@
// @flow
import { connect } from 'react-redux';
import { getLocalParticipant } from '../../../base/participants';
import {
AbstractButton,
type AbstractButtonProps
} from '../../../base/toolbox';
import { openDisplayNamePrompt } from '../../../display-name';
import { toggleChat } from '../../actions';
import { getUnreadCount } from '../../functions';
type Props = AbstractButtonProps & {
/**
* Function to display chat.
*
* @protected
*/
_displayChat: Function,
/**
* Function to diaply the name prompt before displaying the chat
* window, if the user has no display name set.
*/
_displayNameInputDialog: Function,
/**
* Whether or not to block chat access with a nickname input form.
*/
_showNamePrompt: boolean,
/**
* The unread message count.
*/
_unreadMessageCount: number
};
/**
* Implements an {@link AbstractButton} to open the chat screen on mobile.
*/
class ChatButton extends AbstractButton<Props, *> {
accessibilityLabel = 'toolbar.accessibilityLabel.chat';
iconName = 'chat';
label = 'toolbar.chat';
toggledIconName = 'chat-unread';
/**
* Handles clicking / pressing the button, and opens the appropriate dialog.
*
* @private
* @returns {void}
*/
_handleClick() {
if (this.props._showNamePrompt) {
this.props._displayNameInputDialog(() => {
this.props._displayChat();
});
} else {
this.props._displayChat();
}
}
/**
* Renders the button toggled when there are unread messages.
*
* @protected
* @returns {boolean}
*/
_isToggled() {
return Boolean(this.props._unreadMessageCount);
}
}
/**
* Maps redux actions to the props of the component.
*
* @param {Function} dispatch - The redux action {@code dispatch} function.
* @returns {{
* _displayChat,
* _displayNameInputDialog
* }}
* @private
*/
function _mapDispatchToProps(dispatch: Function) {
return {
/**
* Launches native invite dialog.
*
* @private
* @returns {void}
*/
_displayChat() {
dispatch(toggleChat());
},
/**
* Displays a diaply name prompt.
*
* @param {Function} onPostSubmit - The function to invoke after a
* succesfulsetting of the display name.
* @returns {void}
*/
_displayNameInputDialog(onPostSubmit) {
dispatch(openDisplayNamePrompt(onPostSubmit));
}
};
}
/**
* Maps part of the redux state to the component's props.
*
* @param {Object} state - The Redux state.
* @returns {{
* _unreadMessageCount
* }}
*/
function _mapStateToProps(state) {
const localParticipant = getLocalParticipant(state);
return {
_showNamePrompt: !localParticipant.name,
_unreadMessageCount: getUnreadCount(state)
};
}
export default connect(_mapStateToProps, _mapDispatchToProps)(ChatButton);

View File

@ -0,0 +1,152 @@
// @flow
import React from 'react';
import { Text, View } from 'react-native';
import { connect } from 'react-redux';
import { getLocalizedDateFormatter, translate } from '../../../base/i18n';
import { Avatar } from '../../../base/participants';
import AbstractChatMessage, {
_mapStateToProps as _abstractMapStateToProps,
type Props as AbstractProps
} from '../AbstractChatMessage';
import styles from './styles';
/**
* Size of the rendered avatar in the message.
*/
const AVATAR_SIZE = 32;
/**
* Formatter string to display the message timestamp.
*/
const TIMESTAMP_FORMAT = 'H:mm';
type Props = AbstractProps & {
/**
* True if the chat window has a solid BG so then we have to adopt in style.
*/
_solidBackground: boolean
}
/**
* Renders a single chat message.
*/
class ChatMessage extends AbstractChatMessage<Props> {
/**
* Implements {@code Component#render}.
*
* @inheritdoc
*/
render() {
const { message } = this.props;
const timeStamp = getLocalizedDateFormatter(
message.createdAt).format(TIMESTAMP_FORMAT);
const localMessage = message.messageType === 'local';
// Style arrays that need to be updated in various scenarios, such as
// error messages or others.
const detailsWrapperStyle = [
styles.detailsWrapper
];
const textWrapperStyle = [
styles.textWrapper
];
const timeTextStyles = [
styles.timeText
];
if (localMessage) {
// The wrapper needs to be aligned to the right.
detailsWrapperStyle.push(styles.ownMessageDetailsWrapper);
// The bubble needs to be differently styled.
textWrapperStyle.push(styles.ownTextWrapper);
} else if (message.system) {
// The bubble needs to be differently styled.
textWrapperStyle.push(styles.systemTextWrapper);
}
if (this.props._solidBackground) {
timeTextStyles.push(styles.solidBGTimeText);
}
return (
<View style = { styles.messageWrapper } >
{
// Avatar is only rendered for remote messages.
!localMessage && this._renderAvatar()
}
<View style = { detailsWrapperStyle }>
<View style = { textWrapperStyle } >
{
// Display name is only rendered for remote
// messages.
!localMessage && this._renderDisplayName()
}
<Text style = { styles.messageText }>
{ message.text }
</Text>
</View>
<Text style = { timeTextStyles }>
{ timeStamp }
</Text>
</View>
</View>
);
}
/**
* Renders the avatar of the sender.
*
* @returns {React$Element<*>}
*/
_renderAvatar() {
const { _avatarURL } = this.props;
return (
<View style = { styles.avatarWrapper }>
<Avatar
size = { AVATAR_SIZE }
uri = { _avatarURL } />
</View>
);
}
/**
* Renders the display name of the sender.
*
* @returns {React$Element<*>}
*/
_renderDisplayName() {
const { message } = this.props;
return (
<Text style = { styles.displayName }>
{ message.user.name }
</Text>
);
}
}
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @param {Props} ownProps - The own props of the component.
* @returns {{
* _solidBackground: boolean
* }}
*/
function _mapStateToProps(state, ownProps) {
return {
..._abstractMapStateToProps(state, ownProps),
_solidBackground: state['features/base/conference'].audioOnly
};
}
export default translate(connect(_mapStateToProps)(ChatMessage));

View File

@ -0,0 +1,4 @@
// @flow
export { default as Chat } from './Chat';
export { default as ChatButton } from './ChatButton';

View File

@ -0,0 +1,124 @@
// @flow
import {
ColorPalette,
createStyleSheet
} from '../../../base/styles';
/**
* The styles of the feature chat.
*
* NOTE: Sizes and colors come from the 8x8 guidelines. This is the first
* component to receive this treating, if others happen to have similar, we
* need to extract the brand colors and sizes into a branding feature (planned
* for the future).
*/
export default createStyleSheet({
/**
* Wrapper View for the avatar.
*/
avatarWrapper: {
marginRight: 8
},
/**
* Wrapper for the details together, such as name, message and time.
*/
detailsWrapper: {
alignItems: 'flex-start',
flex: 1,
flexDirection: 'column'
},
/**
* The text node for the display name.
*/
displayName: {
color: 'rgb(118, 136, 152)',
fontSize: 13
},
/**
* The message text itself.
*/
messageText: {
color: 'rgb(28, 32, 37)',
fontSize: 15
},
/**
* Wrapper View for the entire block.
*/
messageWrapper: {
alignItems: 'flex-start',
flex: 1,
flexDirection: 'row',
marginHorizontal: 17,
marginVertical: 4
},
/**
* Background of the chat screen. Currently it's set to a transparent value
* as the idea is that the participant would still want to see at least a
* part of the video when he/she is in the chat window.
*/
modalBackdrop: {
backgroundColor: 'rgba(127, 127, 127, 0.8)',
flex: 1
},
/**
* Style modifier for the {@code detailsWrapper} for own messages.
*/
ownMessageDetailsWrapper: {
alignItems: 'flex-end'
},
/**
* Style modifier for the {@code textWrapper} for own messages.
*/
ownTextWrapper: {
backgroundColor: 'rgb(210, 231, 249)',
borderTopLeftRadius: 8,
borderTopRightRadius: 0
},
solidBGTimeText: {
color: 'rgb(164, 184, 209)'
},
/**
* Style modifier for the chat window when we're in audio only mode.
*/
solidModalBackdrop: {
backgroundColor: ColorPalette.white
},
/**
* Style modifier for system (error) messages.
*/
systemTextWrapper: {
backgroundColor: 'rgb(247, 215, 215)'
},
/**
* Wrapper for the name and the message text.
*/
textWrapper: {
alignItems: 'flex-start',
backgroundColor: 'rgb(240, 243, 247)',
borderRadius: 8,
borderTopLeftRadius: 0,
flexDirection: 'column',
padding: 9
},
/**
* Text node for the timestamp.
*/
timeText: {
color: ColorPalette.white,
fontSize: 13
}
});

View File

@ -1,79 +1,25 @@
// @flow
import React, { Component } from 'react';
import React from 'react';
import { connect } from 'react-redux';
import Transition from 'react-transition-group/Transition';
import { translate } from '../../base/i18n';
import { getLocalParticipant } from '../../base/participants';
import { toggleChat } from '../actions';
import { translate } from '../../../base/i18n';
import AbstractChat, {
_mapDispatchToProps,
_mapStateToProps,
type Props
} from '../AbstractChat';
import ChatInput from './ChatInput';
import ChatMessage from './ChatMessage';
import DisplayNameForm from './DisplayNameForm';
/**
* The type of the React {@code Component} props of {@link Chat}.
*/
type Props = {
/**
* The JitsiConference instance to send messages to.
*/
_conference: Object,
/**
* Whether or not chat is displayed.
*/
_isOpen: Boolean,
/**
* The local participant's ID.
*/
_localUserId: String,
/**
* All the chat messages in the conference.
*/
_messages: Array<Object>,
/**
* Whether or not to block chat access with a nickname input form.
*/
_showNamePrompt: boolean,
/**
* Invoked to change the chat panel status.
*/
dispatch: Dispatch<*>
};
/**
* The type of the React {@code Component} state of {@Chat}.
*/
type State = {
/**
* User provided nickname when the input text is provided in the view.
*
* @type {String}
*/
message: string
};
/**
* React Component for holding the chat feature in a side panel that slides in
* and out of view.
*
* @extends Component
*/
class Chat extends Component<Props, State> {
/**
* Reference to the HTML element used for typing in a chat message.
*/
_chatInput: ?HTMLElement;
class Chat extends AbstractChat<Props> {
/**
* Whether or not the {@code Chat} component is off-screen, having finished
@ -96,15 +42,12 @@ class Chat extends Component<Props, State> {
constructor(props: Props) {
super(props);
this._chatInput = null;
this._isExited = true;
this._messagesListEnd = null;
// Bind event handlers so they are only bound once for every instance.
this._onCloseClick = this._onCloseClick.bind(this);
this._renderMessage = this._renderMessage.bind(this);
this._renderPanelContent = this._renderPanelContent.bind(this);
this._setChatInputRef = this._setChatInputRef.bind(this);
this._setMessageListEndRef = this._setMessageListEndRef.bind(this);
}
@ -145,17 +88,6 @@ class Chat extends Component<Props, State> {
);
}
_onCloseClick: () => void;
/**
* Callback invoked to hide {@code Chat}.
*
* @returns {void}
*/
_onCloseClick() {
this.props.dispatch(toggleChat());
}
/**
* Returns a React Element for showing chat messages and a form to send new
* chat messages.
@ -177,7 +109,7 @@ class Chat extends Component<Props, State> {
<div id = 'chatconversation'>
{ messages }
</div>
<ChatInput getChatInputRef = { this._setChatInputRef } />
<ChatInput />
</div>
);
}
@ -213,14 +145,14 @@ class Chat extends Component<Props, State> {
_renderPanelContent(state) {
this._isExited = state === 'exited';
const { _isOpen, _showNamePrompt } = this.props;
const { _isOpen, _onToggleChat, _showNamePrompt } = this.props;
const ComponentToRender = !_isOpen && state === 'exited'
? null
: (
<div>
<div
className = 'chat-close'
onClick = { this._onCloseClick }>X</div>
onClick = { _onToggleChat }>X</div>
{ _showNamePrompt
? <DisplayNameForm /> : this._renderChat() }
</div>
@ -256,20 +188,6 @@ class Chat extends Component<Props, State> {
}
}
_setChatInputRef: (?HTMLElement) => void;
/**
* Sets a reference to the HTML text input element used for typing in chat
* messages.
*
* @param {Object} chatInput - The input for typing chat messages.
* @private
* @returns {void}
*/
_setChatInputRef(chatInput: ?HTMLElement) {
this._chatInput = chatInput;
}
_setMessageListEndRef: (?HTMLElement) => void;
/**
@ -284,29 +202,4 @@ class Chat extends Component<Props, State> {
}
}
/**
* Maps (parts of) the redux state to {@link Chat} React {@code Component}
* props.
*
* @param {Object} state - The redux store/state.
* @private
* @returns {{
* _conference: Object,
* _isOpen: boolean,
* _messages: Array<Object>,
* _showNamePrompt: boolean
* }}
*/
function _mapStateToProps(state) {
const { isOpen, messages } = state['features/chat'];
const localParticipant = getLocalParticipant(state);
return {
_conference: state['features/base/conference'].conference,
_isOpen: isOpen,
_messages: messages,
_showNamePrompt: !localParticipant.name
};
}
export default translate(connect(_mapStateToProps)(Chat));
export default translate(connect(_mapStateToProps, _mapDispatchToProps)(Chat));

View File

@ -3,7 +3,7 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { getUnreadCount } from '../functions';
import { getUnreadCount } from '../../functions';
/**
* The type of the React {@code Component} props of {@link ChatCounter}.

View File

@ -4,7 +4,7 @@ import React, { Component } from 'react';
import { connect } from 'react-redux';
import Emoji from 'react-emoji-render';
import { sendMessage } from '../actions';
import { sendMessage } from '../../actions';
import SmileysPanel from './SmileysPanel';

View File

@ -1,34 +1,20 @@
// @flow
import React, { PureComponent } from 'react';
import React from 'react';
import { toArray } from 'react-emoji-render';
import Linkify from 'react-linkify';
import { translate } from '../../base/i18n';
import { translate } from '../../../base/i18n';
import AbstractChatMessage, {
type Props
} from '../AbstractChatMessage';
/**
* The type of the React {@code Component} props of {@link Chat}.
* Renders a single chat message.
*/
type Props = {
/**
* The redux representation of a chat message.
*/
message: Object,
/**
* Invoked to receive translated strings.
*/
t: Function
};
/**
* Displays as passed in chat message.
*
* @extends Component
*/
class ChatMessage extends PureComponent<Props> {
class ChatMessage extends AbstractChatMessage<Props> {
/**
* Implements React's {@link Component#render()}.
*

View File

@ -4,8 +4,8 @@ import { FieldTextStateless } from '@atlaskit/field-text';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { translate } from '../../base/i18n';
import { updateSettings } from '../../base/settings';
import { translate } from '../../../base/i18n';
import { updateSettings } from '../../../base/settings';
/**
* The type of the React {@code Component} props of {@DisplayNameForm}.

View File

@ -2,7 +2,7 @@
import React, { PureComponent } from 'react';
import Emoji from 'react-emoji-render';
import { smileys } from '../smileys';
import { smileys } from '../../smileys';
/**
* The type of the React {@code Component} props of {@link SmileysPanel}.

View File

@ -0,0 +1,4 @@
// @flow
export { default as Chat } from './Chat';
export { default as ChatCounter } from './ChatCounter';

View File

@ -3,15 +3,21 @@
import UIUtil from '../../../modules/UI/util/UIUtil';
import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../base/app';
import { CONFERENCE_JOINED, CONFERENCE_WILL_LEAVE } from '../base/conference';
import {
CONFERENCE_JOINED,
getCurrentConference
} from '../base/conference';
import { JitsiConferenceEvents } from '../base/lib-jitsi-meet';
import { getParticipantById } from '../base/participants';
import { MiddlewareRegistry } from '../base/redux';
import {
getParticipantById,
getParticipantDisplayName
} from '../base/participants';
import { MiddlewareRegistry, StateListenerRegistry } from '../base/redux';
import { playSound, registerSound, unregisterSound } from '../base/sounds';
import { isButtonEnabled, showToolbox } from '../toolbox';
import { SEND_MESSAGE } from './actionTypes';
import { addMessage, clearMessages } from './actions';
import { addMessage, clearMessages, toggleChat } from './actions';
import { INCOMING_MSG_SOUND_ID } from './constants';
import { INCOMING_MSG_SOUND_FILE } from './sounds';
@ -27,49 +33,60 @@ declare var interfaceConfig : Object;
MiddlewareRegistry.register(store => next => action => {
switch (action.type) {
case APP_WILL_MOUNT:
// Register the chat message sound on Web only because there's no chat
// on mobile.
typeof APP === 'undefined'
|| store.dispatch(
store.dispatch(
registerSound(INCOMING_MSG_SOUND_ID, INCOMING_MSG_SOUND_FILE));
break;
case APP_WILL_UNMOUNT:
// Unregister the chat message sound on Web because it's registered
// there only.
typeof APP === 'undefined'
|| store.dispatch(unregisterSound(INCOMING_MSG_SOUND_ID));
store.dispatch(unregisterSound(INCOMING_MSG_SOUND_ID));
break;
case CONFERENCE_JOINED:
typeof APP === 'undefined'
|| _addChatMsgListener(action.conference, store);
_addChatMsgListener(action.conference, store);
break;
case CONFERENCE_WILL_LEAVE:
store.dispatch(clearMessages());
break;
case SEND_MESSAGE: {
const { conference } = store.getState()['features/base/conference'];
case SEND_MESSAGE:
if (typeof APP !== 'undefined') {
const { conference } = store.getState()['features/base/conference'];
if (conference) {
const escapedMessage = UIUtil.escapeHtml(action.message);
if (conference) {
const escapedMessage = UIUtil.escapeHtml(action.message);
if (typeof APP !== 'undefined') {
APP.API.notifySendingChatMessage(escapedMessage);
conference.sendTextMessage(escapedMessage);
}
conference.sendTextMessage(escapedMessage);
}
break;
}
}
return next(action);
});
/**
* Registers listener for {@link JitsiConferenceEvents.MESSAGE_RECEIVED} which
* will play a sound on the event, given that the chat is not currently visible.
* Set up state change listener to perform maintenance tasks when the conference
* is left or failed, e.g. clear messages or close the chat modal if it's left
* open.
*/
StateListenerRegistry.register(
state => getCurrentConference(state),
(conference, { dispatch, getState }, previousConference) => {
if (conference !== previousConference) {
// conference changed, left or failed...
if (getState()['features/chat'].isOpen) {
// Closes the chat if it's left open.
dispatch(toggleChat());
}
// Clear chat messages.
dispatch(clearMessages());
}
});
/**
* Registers listener for {@link JitsiConferenceEvents.MESSAGE_RECEIVED} that
* will perform various chat related activities.
*
* @param {JitsiConference} conference - The conference instance on which the
* new event listener will be registered.
@ -79,35 +96,28 @@ MiddlewareRegistry.register(store => next => action => {
*/
function _addChatMsgListener(conference, { dispatch, getState }) {
if ((typeof interfaceConfig === 'object' && interfaceConfig.filmStripOnly)
|| !isButtonEnabled('chat')) {
|| (typeof APP !== 'undefined' && !isButtonEnabled('chat'))) {
// We don't register anything on web if we're in filmStripOnly mode, or
// the chat button is not enabled in interfaceConfig.
return;
}
conference.on(
JitsiConferenceEvents.MESSAGE_RECEIVED,
(id, message, timestamp) => {
// Logic for all platforms:
const state = getState();
const { isOpen: isChatOpen } = state['features/chat'];
if (!isChatOpen) {
dispatch(playSound(INCOMING_MSG_SOUND_ID));
dispatch(showToolbox(4000));
}
// Provide a default for for the case when a message is being
// backfilled for a participant that has left the conference.
const participant = getParticipantById(state, id) || {};
const displayName = participant.name
|| `${interfaceConfig.DEFAULT_REMOTE_DISPLAY_NAME} (${id})`;
const displayName = getParticipantDisplayName(getState, id);
const hasRead = participant.local || isChatOpen;
APP.API.notifyReceivedChatMessage({
body: message,
id,
nick: displayName,
ts: timestamp
});
const timestampToDate = timestamp
? new Date(timestamp) : new Date();
const millisecondsTimestamp = timestampToDate.getTime();
@ -120,6 +130,19 @@ function _addChatMsgListener(conference, { dispatch, getState }) {
message,
timestamp: millisecondsTimestamp
}));
if (typeof APP !== 'undefined') {
// Logic for web only:
APP.API.notifyReceivedChatMessage({
body: message,
id,
nick: displayName,
ts: timestamp
});
dispatch(showToolbox(4000));
}
}
);
}

View File

@ -15,6 +15,7 @@ import {
import { TestConnectionInfo } from '../../base/testing';
import { createDesiredLocalTracks } from '../../base/tracks';
import { ConferenceNotification } from '../../calendar-sync';
import { Chat } from '../../chat';
import {
Filmstrip,
isFilmstripVisible,
@ -250,6 +251,8 @@ class Conference extends Component<Props> {
hidden = { true }
translucent = { true } />
<Chat />
{/*
* The LargeVideo is the lowermost stacking layer.
*/

View File

@ -0,0 +1,30 @@
// @flow
import React from 'react';
import { connect } from 'react-redux';
import { InputDialog } from '../../base/dialog';
import AbstractDisplayNamePrompt from './AbstractDisplayNamePrompt';
/**
* Implements a component to render a display name prompt.
*/
class DisplayNamePrompt extends AbstractDisplayNamePrompt<*> {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
*/
render() {
return (
<InputDialog
contentKey = 'dialog.enterDisplayName'
onSubmit = { this._onSetDisplayName } />
);
}
_onSetDisplayName: string => boolean;
}
export default connect()(DisplayNamePrompt);

View File

@ -9,6 +9,7 @@ import {
bottomSheetItemStylesCombined,
hideDialog
} from '../../../base/dialog';
import { InviteButton } from '../../../invite';
import { AudioRouteButton } from '../../../mobile/audio-mode';
import { PictureInPictureButton } from '../../../mobile/picture-in-picture';
import { LiveStreamButton, RecordButton } from '../../../recording';
@ -86,6 +87,7 @@ class OverflowMenu extends Component<Props> {
}
<LiveStreamButton { ...buttonProps } />
<TileViewButton { ...buttonProps } />
<InviteButton { ...buttonProps } />
<PictureInPictureButton { ...buttonProps } />
</BottomSheet>
);

View File

@ -5,12 +5,13 @@ import { View } from 'react-native';
import { connect } from 'react-redux';
import { Container } from '../../../base/react';
import { InviteButton } from '../../../invite';
import { ChatButton } from '../../../chat';
import AudioMuteButton from '../AudioMuteButton';
import HangupButton from '../HangupButton';
import OverflowMenuButton from './OverflowMenuButton';
import styles, {
chatButtonOverride,
hangupButtonStyles,
toolbarButtonStyles,
toolbarToggledButtonStyles
@ -141,6 +142,35 @@ class Toolbox extends Component<Props, State> {
return 2 * Math.round(buttonSize / 2);
}
/**
* Constructs the toggled style of the chat button. This cannot be done by
* simple style inheritance due to the size calculation done in this
* component.
*
* @param {Object} baseStyle - The base style that was originally
* calculated.
* @returns {Object | Array}
*/
_getChatButtonToggledStyle(baseStyle) {
if (Array.isArray(baseStyle.style)) {
return {
...baseStyle,
style: [
...baseStyle.style,
chatButtonOverride.toggled
]
};
}
return {
...baseStyle,
style: [
baseStyle.style,
chatButtonOverride.toggled
]
};
}
_onLayout: (Object) => void;
/**
@ -200,7 +230,11 @@ class Toolbox extends Component<Props, State> {
<View
pointerEvents = 'box-none'
style = { styles.toolbar }>
<InviteButton styles = { buttonStyles } />
<ChatButton
styles = { buttonStyles }
toggledStyles = {
this._getChatButtonToggledStyle(toggledButtonStyles)
} />
<AudioMuteButton
styles = { buttonStyles }
toggledStyles = { toggledButtonStyles } />

View File

@ -130,3 +130,13 @@ export const toolbarToggledButtonStyles = {
iconStyle: styles.whiteToolbarButtonIcon,
style: styles.whiteToolbarButton
};
/**
* Overrides to the standard styles that we apply to the chat button, as that
* behaves slightly differently to other buttons.
*/
export const chatButtonOverride = createStyleSheet({
toggled: {
backgroundColor: ColorPalette.blue
}
});