Merge pull request #93 from tinode/calls-dev

Video calls
This commit is contained in:
aforge
2022-04-28 22:01:10 -07:00
committed by GitHub
35 changed files with 6370 additions and 577 deletions
Binary file not shown.
+71
View File
@@ -2738,3 +2738,74 @@ ul.tabbar li.active > a {
opacity: 0.3;
}
}
#video-container {
display: flex;
flex-direction: column;
justify-content: center;
height: 100%!important;
width: 85%;
margin: auto;
height: 80%;
}
video {
width:100%;
}
.video-container-panel {
-ms-flex-align: center!important;
align-items: center!important;
margin-top: 10%;
position:relative;
}
.video-elem {
width:50%;
float:left;
position:relative;
z-index:0;
}
.video-title {
position:absolute;
bottom: 0;
right: 0;
z-index: 1;
background-color: white;
}
#video-container-controls {
display: flex;
justify-content: center;
padding: 20px;
}
.video-call-accept {
border-radius: 10px;
background-color: green;
}
.video-call-hangup {
border-radius: 10px;
background-color: red;
}
.video-call-toggle-media {
border-radius: 10px;
background-color: gray;
}
.incoming-call-title {
text-align: center;
padding: 10px;
}
.incoming-call-badges {
text-align: center;
}
.incoming-call-actions {
text-align: center;
padding: 20px;
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+423 -353
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -1,7 +1,7 @@
{
"name": "tinode-webapp",
"description": "Tinode messenger for the web",
"version": "0.19.0-alpha1",
"version": "0.20.0-alpha1",
"repository": {
"type": "git",
"url": "https://github.com/tinode/example-react-js.git"
@@ -71,7 +71,9 @@
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-intl": "^5.20.2",
"tinode-sdk": "^0.19.0-alpha1"
"react-intl-formatted-duration": "^4.0.0",
"tinode-sdk": "^0.20.0-alpha1",
"webrtc-adapter": "^8.1.1"
},
"devDependencies": {
"@babel/core": "^7.14.5",
+18
View File
@@ -106,3 +106,21 @@ export const LINK_PRIVACY_POLICY = 'https://tinode.co/privacy.html';
// Link to Terms of Service.
export const LINK_TERMS_OF_SERVICE = 'https://tinode.co/terms.html';
// Video calls/WebRTC configuration.
export const CALL_WEBRTC_CONFIG = {
iceServers: [
{ urls: [ "stun:bn-turn1.xirsys.com" ] },
{
username: "0kYXFmQL9xojOrUy4VFemlTnNPVFZpp7jfPjpB3AjxahuRe4QWrCs6Ll1vDc7TTjAAAAAGAG2whXZWJUdXRzUGx1cw==",
credential: "285ff060-5a58-11eb-b269-0242ac140004",
urls: [
"turn:bn-turn1.xirsys.com:80?transport=udp",
"turn:bn-turn1.xirsys.com:3478?transport=udp",
"turn:bn-turn1.xirsys.com:80?transport=tcp",
"turn:bn-turn1.xirsys.com:3478?transport=tcp",
"turns:bn-turn1.xirsys.com:443?transport=tcp",
"turns:bn-turn1.xirsys.com:5349?transport=tcp"
]
}
]};
+8
View File
@@ -0,0 +1,8 @@
// Video call states.
export const CALL_STATE_NONE = 0;
export const CALL_STATE_OUTGOING_INITATED = 1;
export const CALL_STATE_INCOMING_RECEIVED = 2;
export const CALL_STATE_IN_PROGRESS = 3;
// Mime type used for video call messages.
export const CALL_MESSAGE_MIME_TYPE = 'application/x-tinode-webrtc';
+35
View File
@@ -1374,5 +1374,40 @@
"description": "Message shown when media preview is not available",
"missing": false,
"obsolete": false
},
"calls_incoming": {
"translation": "",
"defaultMessage": "Incoming Call",
"description": "Incoming call label",
"missing": true,
"obsolete": false
},
"calls_incoming_title": {
"translation": "",
"defaultMessage": "Incoming Call",
"description": "Incoming call title (Incoming call view)",
"missing": true,
"obsolete": false
},
"calls_outgoing": {
"translation": "",
"defaultMessage": "Outgoing Call",
"description": "Outgoing call label",
"missing": true,
"obsolete": false
},
"calls_you_label": {
"translation": "",
"defaultMessage": "You",
"description": "Shown over the local video screen",
"missing": true,
"obsolete": false
},
"menu_item_video_call": {
"translation": "",
"defaultMessage": "Video Call",
"description": "Start video call",
"missing": true,
"obsolete": false
}
}
+35
View File
@@ -1374,5 +1374,40 @@
"description": "Message shown when media preview is not available",
"missing": false,
"obsolete": false
},
"calls_incoming": {
"translation": "Incoming Call",
"defaultMessage": "Incoming Call",
"description": "Incoming call label",
"missing": false,
"obsolete": false
},
"calls_incoming_title": {
"translation": "Incoming Call",
"defaultMessage": "Incoming Call",
"description": "Incoming call title (Incoming call view)",
"missing": false,
"obsolete": false
},
"calls_outgoing": {
"translation": "Outgoing Call",
"defaultMessage": "Outgoing Call",
"description": "Outgoing call label",
"missing": false,
"obsolete": false
},
"calls_you_label": {
"translation": "You",
"defaultMessage": "You",
"description": "Shown over the local video screen",
"missing": false,
"obsolete": false
},
"menu_item_video_call": {
"translation": "Video Call",
"defaultMessage": "Video Call",
"description": "Start video call",
"missing": false,
"obsolete": false
}
}
+35
View File
@@ -1374,5 +1374,40 @@
"description": "Message shown when media preview is not available",
"missing": false,
"obsolete": false
},
"calls_incoming": {
"translation": "",
"defaultMessage": "Incoming Call",
"description": "Incoming call label",
"missing": true,
"obsolete": false
},
"calls_incoming_title": {
"translation": "",
"defaultMessage": "Incoming Call",
"description": "Incoming call title (Incoming call view)",
"missing": true,
"obsolete": false
},
"calls_outgoing": {
"translation": "",
"defaultMessage": "Outgoing Call",
"description": "Outgoing call label",
"missing": true,
"obsolete": false
},
"calls_you_label": {
"translation": "",
"defaultMessage": "You",
"description": "Shown over the local video screen",
"missing": true,
"obsolete": false
},
"menu_item_video_call": {
"translation": "",
"defaultMessage": "Video Call",
"description": "Start video call",
"missing": true,
"obsolete": false
}
}
+20
View File
@@ -119,6 +119,22 @@
"defaultMessage": "Update",
"description": "Button [Update]"
},
"calls_incoming": {
"defaultMessage": "Incoming Call",
"description": "Incoming call label"
},
"calls_incoming_title": {
"defaultMessage": "Incoming Call",
"description": "Incoming call title (Incoming call view)"
},
"calls_outgoing": {
"defaultMessage": "Outgoing Call",
"description": "Outgoing call label"
},
"calls_you_label": {
"defaultMessage": "You",
"description": "Shown over the local video screen"
},
"cannot_initiate_file_upload": {
"defaultMessage": "Cannot initiate file upload.",
"description": "Generic error messagewhen attachment fails"
@@ -480,6 +496,10 @@
"defaultMessage": "Unmute",
"description": "Turn notifications on"
},
"menu_item_video_call": {
"defaultMessage": "Video Call",
"description": "Start video call"
},
"message_sending": {
"defaultMessage": "sending...",
"description": "Message being sent, in place of time stamp"
+35
View File
@@ -1374,5 +1374,40 @@
"description": "Message shown when media preview is not available",
"missing": false,
"obsolete": false
},
"calls_incoming": {
"translation": "",
"defaultMessage": "Incoming Call",
"description": "Incoming call label",
"missing": true,
"obsolete": false
},
"calls_incoming_title": {
"translation": "",
"defaultMessage": "Incoming Call",
"description": "Incoming call title (Incoming call view)",
"missing": true,
"obsolete": false
},
"calls_outgoing": {
"translation": "",
"defaultMessage": "Outgoing Call",
"description": "Outgoing call label",
"missing": true,
"obsolete": false
},
"calls_you_label": {
"translation": "",
"defaultMessage": "You",
"description": "Shown over the local video screen",
"missing": true,
"obsolete": false
},
"menu_item_video_call": {
"translation": "",
"defaultMessage": "Video Call",
"description": "Start video call",
"missing": true,
"obsolete": false
}
}
+35
View File
@@ -1374,5 +1374,40 @@
"description": "Message shown when media preview is not available",
"missing": false,
"obsolete": false
},
"calls_incoming": {
"translation": "",
"defaultMessage": "Incoming Call",
"description": "Incoming call label",
"missing": true,
"obsolete": false
},
"calls_incoming_title": {
"translation": "",
"defaultMessage": "Incoming Call",
"description": "Incoming call title (Incoming call view)",
"missing": true,
"obsolete": false
},
"calls_outgoing": {
"translation": "",
"defaultMessage": "Outgoing Call",
"description": "Outgoing call label",
"missing": true,
"obsolete": false
},
"calls_you_label": {
"translation": "",
"defaultMessage": "You",
"description": "Shown over the local video screen",
"missing": true,
"obsolete": false
},
"menu_item_video_call": {
"translation": "",
"defaultMessage": "Video Call",
"description": "Start video call",
"missing": true,
"obsolete": false
}
}
+35
View File
@@ -1374,5 +1374,40 @@
"description": "Message shown when media preview is not available",
"missing": false,
"obsolete": false
},
"calls_incoming": {
"translation": "",
"defaultMessage": "Incoming Call",
"description": "Incoming call label",
"missing": true,
"obsolete": false
},
"calls_incoming_title": {
"translation": "",
"defaultMessage": "Incoming Call",
"description": "Incoming call title (Incoming call view)",
"missing": true,
"obsolete": false
},
"calls_outgoing": {
"translation": "",
"defaultMessage": "Outgoing Call",
"description": "Outgoing call label",
"missing": true,
"obsolete": false
},
"calls_you_label": {
"translation": "",
"defaultMessage": "You",
"description": "Shown over the local video screen",
"missing": true,
"obsolete": false
},
"menu_item_video_call": {
"translation": "",
"defaultMessage": "Video Call",
"description": "Start video call",
"missing": true,
"obsolete": false
}
}
+35
View File
@@ -1374,5 +1374,40 @@
"description": "Message shown when media preview is not available",
"missing": false,
"obsolete": false
},
"calls_incoming": {
"translation": "",
"defaultMessage": "Incoming Call",
"description": "Incoming call label",
"missing": true,
"obsolete": false
},
"calls_incoming_title": {
"translation": "",
"defaultMessage": "Incoming Call",
"description": "Incoming call title (Incoming call view)",
"missing": true,
"obsolete": false
},
"calls_outgoing": {
"translation": "",
"defaultMessage": "Outgoing Call",
"description": "Outgoing call label",
"missing": true,
"obsolete": false
},
"calls_you_label": {
"translation": "",
"defaultMessage": "You",
"description": "Shown over the local video screen",
"missing": true,
"obsolete": false
},
"menu_item_video_call": {
"translation": "",
"defaultMessage": "Video Call",
"description": "Start video call",
"missing": true,
"obsolete": false
}
}
+35
View File
@@ -1374,5 +1374,40 @@
"description": "Message shown when media preview is not available",
"missing": false,
"obsolete": false
},
"calls_incoming": {
"translation": "",
"defaultMessage": "Incoming Call",
"description": "Incoming call label",
"missing": true,
"obsolete": false
},
"calls_incoming_title": {
"translation": "",
"defaultMessage": "Incoming Call",
"description": "Incoming call title (Incoming call view)",
"missing": true,
"obsolete": false
},
"calls_outgoing": {
"translation": "",
"defaultMessage": "Outgoing Call",
"description": "Outgoing call label",
"missing": true,
"obsolete": false
},
"calls_you_label": {
"translation": "",
"defaultMessage": "You",
"description": "Shown over the local video screen",
"missing": true,
"obsolete": false
},
"menu_item_video_call": {
"translation": "",
"defaultMessage": "Video Call",
"description": "Start video call",
"missing": true,
"obsolete": false
}
}
+12
View File
@@ -2,6 +2,8 @@
import { Tinode } from 'tinode-sdk';
import { CALL_MESSAGE_MIME_TYPE } from '../constants.js'
// Make shortcut icon appear with a green dot + show unread count in title.
export function updateFavicon(count) {
const oldIcon = document.getElementById('shortcut-icon');
@@ -205,3 +207,13 @@ export function cancelablePromise(promise) {
},
};
};
// Returns true if specified mime type represents a Tinode video call.
export function isVideoCall(mime) {
return mime == CALL_MESSAGE_MIME_TYPE;
}
// Clips string to the specified length.
export function clipStr(str, length) {
return str && str.substring(0, length);
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,3 +1,3 @@
// This is a generated file. Don't edit.
export const PACKAGE_VERSION = "0.19.0-alpha1";
export const PACKAGE_VERSION = "0.20.0-alpha1";
+167
View File
@@ -0,0 +1,167 @@
// IncomingCallView: displays Accept & Reject buttons for incoming calls.
import React from 'react';
import { FormattedMessage } from 'react-intl';
import AvatarUpload from '../widgets/avatar-upload.jsx';
import BadgeList from '../widgets/badge-list.jsx';
import MenuCancel from '../widgets/menu-cancel.jsx';
import { MAX_TITLE_LENGTH } from '../config.js';
import { CALL_STATE_INCOMING_RECEIVED } from '../constants.js';
import { makeImageUrl } from '../lib/blob-helpers.js';
import { clipStr } from '../lib/utils.js'
const RING_SOUND = new Audio('audio/phone_ring.mp3');
export default class IncomingCallView extends React.Component {
constructor(props) {
super(props);
this.state = {
topic: null,
fullName: undefined,
avatar: null,
trustedBadges: [],
previousMetaDesc: undefined,
};
this.resetDesc = this.resetDesc.bind(this);
this.onMetaDesc = this.onMetaDesc.bind(this);
this.handleRejectCall = this.handleRejectCall.bind(this);
this.handleAcceptCall = this.handleAcceptCall.bind(this);
this.ringTimer = null;
}
componentDidMount() {
const topic = this.props.tinode.getTopic(this.props.topic);
if (!topic) {
return;
}
this.resetDesc(topic, this.props);
if (this.props.callState == CALL_STATE_INCOMING_RECEIVED) {
RING_SOUND.play();
this.ringTimer = setInterval(() => RING_SOUND.play(), 2000);
this.props.onRinging(this.props.topic, this.props.seq);
}
}
// No need to separately handle component mount.
componentDidUpdate(props) {
const topic = this.props.tinode.getTopic(props.topic);
if (!topic) {
return;
}
if (this.onMetaDesc != topic.onMetaDesc) {
this.previousMetaDesc = topic.onMetaDesc;
topic.onMetaDesc = this.onMetaDesc;
}
if (this.state.topic != props.topic) {
this.setState({topic: props.topic});
this.resetDesc(topic, props);
}
}
componentWillUnmount() {
if (this.ringTimer != null) {
clearInterval(this.ringTimer);
RING_SOUND.pause();
}
const topic = this.props.tinode.getTopic(this.props.topic);
if (!topic) {
return;
}
this.setState({topic: null});
topic.onMetaDesc = this.previousMetaDesc;
}
resetDesc(topic, props) {
const defacs = topic.getDefaultAccess() || {};
const acs = topic.getAccessMode();
const badges = [];
if (topic.trusted) {
for (const [key, val] of Object.entries(topic.trusted)) {
if (val) {
badges.push(key);
}
}
}
this.setState({
fullName: clipStr(topic.public ? topic.public.fn : undefined, MAX_TITLE_LENGTH),
avatar: makeImageUrl(topic.public ? topic.public.photo : null),
trustedBadges: badges,
});
}
onMetaDesc(desc) {
const topic = this.props.tinode.getTopic(this.props.topic);
if (!topic) {
return;
}
this.resetDesc(topic, this.props);
if (this.previousMetaDesc && this.previousMetaDesc != this.onMetaDesc) {
this.previousMetaDesc(desc);
}
}
handleAcceptCall() {
this.props.onAcceptCall(this.props.topic);
}
handleRejectCall() {
this.props.onReject(this.props.topic, this.props.seq);
this.props.onClose();
}
render() {
let panelTitle = <FormattedMessage id="calls_incoming_title"
defaultMessage="Incoming Call" description="Incoming call title (Incoming call view)" />;
return (
<div id="info-view">
<div className="caption-panel" id="info-caption-panel">
<div className="panel-title" id="info-title">{panelTitle}</div>
<div>
<MenuCancel onCancel={this.handleRejectCall} />
</div>
</div>
<div id="info-view-content" className="scrollable-panel">
<div className="panel-form-column">
<center>
<AvatarUpload
tinode={this.props.tinode}
avatar={this.state.avatar}
readOnly={true}
uid={this.props.topic}
title={this.state.fullName} />
</center>
<div className="group incoming-call-title">
<div className="large ellipsized">{clipStr(this.state.fullName)}</div>
</div>
<div className="group incoming-call-badges">
<BadgeList trustedBadges={this.state.trustedBadges} />
</div>
<div id="actions" className="group incoming-call-actions">
{this.props.callState == CALL_STATE_INCOMING_RECEIVED ?
<>
<button className="video-call-hangup" onClick={this.handleRejectCall}>
<i className="material-icons">call_end</i>
</button>
<button className="video-call-accept" onClick={this.handleAcceptCall}>
<i className="material-icons">call</i>
</button>
</>
:
null
}
</div>
</div>
</div>
</div>
);
}
};
+4 -8
View File
@@ -18,7 +18,7 @@ import TopicSecurity from '../widgets/topic-security.jsx';
import { MAX_TITLE_LENGTH, MAX_TOPIC_DESCRIPTION_LENGTH, NO_ACCESS_MODE } from '../config.js';
import { makeImageUrl } from '../lib/blob-helpers.js';
import { theCard } from '../lib/utils.js';
import { theCard, clipStr } from '../lib/utils.js';
const messages = defineMessages({
info: {
@@ -78,10 +78,6 @@ const messages = defineMessages({
},
});
function _clip(str, length) {
return str && str.substring(0, length);
}
class InfoView extends React.Component {
constructor(props) {
super(props);
@@ -206,11 +202,11 @@ class InfoView extends React.Component {
deleter: acs && acs.isDeleter(),
muted: acs && acs.isMuted(),
fullName: _clip(topic.public ? topic.public.fn : undefined, MAX_TITLE_LENGTH),
description: _clip(topic.public ? topic.public.note : undefined, MAX_TOPIC_DESCRIPTION_LENGTH),
fullName: clipStr(topic.public && topic.public.fn, MAX_TITLE_LENGTH),
description: clipStr(topic.public && topic.public.note, MAX_TOPIC_DESCRIPTION_LENGTH),
avatar: makeImageUrl(topic.public ? topic.public.photo : null),
trustedBadges: badges,
private: _clip(topic.private ? topic.private.comment : null, MAX_TITLE_LENGTH),
private: clipStr(topic.private && topic.private.comment, MAX_TITLE_LENGTH),
archived: topic.isArchived(),
address: topic.name,
groupTopic: topic.isGroupType(),
+64 -1
View File
@@ -5,6 +5,7 @@ import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import { Drafty, Tinode } from 'tinode-sdk';
import CallPanel from '../widgets/call-panel.jsx';
import ChatMessage from '../widgets/chat-message.jsx';
import ContactBadges from '../widgets/contact-badges.jsx';
import DocPreview from '../widgets/doc-preview.jsx';
@@ -21,9 +22,11 @@ import SendMessage from '../widgets/send-message.jsx';
import { DEFAULT_P2P_ACCESS_MODE, IMAGE_PREVIEW_DIM, KEYPRESS_DELAY,
MESSAGES_PAGE, MAX_EXTERN_ATTACHMENT_SIZE, MAX_IMAGE_DIM, MAX_INBAND_ATTACHMENT_SIZE,
READ_DELAY, QUOTED_REPLY_LENGTH } from '../config.js';
import { CALL_STATE_OUTGOING_INITATED, CALL_STATE_IN_PROGRESS } from '../constants.js';
import { blobToBase64, fileToBase64, imageScaled, makeImageUrl } from '../lib/blob-helpers.js';
import HashNavigation from '../lib/navigation.js';
import { bytesToHumanSize, shortDateFormat } from '../lib/strformat.js';
import { isVideoCall } from '../lib/utils.js';
// Run timer with this frequency (ms) for checking notification queue.
const NOTIFICATION_EXEC_INTERVAL = 300;
@@ -80,6 +83,13 @@ function isPeerRestricted(acs) {
return false;
}
function shouldPresentCallPanel(callState) {
// Show call panel if either:
// - call is outgoing (and the client is waiting for the other side to pick up) or,
// - call is already in progress.
return callState == CALL_STATE_OUTGOING_INITATED || callState == CALL_STATE_IN_PROGRESS;
}
class MessagesView extends React.Component {
constructor(props) {
super(props);
@@ -118,6 +128,7 @@ class MessagesView extends React.Component {
this.handlePickReply = this.handlePickReply.bind(this);
this.handleCancelReply = this.handleCancelReply.bind(this);
this.handleQuoteClick = this.handleQuoteClick.bind(this);
this.handleCallHangup = this.handleCallHangup.bind(this);
this.chatMessageRefs = {};
this.getOrCreateMessageRef = this.getOrCreateMessageRef.bind(this);
@@ -167,6 +178,9 @@ class MessagesView extends React.Component {
if (this.state.topic != prevState.topic) {
if (prevState.topic && !Tinode.isNewGroupTopicName(prevState.topic)) {
this.leave(prevState.topic);
if (prevState.rtcPanel) {
this.handleCallHangup(prevState.topic, prevProps.callSeq);
}
}
if (topic) {
@@ -215,6 +229,7 @@ class MessagesView extends React.Component {
docPreview: null,
imagePreview: null,
imagePostview: null,
rtcPanel: null,
typingIndicator: false,
scrollPosition: 0,
fetchingMessages: false,
@@ -241,6 +256,7 @@ class MessagesView extends React.Component {
docPreview: null,
imagePreview: null,
imagePostview: null,
rtcPanel: null,
typingIndicator: false,
scrollPosition: 0,
fetchingMessages: false,
@@ -292,6 +308,10 @@ class MessagesView extends React.Component {
latestClearId: topic.maxClearId(),
channel: topic.isChannelType()
});
if (nextProps.callTopic == topic.name && shouldPresentCallPanel(nextProps.callState)) {
nextState.rtcPanel = nextProps.callTopic;
}
} else {
// Invalid topic.
Object.assign(nextState, {
@@ -304,6 +324,12 @@ class MessagesView extends React.Component {
channel: false
});
}
} else {
// We are still in same topic. Show the call panel if necessary.
if (nextProps.callTopic == prevState.topic && !prevState.rtcPanel &&
shouldPresentCallPanel(nextProps.callState)) {
nextState.rtcPanel = nextProps.callTopic;
}
}
if (nextProps.acs) {
@@ -402,6 +428,7 @@ class MessagesView extends React.Component {
if (!oldTopicName || !this.props.tinode.isTopicCached(oldTopicName)) {
return;
}
const oldTopic = this.props.tinode.getTopic(oldTopicName);
if (oldTopic && oldTopic.isSubscribed()) {
oldTopic.leave(false)
@@ -788,6 +815,14 @@ class MessagesView extends React.Component {
}
}
handleCallHangup(topic, seq) {
this.props.onVideoCallClosed();
this.setState({
rtcPanel: null
});
this.props.onCallHangup(topic, seq);
}
// sendImageAttachment sends the image bits inband as Drafty message.
sendImageAttachment(caption, blob) {
const mime = this.state.imagePreview.mime;
@@ -1013,6 +1048,26 @@ class MessagesView extends React.Component {
onClose={this.handleClosePreview}
onSendMessage={this.sendFileAttachment} />
);
} else if (this.state.rtcPanel) {
const topic = this.props.tinode.getTopic(this.state.topic);
component2 = (
<CallPanel
topic={topic}
seq={this.props.callSeq}
callState={this.props.callState}
displayMobile={this.props.displayMobile}
tinode={this.props.tinode}
title={this.state.title}
avatar={this.state.avatar || true}
onError={this.props.onError}
onHangup={this.handleCallHangup}
onInvite={this.props.onCallInvite}
onSendOffer={this.props.onCallSendOffer}
onIceCandidate={this.props.onCallIceCandidate}
onSendAnswer={this.props.onCallSendAnswer}
/>
);
} else {
const topic = this.props.tinode.getTopic(this.state.topic);
const isChannel = topic.isChannelType();
@@ -1088,12 +1143,21 @@ class MessagesView extends React.Component {
);
prevDate = thisDate;
}
let duration;
if (msg.head && isVideoCall(msg.head.mime)) {
topic.messageVersions(msg, (cur, before) => {
if (cur.content == 'finished' && before.content == 'accepted') {
duration = msg.ts - before.ts;
}
});
}
messageNodes.push(
<ChatMessage
tinode={this.props.tinode}
content={msg.content}
mimeType={msg.head ? msg.head.mime : null}
timestamp={msg.ts}
duration={duration}
response={isReply}
seq={msg.seq}
isGroup={groupTopic}
@@ -1235,7 +1299,6 @@ class MessagesView extends React.Component {
</>
);
}
component = <div id="topic-view">{component2}</div>
}
return component;
+230 -11
View File
@@ -13,11 +13,15 @@ import ContextMenu from '../widgets/context-menu.jsx';
import ForwardDialog from '../widgets/forward-dialog.jsx';
import InfoView from './info-view.jsx';
import IncomingCallView from './incoming-call-view.jsx';
import MessagesView from './messages-view.jsx';
import SidepanelView from './sidepanel-view.jsx';
import { API_KEY, APP_NAME, DEFAULT_P2P_ACCESS_MODE, FORWARDED_PREVIEW_LENGTH, LOGGING_ENABLED,
MEDIA_BREAKPOINT, RECEIVED_DELAY } from '../config.js';
import { CALL_STATE_NONE, CALL_STATE_OUTGOING_INITATED,
CALL_STATE_INCOMING_RECEIVED, CALL_STATE_IN_PROGRESS,
CALL_MESSAGE_MIME_TYPE } from '../constants.js';
import { PACKAGE_VERSION } from '../version.js';
import { base64ReEncode, makeImageUrl } from '../lib/blob-helpers.js';
import { detectServerAddress, isLocalHost, isSecureConnection } from '../lib/host-name.js';
@@ -64,6 +68,11 @@ const messages = defineMessages({
id: 'menu_item_info',
defaultMessage: 'Info',
description: 'Show extended topic information'
},
menu_item_video_call: {
id: 'menu_item_video_call',
defaultMessage: 'Video Call',
description: 'Start video call'
}
});
@@ -145,6 +154,19 @@ class TinodeWeb extends React.Component {
this.handleShowForwardDialog = this.handleShowForwardDialog.bind(this);
this.handleHideForwardDialog = this.handleHideForwardDialog.bind(this);
this.handleStartVideoCall = this.handleStartVideoCall.bind(this);
this.handleInfoMessage = this.handleInfoMessage.bind(this);
this.handleCallClose = this.handleCallClose.bind(this);
this.handleCallInvite = this.handleCallInvite.bind(this);
this.handleCallRinging = this.handleCallRinging.bind(this);
this.handleCallHangup = this.handleCallHangup.bind(this);
this.handleCallSendOffer = this.handleCallSendOffer.bind(this);
this.handleCallIceCandidate = this.handleCallIceCandidate.bind(this);
this.handleCallSendAnswer = this.handleCallSendAnswer.bind(this);
this.handleCallAcceptCall = this.handleCallAcceptCall.bind(this);
this.sendMessageToTopic = this.sendMessageToTopic.bind(this);
}
@@ -199,6 +221,13 @@ class TinodeWeb extends React.Component {
infoPanel: undefined,
mobilePanel: 'sidepanel',
// Video calls.
callTopic: undefined,
callState: CALL_STATE_NONE,
// If true, call state should be transitioned to CALL_STATE_IN_PROGRESS upon
// switching to the call topic.
callShouldStart: false,
contextMenuVisible: false,
contextMenuBounds: null,
contextMenuClickAt: null,
@@ -254,6 +283,7 @@ class TinodeWeb extends React.Component {
this.tinode.onConnect = this.handleConnected;
this.tinode.onDisconnect = this.handleDisconnect;
this.tinode.onAutoreconnectIteration = this.handleAutoreconnectIteration;
this.tinode.onInfoMessage = this.handleInfoMessage;
}).then(() => {
// Initialize desktop alerts.
if (this.state.desktopAlertsEnabled) {
@@ -501,7 +531,13 @@ class TinodeWeb extends React.Component {
}
handleError(err, level, action, actionText) {
this.setState({errorText: err, errorLevel: level, errorAction: action, errorActionText: actionText});
this.setState({
errorText: err,
errorLevel: level,
errorAction: action,
errorActionText: actionText,
callShouldStart: false,
});
}
// User clicked Login button in the side panel.
@@ -964,7 +1000,8 @@ class TinodeWeb extends React.Component {
// - head - head dictionary to be attached to the message
handleSendMessage(msg, uploadCompletionPromise, uploader, head) {
const topic = this.tinode.getTopic(this.state.topicSelected);
this.sendMessageToTopic(topic, msg, uploadCompletionPromise, uploader, head);
/* TODO: check if return is required */
return this.sendMessageToTopic(topic, msg, uploadCompletionPromise, uploader, head);
}
sendMessageToTopic(topic, msg, uploadCompletionPromise, uploader, head) {
@@ -999,11 +1036,13 @@ class TinodeWeb extends React.Component {
completion.push(subscribePromise);
}
topic.publishDraft(msg, Promise.all(completion))
.then(_ => {
// TODO: check if return is required.
return topic.publishDraft(msg, Promise.all(completion))
.then((ctrl) => {
if (topic.isArchived()) {
topic.archive(false);
}
return ctrl;
})
.catch((err) => {
this.handleError(err.message, 'err');
@@ -1257,14 +1296,19 @@ class TinodeWeb extends React.Component {
// New topic was created, here is the new topic name.
handleNewTopicCreated(oldName, newName) {
let nextState = {};
if (this.state.callShouldStart) {
nextState = {callState: CALL_STATE_IN_PROGRESS, callShouldStart: false};
}
if (this.state.topicSelected == oldName && oldName != newName) {
// If the current URl contains the old topic name, replace it with new.
// Update the name of the selected topic first so the navigator doen't clear
// the state.
this.setState({topicSelected: newName}, () => {
HashNavigation.navigateTo(HashNavigation.setUrlTopic('', newName));
});
nextState.topicSelected = newName;
}
this.setState(nextState, _ => {
HashNavigation.navigateTo(HashNavigation.setUrlTopic('', newName));
});
}
handleTopicUpdateRequest(topicName, pub, priv, defacs) {
@@ -1376,7 +1420,7 @@ class TinodeWeb extends React.Component {
}
handleDeleteAccount() {
this.tinode.delCurrentUser(true).then((ctrl) => {
this.tinode.delCurrentUser(true).then(_ => {
this.handleLogout();
});
}
@@ -1428,7 +1472,7 @@ class TinodeWeb extends React.Component {
return;
}
topic.updateMode(null, '-JP').then((ctrl) => {
topic.updateMode(null, '-JP').then(_ => {
// Hide MessagesView and InfoView panels.
HashNavigation.navigateTo(HashNavigation.setUrlTopic(window.location.hash, ''));
}).catch((err) => {
@@ -1443,7 +1487,7 @@ class TinodeWeb extends React.Component {
}
// Publish spam report.
this.tinode.report('report',topicName);
this.tinode.report('report', topicName);
// Remove J and P permissions.
topic.updateMode(null, '-JP').then((ctrl) => {
@@ -1507,6 +1551,10 @@ class TinodeWeb extends React.Component {
title: this.props.intl.formatMessage(messages.menu_item_info),
handler: this.handleShowInfoView
} : null,
subscribed && Tinode.isP2PTopicName(topicName) ? {
title: this.props.intl.formatMessage(messages.menu_item_video_call),
handler: this.handleStartVideoCall
} : null,
subscribed ? 'messages_clear' : null,
subscribed && deleter ? 'messages_clear_hard' : null,
muted ? (blocked ? null : 'topic_unmute') : 'topic_mute',
@@ -1637,6 +1685,151 @@ class TinodeWeb extends React.Component {
}
}
handleStartVideoCall() {
this.setState({
callTopic: this.state.topicSelected,
callState: CALL_STATE_OUTGOING_INITATED
});
}
handleCallInvite(callTopic, callSeq, callState) {
switch (callState) {
case CALL_STATE_OUTGOING_INITATED:
let head = {mime: CALL_MESSAGE_MIME_TYPE };
this.handleSendMessage('started', undefined, undefined, head)
.then((ctrl) => {
if (ctrl.code < 200 || ctrl.code >= 300 || !ctrl.params || !ctrl.params.seq) {
this.handleCallClose();
return;
}
this.setState({callSeq: ctrl.params['seq']});
});
break;
case CALL_STATE_IN_PROGRESS:
const topic = this.tinode.getTopic(callTopic);
if (!topic) {
return;
}
// We've accepted the call. Let the other side know.
topic.videoCall('accept', callSeq).catch((err) => {
this.handleCallClose();
this.handleError(err.message, 'err');
});
break;
}
}
handleCallRinging(callTopic, callSeq) {
const topic = this.tinode.getTopic(callTopic);
if (!topic) {
return;
}
topic.videoCall('ringing', callSeq);
}
handleCallHangup(callTopic, callSeq) {
const topic = this.tinode.getTopic(callTopic);
if (!topic) {
return;
}
topic.videoCall('hang-up', callSeq);
}
handleCallSendOffer(callTopic, callSeq, sdp) {
const topic = this.tinode.getTopic(callTopic);
if (!topic) {
return;
}
topic.videoCall('offer', callSeq, sdp);
}
handleCallIceCandidate(callTopic, callSeq, candidate) {
const topic = this.tinode.getTopic(callTopic);
if (!topic) {
return;
}
topic.videoCall('ice-candidate', callSeq, candidate);
}
handleCallSendAnswer(callTopic, callSeq, sdp) {
const topic = this.tinode.getTopic(callTopic);
if (!topic) {
return;
}
topic.videoCall('answer', callSeq, sdp);
}
handleCallClose() {
if (this.state.callTimeout) {
console.log('closing call timeout ', this.state.callTimeout);
clearTimeout(this.state.callTimeout);
}
this.setState({
callTopic: undefined,
callState: CALL_STATE_NONE
});
}
handleCallAcceptCall(topicName) {
const topic = this.tinode.getTopic(topicName);
if (!topic) {
return;
}
if (topic.isSubscribed()) {
this.handleTopicSelected(this.state.callTopic);
this.setState({
callState: CALL_STATE_IN_PROGRESS
});
} else {
// We need to switch and subscribe to callTopic first.
this.setState({
callShouldStart: true,
}, () => this.handleTopicSelected(this.state.callTopic));
}
}
handleInfoMessage(info) {
if (info.what != 'call') {
return;
}
switch (info.event) {
case 'invite':
if (info.from != this.state.myUserId) {
// Incoming call.
this.setState({
callTopic: info.src,
callState: CALL_STATE_INCOMING_RECEIVED,
callSeq: info.seq
});
}
break;
case 'accept':
console.log('TinodeWeb: received accept msg ', info);
// If another my session has accepted the call.
if (Tinode.isMeTopicName(info.topic) && this.tinode.isMe(info.from)) {
this.setState({
callTopic: null,
callState: CALL_STATE_NONE,
callSeq: null
});
return;
}
if (info.topic == this.state.callTopic) {
// Update state.
this.setState({callState: CALL_STATE_IN_PROGRESS});
}
break;
case 'hang-up':
// Remote hangup.
this.handleCallClose();
break;
}
}
render() {
return (
<div id="app-container" ref={this.selfRef}>
@@ -1774,6 +1967,16 @@ class TinodeWeb extends React.Component {
forwardMessage={this.state.forwardMessage}
onCancelForwardMessage={this.handleHideForwardDialog}
callTopic={this.state.callTopic}
callSeq={this.state.callSeq}
callState={this.state.callState}
onCallHangup={this.handleCallHangup}
onCallInvite={this.handleCallInvite}
onCallSendOffer={this.handleCallSendOffer}
onCallIceCandidate={this.handleCallIceCandidate}
onCallSendAnswer={this.handleCallSendAnswer}
errorText={this.state.errorText}
errorLevel={this.state.errorLevel}
errorAction={this.state.errorAction}
@@ -1788,7 +1991,8 @@ class TinodeWeb extends React.Component {
showContextMenu={this.handleShowContextMenu}
onChangePermissions={this.handleChangePermissions}
onNewChat={this.handleNewChatInvitation}
sendMessage={this.handleSendMessage} />
sendMessage={this.handleSendMessage}
onVideoCallClosed={this.handleCallClose} />
{this.state.infoPanel ?
<InfoView
@@ -1826,6 +2030,21 @@ class TinodeWeb extends React.Component {
:
null
}
{this.state.callTopic && this.state.callState == CALL_STATE_INCOMING_RECEIVED ?
<IncomingCallView
tinode={this.tinode}
onClose={this.handleCallClose}
topic={this.state.callTopic}
seq={this.state.callSeq}
callState={this.state.callState}
onRinging={this.handleCallRinging}
onAcceptCall={this.handleCallAcceptCall}
onReject={this.handleCallHangup}
/>
:
null
}
</div>
);
}
+381
View File
@@ -0,0 +1,381 @@
// CallPanel: displays local and remote viewports and controls.
import React from 'react';
import { FormattedMessage } from 'react-intl';
import LetterTile from '../widgets/letter-tile.jsx';
import { CALL_WEBRTC_CONFIG, MAX_TITLE_LENGTH } from '../config.js';
import { CALL_STATE_OUTGOING_INITATED, CALL_STATE_IN_PROGRESS } from '../constants.js';
import { clipStr } from '../lib/utils.js'
export default class CallPanel extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
localStream: undefined,
pc: undefined,
previousOnInfo: undefined
};
this.localStreamConstraints = {
audio: true,
video: true
};
this.localRef = React.createRef();
this.remoteRef = React.createRef();
this.onInfo = this.onInfo.bind(this);
this.start = this.start.bind(this);
this.createPeerConnection = this.createPeerConnection.bind(this);
this.handleNegotiationNeededEvent = this.handleNegotiationNeededEvent.bind(this);
this.handleICECandidateEvent = this.handleICECandidateEvent.bind(this);
this.handleNewICECandidateMsg = this.handleNewICECandidateMsg.bind(this);
this.handleICEConnectionStateChangeEvent = this.handleICEConnectionStateChangeEvent.bind(this);
this.handleSignalingStateChangeEvent = this.handleSignalingStateChangeEvent.bind(this);
this.handleICEGatheringStateChangeEvent = this.handleICEGatheringStateChangeEvent.bind(this);
this.handleTrackEvent = this.handleTrackEvent.bind(this);
this.handleVideoOfferMsg = this.handleVideoOfferMsg.bind(this);
this.handleVideoAnswerMsg = this.handleVideoAnswerMsg.bind(this);
this.handleNewICECandidateMsg = this.handleNewICECandidateMsg.bind(this);
this.reportError = this.reportError.bind(this);
this.handleGetUserMediaError = this.handleGetUserMediaError.bind(this);
this.stop = this.stop.bind(this);
this.stopTracks = this.stopTracks.bind(this);
this.handleCloseClick = this.handleCloseClick.bind(this);
this.handleToggleCameraClick = this.handleToggleCameraClick.bind(this);
this.handleToggleMicClick = this.handleToggleMicClick.bind(this);
this.toggleMedia = this.toggleMedia.bind(this);
this.handleRemoteHangup = this.handleRemoteHangup.bind(this);
this.handleVideoCallAccepted = this.handleVideoCallAccepted.bind(this);
}
handleVideoCallAccepted(info) {
const pc = this.createPeerConnection();
const stream = this.state.localStream;
stream.getTracks().forEach(track => {
pc.addTrack(track, stream);
});
}
onInfo(info) {
if (info.what != 'call') {
return;
}
switch (info.event) {
case 'accept':
this.handleVideoCallAccepted(info);
break;
case 'offer':
this.handleVideoOfferMsg(info);
break;
case 'answer':
this.handleVideoAnswerMsg(info);
break;
case 'ice-candidate':
this.handleNewICECandidateMsg(info);
break;
case 'hang-up':
this.handleRemoteHangup(info);
break;
}
}
componentDidMount() {
const topic = this.props.topic;
this.previousOnInfo = topic.onInfo;
topic.onInfo = this.onInfo;
if ((this.props.callState == CALL_STATE_OUTGOING_INITATED ||
this.props.callState == CALL_STATE_IN_PROGRESS) && this.localRef.current) {
this.start();
}
}
componentWillUnmount() {
this.props.topic.onInfo = this.previousOnInfo;
this.stop();
}
start() {
if (this.state.localStream) {
console.log("You can't start a call because you already have one open!");
} else {
if (this.props.callState == CALL_STATE_IN_PROGRESS) {
// We apparently just accepted the call.
this.props.onInvite(this.props.topic.name, this.props.seq, this.props.callState);
return;
}
// This is an outgoing call waiting for the other side to pick up.
// Start local video.
navigator.mediaDevices.getUserMedia(this.localStreamConstraints)
.then(stream => {
this.setState({localStream: stream});
this.localRef.current.srcObject = stream;
// Send call invitation.
this.props.onInvite(this.props.topic.name, this.props.seq, this.props.callState);
})
.catch(this.handleGetUserMediaError);
}
}
createPeerConnection() {
var pc = new RTCPeerConnection(CALL_WEBRTC_CONFIG);
pc.onicecandidate = this.handleICECandidateEvent;
pc.oniceconnectionstatechange = this.handleICEConnectionStateChangeEvent;
pc.onicegatheringstatechange = this.handleICEGatheringStateChangeEvent;
pc.onsignalingstatechange = this.handleSignalingStateChangeEvent;
pc.onnegotiationneeded = this.handleNegotiationNeededEvent;
pc.ontrack = this.handleTrackEvent;
this.setState({pc: pc});
return pc;
}
handleVideoAnswerMsg(info) {
// Configure the remote description, which is the SDP payload
// in 'info' message.
var desc = new RTCSessionDescription(info.payload);
this.state.pc.setRemoteDescription(desc).catch(this.reportError);
}
reportError(err) {
this.props.onError(err.message, 'err');
}
handleNegotiationNeededEvent() {
this.state.pc.createOffer().then(offer => {
return this.state.pc.setLocalDescription(offer);
})
.then(_ => {
this.props.onSendOffer(this.props.topic.name, this.props.seq, this.state.pc.localDescription.toJSON());
})
.catch(this.reportError);
}
handleICECandidateEvent(event) {
if (event.candidate) {
this.props.onIceCandidate(this.props.topic.name, this.props.seq, event.candidate.toJSON());
}
}
handleNewICECandidateMsg(info) {
var candidate = new RTCIceCandidate(info.payload);
this.state.pc.addIceCandidate(candidate)
.catch(this.reportError);
}
handleICEConnectionStateChangeEvent(event) {
switch (this.state.pc.iceConnectionState) {
case 'closed':
case 'failed':
this.handleCloseClick();
break;
}
}
handleSignalingStateChangeEvent(event) {
if (this.state.pc.signalingState == 'closed') {
this.handleCloseClick();
}
}
handleICEGatheringStateChangeEvent(event) {
console.log('ICE gathering change state: ', event);
}
handleTrackEvent(event) {
// Remote video becomes available.
this.remoteRef.current.srcObject = event.streams[0];
// Make sure we display the title (peer's name) over the remote video.
this.forceUpdate();
}
handleGetUserMediaError(e) {
switch(e.name) {
case 'NotFoundError':
// Cannot start the call b/c no camera and/or microphone found.
this.reportError(e);
break;
case 'SecurityError':
case 'PermissionDeniedError':
// Do nothing; this is the same as the user canceling the call.
break;
default:
console.log('Error opening your camera and/or microphone: ' + e.message);
break;
}
// Make sure we shut down our end of the RTCPeerConnection so we're
// ready to try again.
this.handleCloseClick();
}
handleVideoOfferMsg(info) {
var localStream = null;
const pc = this.createPeerConnection();
var desc = new RTCSessionDescription(info.payload);
pc.setRemoteDescription(desc).then(() => {
return navigator.mediaDevices.getUserMedia(this.localStreamConstraints);
})
.then(stream => {
localStream = stream;
this.localRef.current.srcObject = stream;
this.setState({localStream: stream});
localStream.getTracks().forEach(track => {
pc.addTrack(track, localStream);
});
})
.then(() => {
return pc.createAnswer();
})
.then(answer => {
return pc.setLocalDescription(answer);
})
.then(() => {
this.props.onSendAnswer(this.props.topic.name, this.props.seq, pc.localDescription.toJSON());
})
.catch(this.handleGetUserMediaError);
}
handleRemoteHangup() {
this.handleCloseClick();
}
stopTracks(el) {
if (el == null) { return; }
let stream = el.srcObject;
if (stream == null) { return; }
let tracks = stream.getTracks();
if (tracks != null) {
tracks.forEach((track) => {
track.stop();
track.enabled = false;
});
}
el.srcObject = null;
el.src = '';
}
stop() {
this.stopTracks(this.localRef.current);
this.stopTracks(this.remoteRef.current);
if (this.state.pc != null) {
this.state.pc.ontrack = null;
this.state.pc.onremovetrack = null;
this.state.pc.onremovestream = null;
this.state.pc.onicecandidate = null;
this.state.pc.oniceconnectionstatechange = null;
this.state.pc.onsignalingstatechange = null;
this.state.pc.onicegatheringstatechange = null;
this.state.pc.onnegotiationneeded = null;
this.state.pc.close();
}
this.setState({pc: null});
}
handleCloseClick() {
this.stop();
this.props.onHangup(this.props.topic.name, this.props.seq);
}
// Mute/unmute audio/video (specified by kind).
toggleMedia(kind) {
const stream = this.state.localStream;
stream.getTracks().forEach(track => {
if (track.kind != kind) {
return;
}
track.enabled = !track.enabled;
});
// Make sure we redraw the mute/unmute icons (e.g. mic -> mic_off).
this.forceUpdate();
}
handleToggleCameraClick() {
this.toggleMedia('video');
}
handleToggleMicClick() {
this.toggleMedia('audio');
}
render() {
if (!this.props.topic) {
return null;
}
const remoteActive = this.remoteRef.current != null && this.remoteRef.current.srcObject != null;
const audioIcon = this.state.localStream != null && this.state.localStream.getAudioTracks()[0].enabled ? 'mic' : 'mic_off';
const videoIcon = this.state.localStream != null && this.state.localStream.getVideoTracks()[0].enabled ? 'videocam' : 'videocam_off';
return (
<>
<div id="topic-caption-panel" className="caption-panel">
{this.props.displayMobile ?
<a href="#" id="hide-message-view" onClick={(e) => {e.preventDefault(); this.props.onHideMessagesView();}}>
<i className="material-icons">arrow_back</i>
</a>
:
null}
<div className="avatar-box">
<LetterTile
tinode={this.props.tinode}
avatar={this.props.avatar}
topic={this.props.topic}
title={this.props.title} />
</div>
<div id="topic-title-group">
<div id="topic-title" className="panel-title">{
this.props.title ||
<i><FormattedMessage id="unnamed_topic" defaultMessage="Unnamed"
description="Title shown when the topic has no name" /></i>
}</div>
</div>
</div>
<div id="video-container">
<div className="video-container-panel">
<div className="video-elem">
<video id="localVideo" ref={this.localRef} autoPlay muted playsInline></video>
<div className="video-title">
<FormattedMessage id="calls_you_label"
defaultMessage="You" description="Shown over the local video screen" />
</div>
</div>
<div className="video-elem">
<video id="remoteVideo" ref={this.remoteRef} autoPlay playsInline></video>
{remoteActive ?
<div className="video-title">{clipStr(this.props.title, MAX_TITLE_LENGTH)}</div>
:
null}
</div>
</div>
<div id="video-container-controls">
<button className="video-call-hangup" onClick={this.handleCloseClick}>
<i className="material-icons">call_end</i>
</button>
<button className="video-call-toggle-media" onClick={this.handleToggleCameraClick}><i className="material-icons">{videoIcon}</i></button>
<button className="video-call-toggle-media" onClick={this.handleToggleMicClick}><i className="material-icons">{audioIcon}</i></button>
</div>
</div>
</>
);
}
};
+22 -1
View File
@@ -2,6 +2,7 @@
import React from 'react';
import { FormattedMessage, injectIntl } from 'react-intl';
import FormattedDuration from 'react-intl-formatted-duration';
import { Drafty, Tinode } from 'tinode-sdk';
import Attachment from './attachment.jsx';
@@ -9,7 +10,7 @@ import LetterTile from './letter-tile.jsx';
import ReceivedMarker from './received-marker.jsx'
import { fullFormatter } from '../lib/formatters.js';
import { sanitizeUrl } from '../lib/utils.js';
import { isVideoCall, sanitizeUrl } from '../lib/utils.js';
class BaseChatMessage extends React.PureComponent {
constructor(props) {
@@ -140,6 +141,26 @@ class BaseChatMessage extends React.PureComponent {
}, this);
const tree = Drafty.format(content, fullFormatter, this.formatterContext);
content = React.createElement(React.Fragment, null, tree);
} else if (this.props.deleted) {
// Message represents a range of deleted messages.
content = <><i className="material-icons gray">block</i> <i className="gray">
<FormattedMessage id="deleted_content"
defaultMessage="content deleted" description="Shown when messages are deleted" />
</i></>
} else if (isVideoCall(this.props.mimeType)) {
// Video calls.
const direction = this.props.response ? 'call_received' : 'call_made';
const text = this.props.response ?
<FormattedMessage id="calls_incoming"
defaultMessage="Incoming Call" description="Incoming call label" /> :
<FormattedMessage id="calls_outgoing"
defaultMessage="Outgoing Call" description="Outgoing call label" />;
const isCallDropped = this.props.content == 'disconnected';
content = <><i className="material-icons" style={{color: isCallDropped ? 'red' : 'green'}}>{direction}</i>{text}
{ !isCallDropped && this.props.duration ?
[' (', <FormattedDuration seconds={this.props.duration / 1000} format="{hours} {minutes} {seconds}" />, ')'] :
null}
</>
} else if (typeof content != 'string') {
content = <><i className="material-icons gray">warning_amber</i> <i className="gray">
<FormattedMessage id="invalid_content"
+7
View File
@@ -9,6 +9,7 @@ import Contact from './contact.jsx';
import ContactAction from './contact-action.jsx';
import { makeImageUrl } from '../lib/blob-helpers.js';
import { isVideoCall } from '../lib/utils.js'
import { MESSAGE_PREVIEW_LENGTH } from '../config.js';
@@ -77,12 +78,16 @@ class ContactList extends React.Component {
c.private.join(',') : (c.private ? c.private.comment : null);
let preview;
let forwarded;
let previewIsVideoCall;
let previewIsResponse;
let deliveryStatus;
if (!this.props.showMode && c.latestMessage) {
const msg = c.latestMessage(true);
if (msg) {
forwarded = msg.head ? msg.head.forwarded : null;
deliveryStatus = msg._status || c.msgStatus(msg, true);
previewIsVideoCall = msg.head ? isVideoCall(msg.head.mime) : null;
previewIsResponse = msg.from != this.props.myUserId;
if (msg.content) {
preview = typeof msg.content == 'string' ?
msg.content.substr(0, MESSAGE_PREVIEW_LENGTH) :
@@ -98,6 +103,8 @@ class ContactList extends React.Component {
avatar={makeImageUrl(c.public ? c.public.photo : null)}
comment={comment}
preview={preview}
previewIsVideoCall={previewIsVideoCall}
previewIsResponse={previewIsResponse}
forwarded={forwarded}
received={deliveryStatus}
unread={this.props.showUnread ? c.unread : 0}
+4 -1
View File
@@ -70,7 +70,10 @@ class Contact extends React.Component {
// The this.props.preview contains alreay shortened Drafty or string.
let preview;
if (typeof this.props.preview == 'string') {
if (this.props.previewIsVideoCall) {
preview = <><i className="material-icons">{this.props.previewIsResponse ? 'call_received' : 'call_made'}</i>
<i className="material-icons">video_call</i></>;
} else if (typeof this.props.preview == 'string') {
preview = this.props.preview;
} else if (Drafty.isValid(this.props.preview)) {
preview = React.createElement(React.Fragment, null, Drafty.format(this.props.preview, previewFormatter,
+1 -1
View File
@@ -143,7 +143,7 @@ class TopicSecurity extends React.PureComponent {
this.props.onShowAlert(
formatMessage(messages.report_chat), // title
formatMessage(messages.report_chat_warning), // content
(() => { this.props.onReportTopic(this.props.topic); }), // onConfirm
(_ => { this.props.onReportTopic(this.props.topic); }), // onConfirm
null, // "OK"
true, // Show Reject button
null // "Cancel"
+4530 -182
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+116 -9
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,3 +1,3 @@
// This is a generated file. Don't edit.
const PACKAGE_VERSION = "0.19.0-alpha1";
const PACKAGE_VERSION = "0.20.0-alpha1";