merge devel

This commit is contained in:
or-else
2025-02-08 19:50:34 +03:00
133 changed files with 9381 additions and 9444 deletions
+13
View File
@@ -126,3 +126,16 @@ export const LINK_TERMS_OF_SERVICE = 'https://tinode.co/terms.html';
// happen until the first message. Setting this option to true enables
// immediate subscription.
export const IMMEDIATE_P2P_SUBSCRIPTION = false;
// URL schemes permitted to appear in links (i.e. no javascript: scheme).
export const CLICKABLE_URL_SCHEMES = ['http', 'https', 'ftp', 'ftps'];
// Size of the QR code in the app.
export const QRCODE_SIZE = 128;
// How much time has to pass to consider computer awoken from sleep (80 sec).
// Chrome throttles ticker events in inactive tabs to once per minute. This
// value ensures that these once-a-minute events do not trigger a reconnect.
export const WAKE_UP_TIMEOUT = 80000;
// Timer interval between checks if computer woke up from sleep (1 sec).
export const WAKE_UP_TICK = 1000;
+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
+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
+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
+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
File diff suppressed because one or more lines are too long
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
+7
View File
@@ -1668,5 +1668,12 @@
"description": "Call to action to join a conference call",
"missing": false,
"obsolete": false
},
"scan_qr_code": {
"translation": "QR-Code scannen",
"defaultMessage": "Scan QR Code",
"description": "Title for scanning QR code",
"missing": false,
"obsolete": false
}
}
+7
View File
@@ -1668,5 +1668,12 @@
"description": "Call to action to join a conference call",
"missing": false,
"obsolete": false
},
"scan_qr_code": {
"translation": "Scan QR Code",
"defaultMessage": "Scan QR Code",
"description": "Title for scanning QR code",
"missing": false,
"obsolete": false
}
}
+7
View File
@@ -1668,5 +1668,12 @@
"description": "Call to action to join a conference call",
"missing": false,
"obsolete": false
},
"scan_qr_code": {
"translation": "Escanear código QR",
"defaultMessage": "Scan QR Code",
"description": "Title for scanning QR code",
"missing": false,
"obsolete": false
}
}
+4
View File
@@ -808,6 +808,10 @@
"defaultMessage": "save",
"description": "Call to save an attachment"
},
"scan_qr_code": {
"defaultMessage": "Scan QR Code",
"description": "Title for scanning QR code"
},
"search_for_contacts": {
"defaultMessage": "Use search to find contacts",
"description": "Text shown in contacts view when user entered no search query."
+7
View File
@@ -1668,5 +1668,12 @@
"description": "Call to action to join a conference call",
"missing": false,
"obsolete": false
},
"scan_qr_code": {
"translation": "Scanner le code QR",
"defaultMessage": "Scan QR Code",
"description": "Title for scanning QR code",
"missing": false,
"obsolete": false
}
}
+7
View File
@@ -1668,5 +1668,12 @@
"description": "Call to action to join a conference call",
"missing": false,
"obsolete": false
},
"scan_qr_code": {
"translation": "QR 코드 스캔",
"defaultMessage": "Scan QR Code",
"description": "Title for scanning QR code",
"missing": false,
"obsolete": false
}
}
+7
View File
@@ -1668,5 +1668,12 @@
"description": "Call to action to join a conference call",
"missing": false,
"obsolete": false
},
"scan_qr_code": {
"translation": "Scanați codul QR",
"defaultMessage": "Scan QR Code",
"description": "Title for scanning QR code",
"missing": false,
"obsolete": false
}
}
+7
View File
@@ -1668,5 +1668,12 @@
"description": "Call to action to join a conference call",
"missing": false,
"obsolete": false
},
"scan_qr_code": {
"translation": "Отсканируйте QR код",
"defaultMessage": "Scan QR Code",
"description": "Title for scanning QR code",
"missing": false,
"obsolete": false
}
}
+1679
View File
File diff suppressed because it is too large Load Diff
+1679
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -1668,5 +1668,12 @@
"description": "Call to action to join a conference call",
"missing": false,
"obsolete": false
},
"scan_qr_code": {
"translation": "掃描二維碼",
"defaultMessage": "Scan QR Code",
"description": "Title for scanning QR code",
"missing": false,
"obsolete": false
}
}
+7
View File
@@ -1668,5 +1668,12 @@
"description": "Call to action to join a conference call",
"missing": false,
"obsolete": false
},
"scan_qr_code": {
"translation": "扫描二维码",
"defaultMessage": "Scan QR Code",
"description": "Title for scanning QR code",
"missing": false,
"obsolete": false
}
}
+2
View File
@@ -31,6 +31,8 @@ const messageLoader = {
'ko': _ => import('./i18n.min/ko.json'),
'ro': _ => import('./i18n.min/ro.json'),
'ru': _ => import('./i18n.min/ru.json'),
'th': _ => import('./i18n.min/th.json'),
'uk': _ => import('./i18n.min/uk.json'),
'zh': _ => import('./i18n.min/zh.json'),
'zh-TW': _ => import('./i18n.min/zh-TW.json')
};
+9 -2
View File
@@ -10,10 +10,11 @@ import InlineVideo from '../widgets/inline-video.jsx';
import LazyImage from '../widgets/lazy-image.jsx'
import UploadingImage from '../widgets/uploading-image.jsx'
import { BROKEN_IMAGE_SIZE, IMAGE_THUMBNAIL_DIM, NO_DIMENSIONS_VIDEO, REM_SIZE, VIDEO_THUMBNAIL_WIDTH } from '../config.js';
import { BROKEN_IMAGE_SIZE, CLICKABLE_URL_SCHEMES, IMAGE_THUMBNAIL_DIM, NO_DIMENSIONS_VIDEO, REM_SIZE,
VIDEO_THUMBNAIL_WIDTH } from '../config.js';
import { base64ToBlob, blobToBase64, fitImageSize, imageScaled } from './blob-helpers.js';
import { idToColorClass, secondsToTime, shortenFileName } from './strformat.js';
import { cancelablePromise, sanitizeUrlForMime } from './utils.js';
import { cancelablePromise, sanitizeUrl, sanitizeUrlForMime } from './utils.js';
const messages = defineMessages({
drafty_form: {
@@ -109,6 +110,12 @@ export function fullFormatter(style, data, values, key, stack) {
// Get text which will be sent back when the button is clicked.
attr['data-title'] = inner.join('');
break;
case 'LN':
if (attr) {
// Ensure only safe URL schemes are clickable.
attr.href = typeof attr.href == 'string' ? sanitizeUrl(attr.href, CLICKABLE_URL_SCHEMES) : '';
}
break;
case 'MN':
// Mention
attr.className = 'mention'
+43 -2
View File
@@ -108,9 +108,12 @@ export function asEmail(val) {
// example.html - ok
// https:example.com - not ok.
// http:/example.com - not ok.
// //example.com or \\example.com - not ok.
// ' ↲ https://example.com' - not ok. (↲ means carriage return)
export function isUrlRelative(url) {
return url && !/^\s*([a-z][a-z0-9+.-]*:|\/\/)/im.test(url);
// Replacing backslashes with forward slashes to mimic JS URL parser then testing for
// 'scheme:' and '//' with optional space at the start.
return url && !/^\s*([a-z][a-z0-9+.-]*:|\/\/)/im.test(url.replace(/\\/g, '/'));
}
// Ensure URL does not present an XSS risk. Optional allowedSchemes may contain an array of
@@ -121,7 +124,10 @@ export function sanitizeUrl(url, allowedSchemes) {
}
// Strip control characters and whitespace. They are not valid URL characters anyway.
url = url.replace(/[^\x20-\x7E]/gmi, '').trim();
url = url.replace(/[^\x21-\x7E]/gmi, '').trim();
// Replace backslashes with forward slashes. They will be replaced in the URL parser anyway.
url = url.replace(/\\/g, '/');
// Relative URLs are safe.
// Relative URL does not start with ':', abcd123: or '//'.
@@ -166,6 +172,41 @@ export function sanitizeUrlForMime(url, mimeMajor) {
return null;
}
// Append query parameter 'asatt=1' to the URL.
// It will cause Tinode server to add 'Content-Disposition: attachment' header when serving it.
// The URL is a string. The URL here is always absolute.
export function urlAsAttachment(url) {
// TODO: check if URL is local or remote, i.e. compare to window.location.origin.
/*
if (typeof window.location == 'object') {
if (!url.startsWith(window.location.origin)) {
return url;
}
}
*/
// Check if the URL is a data or blob. Do not alter such URLs.
if (url.startsWith('data:') || url.startsWith('blob:')) {
return url;
}
// Split URL into host+path, query, fragment.
let query = '', fragment = '';
const idxF = url.indexOf('#');
if (idxF > 0) {
fragment = url.substring(idxF+1);
url = url.substring(0, idxF);
}
const idxQ = url.indexOf('?');
if (idxQ > 0) {
query = url.substring(idxQ+1);
url = url.substring(0, idxQ);
}
// Add parameter and reassemble.
const params = new URLSearchParams(query);
params.append('asatt', '1');
return `${url}?${params.toString()}` + (fragment ? `#${fragment}` : '');
}
// Given message's received status, return name and color of a delivery indicator icon.
export function deliveryMarker(received) {
switch (received) {
+37 -4
View File
@@ -12,6 +12,7 @@ import ErrorPanel from '../widgets/error-panel.jsx';
import GroupManager from '../widgets/group-manager.jsx';
import MenuCancel from '../widgets/menu-cancel.jsx';
import PermissionsEditor from '../widgets/permissions-editor.jsx';
import ShowQRCode from '../widgets/show-qrcode.jsx';
import TopicCommonView from './topic-common-view.jsx';
import TopicSecurity from '../widgets/topic-security.jsx';
@@ -76,6 +77,11 @@ const messages = defineMessages({
defaultMessage: 'Edit permissions',
description: 'Menu item [Edit permissions]'
},
qrcode: {
id: 'scan_qr_code',
defaultMessage: 'Scan QR Code',
description: 'Title for scanning QR code'
},
});
class InfoView extends React.Component {
@@ -120,6 +126,8 @@ class InfoView extends React.Component {
this.handleUnarchive = this.handleUnarchive.bind(this);
this.handlePermissionsChanged = this.handlePermissionsChanged.bind(this);
this.handleLaunchPermissionsEditor = this.handleLaunchPermissionsEditor.bind(this);
this.handleCopyID = this.handleCopyID.bind(this);
this.handleShowQRCode = this.handleShowQRCode.bind(this);
this.handleShowAddMembers = this.handleShowAddMembers.bind(this);
this.handleMemberUpdateRequest = this.handleMemberUpdateRequest.bind(this);
this.handleMemberSelected = this.handleMemberSelected.bind(this);
@@ -368,6 +376,16 @@ class InfoView extends React.Component {
this.props.onNavigate(`perm/${which}`);
}
handleCopyID(e) {
e.preventDefault();
navigator.clipboard.writeText(this.props.myUserId);
}
handleShowQRCode(e) {
e.preventDefault();
this.props.onNavigate('qrcode');
}
handleShowAddMembers(e) {
e.preventDefault();
this.props.onInitFind();
@@ -514,6 +532,11 @@ class InfoView extends React.Component {
onLaunchPermissionsEditor={this.handleLaunchPermissionsEditor}
onNavigate={this.props.onNavigate} />
:
view == 'qrcode' ?
<ShowQRCode
uri={Tinode.URI_TOPIC_ID_PREFIX + this.props.tinode.myUserId}
onCancel={this.handleBackNavigate} />
:
<div id="info-view-content" className="scrollable-panel">
<div className="panel-form-column">
<a href="#" className="flat-button float-right" onClick={(e) => {e.preventDefault(); this.props.onNavigate('general');}}>
@@ -546,10 +569,20 @@ class InfoView extends React.Component {
</div>
: null
}
<div className="group">
<label className="small"><FormattedMessage id="label_user_id" defaultMessage="ID:"
description="Label for user address (ID)" /></label>&nbsp;
<tt>{this.state.address}</tt>
<div className="panel-form-row">
<div>
<label className="small"><FormattedMessage id="label_user_id" defaultMessage="ID:"
description="Label for user address (ID)" /></label>&nbsp;
<tt>{this.state.address}</tt>
</div>
<div style={{marginLeft: 'auto'}}>
&nbsp;<a href="#" onClick={this.handleCopyID}>
<i className="material-icons">content_copy</i>
</a>&nbsp;
&nbsp;<a href="#" onClick={this.handleShowQRCode}>
<i className="material-icons">qr_code</i>
</a>&nbsp;
</div>
</div>
<div className="group">
<BadgeList trustedBadges={this.state.trustedBadges} />
+1 -1
View File
@@ -1685,7 +1685,7 @@ class MessagesView extends React.Component {
<>
<div id="topic-caption-panel" className="caption-panel">
{this.props.displayMobile ?
<a href="#" id="hide-message-view" onClick={(e) => {e.preventDefault(); this.props.onHideMessagesView();}}>
<a href="#" id="hide-message-view" onClick={(e) => {e.preventDefault(); this.leave(this.state.topic); this.props.onHideMessagesView();}}>
<i className="material-icons">arrow_back</i>
</a>
:
+1
View File
@@ -113,6 +113,7 @@ class NewTopicView extends React.Component {
onError={this.props.onError} /> :
this.state.tabSelected === 'byid' ?
<NewTopicById
myURI={Tinode.URI_TOPIC_ID_PREFIX + this.props.tinode.getCurrentUserID()}
onSubmit={this.handleGroupByID}
onError={this.props.onError} /> :
<div className="flex-column">
+15 -4
View File
@@ -19,7 +19,7 @@ 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 } from '../config.js';
MEDIA_BREAKPOINT, WAKE_UP_TICK, WAKE_UP_TIMEOUT } from '../config.js';
import { CALL_STATE_NONE, CALL_STATE_OUTGOING_INITATED,
CALL_STATE_INCOMING_RECEIVED, CALL_STATE_IN_PROGRESS,
CALL_HEAD_STARTED } from '../constants.js';
@@ -298,6 +298,18 @@ class TinodeWeb extends React.Component {
console.warn("Your browser does not support BroadcastChannel. Some features will not be available");
}
// Detect if computer just woke up from sleep. Checks how much time has passed since the last tick.
// If too much time has passed then the computer was asleep.
this.lastWakeUpCheck = new Date().getTime();
this.wakeUpTicker = setInterval(_ => {
const now = new Date().getTime();
if (now - this.lastWakeUpCheck > WAKE_UP_TIMEOUT) {
// Trigger reconnect.
this.handleOnlineOn();
}
this.lastWakeUpCheck = now;
}, WAKE_UP_TICK);
// Window/tab visible or invisible for pausing timers.
document.addEventListener('visibilitychange', this.handleVisibilityEvent);
@@ -339,9 +351,6 @@ class TinodeWeb extends React.Component {
});
}
this.readTimer = null;
this.readTimerCallback = null;
// Parse the hash navigation params.
const parsedNav = HashNavigation.parseUrlHash(window.location.hash);
// Maybe navigate to home screen.
@@ -364,6 +373,8 @@ class TinodeWeb extends React.Component {
window.removeEventListener('online', this.handleOnlineOn);
window.removeEventListener('offline', this.handleOnlineOff);
document.removeEventListener('visibilitychange', this.handleVisibilityEvent);
clearInterval(this.wakeUpTicker);
}
// Setup transport (usually websocket) and server address. This will terminate connection with the server.
+10 -2
View File
@@ -159,6 +159,7 @@ class BaseChatMessage extends React.PureComponent {
const sideClass = this.props.sequence + ' ' + (this.props.response ? 'left' : 'right');
const bubbleClass = (this.props.sequence == 'single' || this.props.sequence == 'last') ? 'bubble tip' : 'bubble';
const avatar = this.props.userAvatar || true;
let textSizeClass = 'message-content';
const fullDisplay = (this.props.isGroup && this.props.response &&
(this.props.sequence == 'single' || this.props.sequence == 'last'));
@@ -185,7 +186,14 @@ class BaseChatMessage extends React.PureComponent {
}, this);
const tree = Drafty.format(content, fullFormatter, this.formatterContext);
content = React.createElement(React.Fragment, null, tree);
} else if (typeof content != 'string') {
} else if (typeof content == 'string') {
// Make font bigger for emoji-only messages.
// Must use 'new RegExp(...)' because React does not like /{1-5}/.
if (new RegExp('^\\p{RGI_Emoji}{1,5}$', 'v').test(content || '')) {
// Content consists of 1-5 emoji characters. Count how many and use it to increase the font size.
textSizeClass += ' emoji-' + (content || '').match(/\p{RGI_Emoji}/vg).length;
}
} else {
content = <><i className="material-icons gray">warning_amber</i> <i className="gray">
<FormattedMessage id="invalid_content"
defaultMessage="invalid content" description="Shown when the message is unreadable" /></i></>
@@ -207,7 +215,7 @@ class BaseChatMessage extends React.PureComponent {
<div>
<div className={bubbleClass}>
<div className="content-meta">
<div className="message-content">
<div className={textSizeClass}>
{content}
{attachments}
</div>
+5 -1
View File
@@ -5,6 +5,7 @@ import SendMessage from './send-message.jsx';
import { REM_SIZE } from '../config.js';
import { fitImageSize } from '../lib/blob-helpers.js';
import { bytesToHumanSize, shortenFileName } from '../lib/strformat.js';
import { urlAsAttachment } from '../lib/utils.js';
export default class ImagePreview extends React.PureComponent {
constructor(props) {
@@ -71,13 +72,16 @@ export default class ImagePreview extends React.PureComponent {
const width = this.props.content.width || '-';
const height = this.props.content.height || '-';
const download_url = urlAsAttachment(this.props.content.url);
return (
<div id="image-preview">
<div id="image-preview-caption-panel">
{this.props.onSendMessage ?
<span>{fname}</span>
:
<a href={this.props.content.url} download={this.props.content.filename}>
<a href={download_url} download={this.props.content.filename}>
<i className="material-icons">file_download</i> <FormattedMessage
id="download_action" defaultMessage="download" description="Call to action [download]" />
</a>
+20 -2
View File
@@ -1,6 +1,9 @@
import QRCode from 'qrcodejs';
import React from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import { QRCODE_SIZE } from '../config';
const messages = defineMessages({
invalid_id: {
id: 'error_invalid_id',
@@ -13,6 +16,8 @@ class NewTopicById extends React.PureComponent {
constructor(props) {
super(props);
this.qrCodeRef = React.createRef();
this.state = {
groupId: '',
};
@@ -22,6 +27,14 @@ class NewTopicById extends React.PureComponent {
this.handleSubmit = this.handleSubmit.bind(this);
}
componentDidMount() {
new QRCode(this.qrCodeRef.current, {
text: this.props.myURI,
width: QRCODE_SIZE,
height: QRCODE_SIZE,
});
}
handleChange(e) {
this.setState({groupId: e.target.value});
}
@@ -36,7 +49,7 @@ class NewTopicById extends React.PureComponent {
e.preventDefault();
if (this.state.groupId) {
const name = this.state.groupId.trim();
const prefix = name.substr(0, 3);
const prefix = name.substring(0, 3);
if (name.length > 3 && ['usr', 'grp', 'chn'].includes(prefix)) {
this.props.onSubmit(name);
} else {
@@ -53,7 +66,7 @@ class NewTopicById extends React.PureComponent {
description="Prompt for entering user or group ID">{
(prompt) => <input type="text" placeholder={prompt}
value={this.state.groupId} onChange={this.handleChange}
onKeyPress={this.handleKeyPress} required autoFocus />
onKeyDown={this.handleKeyPress} required autoFocus />
}</FormattedMessage>
</div>
<div className="dialog-buttons">
@@ -62,6 +75,11 @@ class NewTopicById extends React.PureComponent {
description="Button [Subscribe]" />
</button>
</div>
<br />
<div className="panel-form-column">
<label className="small">Scan my ID:</label>
<div className="qr-code" ref={this.qrCodeRef} />
</div>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
import QRCode from 'qrcodejs';
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { QRCODE_SIZE } from '../config';
export default class ShowQRCode extends React.PureComponent {
constructor(props) {
super(props);
this.qrCodeRef = React.createRef();
}
componentDidMount() {
new QRCode(this.qrCodeRef.current, {
text: this.props.uri,
width: QRCODE_SIZE,
height: QRCODE_SIZE,
});
}
render() {
return (
<div className="panel-form-column">
<br />
<div className="qr-code" ref={this.qrCodeRef} />
<div className="dialog-buttons">
<button className="outline" onClick={this.props.onCancel}>
<FormattedMessage id="button_ok" defaultMessage="OK" description="Button [OK]" />
</button>
</div>
</div>
);
}
};