Convert im_webapp from static mock prototypes to real backend integration
Implements all 11 product chapters (auth, messaging, contacts, groups,
files/favorites, calls, workplace/reports, settings, security) against the
real /v1/im REST API and native Tinode WS protocol, replacing the hardcoded
mock data the ba8f48ee prototype commit shipped with. Adds src/lib/im-api.js
as the REST client, plus supporting fixes across messages-view.jsx and the
attachment/upload/download pipeline (wrong SDK auth headers, group topic
naming, seq/timezone edge cases) found via live end-to-end testing against
the real backend.
Backup checkpoint before reconciling with origin/dev's parallel rewrite.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
ba8f48ee83
commit
251c9de87c
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import FileProgress from './file-progress.jsx';
|
||||
import { createFileDownloader } from '../lib/im-api.js';
|
||||
import { bytesToHumanSize } from '../lib/strformat.js';
|
||||
import { isUrlRelative, sanitizeUrl } from '../lib/utils.js';
|
||||
|
||||
@@ -24,7 +25,11 @@ export default class Attachment extends React.Component {
|
||||
return;
|
||||
}
|
||||
|
||||
const downloader = this.props.tinode.getLargeFileHelper();
|
||||
// Same wrong-header bug as content-library-view.jsx's DownloadPage (X-Tinode-APIKey/
|
||||
// X-Tinode-Auth, neither recognized by this backend) -- confirmed live: clicking "保存" on any
|
||||
// real chat file attachment 403'd with "valid API key required". createFileDownloader() is the
|
||||
// fixed-headers drop-in with the same download()/cancel() interface.
|
||||
const downloader = createFileDownloader(this.props.tinode);
|
||||
this.setState({downloader: downloader});
|
||||
downloader.download(url, filename, mimetype,
|
||||
loaded => this.setState({progress: loaded / this.props.size}),
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
// Batch Forward Dialog: reached from multi-select mode (Ch.4 4.17-4.19). Distinct from
|
||||
// ForwardDialog (the single-message quick-forward from the message's own context menu, which
|
||||
// stays on the native Tinode pub/sub path) -- this one posts to the dedicated
|
||||
// POST /v1/im/messages/forward HTTP endpoint since it needs to fan out to several targets in
|
||||
// one call, matching docs/images/转发-单选.png / 转发-多选.png / 转发—逐条转发.png.
|
||||
import React from 'react';
|
||||
|
||||
import { Tinode } from 'tinode-sdk';
|
||||
|
||||
import ContactList from './contact-list.jsx';
|
||||
import SearchContacts from './search-contacts.jsx';
|
||||
|
||||
import { forwardMessages } from '../lib/im-api.js';
|
||||
import { topicDisplayName } from '../lib/utils.js';
|
||||
|
||||
export default class BatchForwardDialog extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
query: null,
|
||||
multiSelect: false,
|
||||
selectedTargets: [],
|
||||
confirmTargets: null,
|
||||
sending: false,
|
||||
error: null
|
||||
};
|
||||
|
||||
this.handleEscapeKey = this.handleEscapeKey.bind(this);
|
||||
this.handleClose = this.handleClose.bind(this);
|
||||
this.handleSearchContacts = this.handleSearchContacts.bind(this);
|
||||
this.handleContactSelected = this.handleContactSelected.bind(this);
|
||||
this.handleToggleMultiSelect = this.handleToggleMultiSelect.bind(this);
|
||||
this.handleConfirmTargets = this.handleConfirmTargets.bind(this);
|
||||
this.handleCancelConfirm = this.handleCancelConfirm.bind(this);
|
||||
this.handleSend = this.handleSend.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.onInitFind();
|
||||
document.addEventListener('keyup', this.handleEscapeKey, false);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('keyup', this.handleEscapeKey, false);
|
||||
}
|
||||
|
||||
handleEscapeKey(e) {
|
||||
if (e.keyCode === 27) {
|
||||
this.props.hide();
|
||||
}
|
||||
}
|
||||
|
||||
handleClose(e) {
|
||||
e.preventDefault();
|
||||
this.props.hide();
|
||||
}
|
||||
|
||||
handleSearchContacts(query) {
|
||||
this.setState({query: Tinode.isNullValue(query) ? null : query});
|
||||
this.props.onSearchContacts(query);
|
||||
}
|
||||
|
||||
handleToggleMultiSelect(e) {
|
||||
e.preventDefault();
|
||||
this.setState(s => ({multiSelect: !s.multiSelect, selectedTargets: []}));
|
||||
}
|
||||
|
||||
// Single-select: picking a contact goes straight to the confirm sheet (docs/images/转发-单选.png).
|
||||
// Multi-select: toggles membership; "确定" (separate button below) advances to confirm.
|
||||
handleContactSelected(uid) {
|
||||
if (!this.state.multiSelect) {
|
||||
this.setState({confirmTargets: [uid]});
|
||||
return;
|
||||
}
|
||||
this.setState(s => ({
|
||||
selectedTargets: s.selectedTargets.includes(uid) ?
|
||||
s.selectedTargets.filter(t => t != uid) : s.selectedTargets.concat(uid)
|
||||
}));
|
||||
}
|
||||
|
||||
handleConfirmTargets(e) {
|
||||
e.preventDefault();
|
||||
if (this.state.selectedTargets.length) {
|
||||
this.setState({confirmTargets: this.state.selectedTargets.slice()});
|
||||
}
|
||||
}
|
||||
|
||||
handleCancelConfirm() {
|
||||
this.setState({confirmTargets: null, error: null});
|
||||
}
|
||||
|
||||
handleSend() {
|
||||
// seq must be a JSON string (server struct tag is `json:"seq,string"`), same as favoriteMessages.
|
||||
const sources = this.props.seqs.map(seq => ({topic: this.props.topicName, seq: String(seq)}));
|
||||
this.setState({sending: true, error: null});
|
||||
forwardMessages('separate', sources, this.state.confirmTargets).then(result => {
|
||||
const failed = (result && result.failed) || [];
|
||||
if (failed.length) {
|
||||
this.setState({sending: false, error: `${failed.length} 条转发失败,请重试`});
|
||||
return;
|
||||
}
|
||||
this.props.hide(true);
|
||||
}).catch(err => {
|
||||
this.setState({sending: false, error: err.message || '转发失败'});
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.confirmTargets) {
|
||||
const targets = this.state.confirmTargets.map(uid => this.props.tinode.getTopic(uid) || {name: uid, public: null});
|
||||
return (
|
||||
<div className="alert-container">
|
||||
<div className="forward-dialog batch-forward-confirm">
|
||||
<div className="title">发送给</div>
|
||||
<ul className="contact-box">
|
||||
{targets.map(t => <li className="batch-forward-target" key={t.name}>
|
||||
<span>{Tinode.isSelfTopicName(t.name) ? '已保存的消息' : topicDisplayName(t.name, t.public, t.name)}</span>
|
||||
<i className="material-icons">chevron_right</i>
|
||||
</li>)}
|
||||
</ul>
|
||||
<div className="batch-forward-comment">{'[逐条转发]共' + this.props.seqs.length + '条消息'}</div>
|
||||
{this.state.error ? <div className="auth-field-error">{this.state.error}</div> : null}
|
||||
<div className="batch-forward-actions">
|
||||
<button type="button" onClick={this.handleCancelConfirm} disabled={this.state.sending}>取消</button>
|
||||
<button type="button" className="primary" onClick={this.handleSend} disabled={this.state.sending}>
|
||||
{this.state.sending ? '发送中…' : '发送'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let contacts = this.state.query != null ? this.props.searchResults : this.props.contacts;
|
||||
contacts = contacts.filter((c) => (c.name != this.props.topicSelected) && c.acs.isJoiner() && c.acs.isWriter());
|
||||
|
||||
return (
|
||||
<div className="alert-container">
|
||||
<div className="forward-dialog">
|
||||
<div className="title with-control">
|
||||
<div>转发</div>
|
||||
<div>
|
||||
<a href="#" onClick={this.handleToggleMultiSelect}>{this.state.multiSelect ? '' : '多选'}</a>
|
||||
{this.state.multiSelect ?
|
||||
<a href="#" onClick={this.handleConfirmTargets}
|
||||
className={this.state.selectedTargets.length ? '' : 'disabled'}>确定</a> : null}
|
||||
<a href="#" onClick={this.handleClose}><i className="material-icons">close</i></a>
|
||||
</div>
|
||||
</div>
|
||||
<SearchContacts placeholder="搜索联系人" onSearchContacts={this.handleSearchContacts} />
|
||||
<ContactList
|
||||
tinode={this.props.tinode}
|
||||
contacts={contacts}
|
||||
myUserId={this.props.myUserId}
|
||||
emptyListMessage="搜索返回任何结果"
|
||||
showOnline={false}
|
||||
showUnread={false}
|
||||
showContextMenu={false}
|
||||
topicSelected={this.state.multiSelect ? this.state.selectedTargets : null}
|
||||
onTopicSelected={this.handleContactSelected} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -116,35 +116,44 @@ export default class CallIncoming extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const isAudioOnly = !!this.props.audioOnly;
|
||||
const ringing = this.props.callState == CALL_STATE_INCOMING_RECEIVED;
|
||||
const surfaceClass = isAudioOnly ? 'call-audio-surface' : 'call-video-surface';
|
||||
const photoStyle = this.state.avatar ? {'--incoming-call-photo': `url(${this.state.avatar})`} : null;
|
||||
return (
|
||||
<div className="alert-container">
|
||||
<div className="incoming-call">
|
||||
<div className="caller-card incoming pulse">
|
||||
<div className="avatar-box">
|
||||
<LetterTile
|
||||
authorizeURL={this.props.tinode.authorizeURL}
|
||||
avatar={this.state.avatar || true}
|
||||
topic={this.props.topic}
|
||||
title={this.state.fullName} />
|
||||
</div>
|
||||
<div className="caller-name">{truncateString(this.state.fullName, MAX_PEER_TITLE_LENGTH)}
|
||||
<BadgeList short={true} trustedBadges={this.state.trustedBadges} /></div>
|
||||
</div>
|
||||
<div id="controls">
|
||||
{this.props.callState == CALL_STATE_INCOMING_RECEIVED ?
|
||||
<>
|
||||
<button className="danger" onClick={this.handleRejectCall}>
|
||||
<i className="material-icons">call_end</i>
|
||||
</button>
|
||||
<button className="positive" onClick={this.handleAcceptCall}>
|
||||
<i className="material-icons">call</i>
|
||||
</button>
|
||||
</>
|
||||
:
|
||||
null
|
||||
}
|
||||
<div className={`call-fullscreen incoming-call-overlay ${surfaceClass} incoming`}>
|
||||
<div className="call-ambient" />
|
||||
{this.state.avatar &&
|
||||
<div className="incoming-call-photo-blur" style={photoStyle} />}
|
||||
<div className="call-peer-card">
|
||||
<div className="avatar-box call-product-avatar">
|
||||
<LetterTile
|
||||
authorizeURL={this.props.tinode.authorizeURL}
|
||||
avatar={this.state.avatar || true}
|
||||
topic={this.props.topic}
|
||||
title={this.state.fullName} />
|
||||
</div>
|
||||
<h1>{truncateString(this.state.fullName, MAX_PEER_TITLE_LENGTH)}
|
||||
<BadgeList short={true} trustedBadges={this.state.trustedBadges} /></h1>
|
||||
<p>{isAudioOnly ? '对方邀请你语音通话...' : '对方邀请你视频通话...'}</p>
|
||||
{ringing && <span className="call-ringing-dots"><i /><i /><i /></span>}
|
||||
</div>
|
||||
<footer className="call-incoming-actions">
|
||||
{ringing ?
|
||||
<>
|
||||
<button type="button" className="call-control danger" onClick={this.handleRejectCall}>
|
||||
<span><i className="material-icons">call_end</i></span>
|
||||
<small>拒绝</small>
|
||||
</button>
|
||||
<button type="button" className="call-control" onClick={this.handleAcceptCall}>
|
||||
<span><i className="material-icons">{isAudioOnly ? 'call' : 'videocam'}</i></span>
|
||||
<small>接听</small>
|
||||
</button>
|
||||
</>
|
||||
:
|
||||
null
|
||||
}
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+196
-37
@@ -17,6 +17,14 @@ const DIALING_SOUND = new Audio('audio/dialing.m4a');
|
||||
const VIDEO_MUTED_EVENT = 'video:muted';
|
||||
const VIDEO_UNMUTED_EVENT = 'video:unmuted';
|
||||
|
||||
function formatElapsedTime(totalSeconds) {
|
||||
const h = Math.floor(totalSeconds / 3600);
|
||||
const m = Math.floor((totalSeconds % 3600) / 60);
|
||||
const s = totalSeconds % 60;
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`;
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
already_in_call: {
|
||||
id: 'already_in_call',
|
||||
@@ -44,7 +52,13 @@ class CallPanel extends React.PureComponent {
|
||||
videoToggleInProgress: false,
|
||||
// Indicates if the remote peer has informed us that their camera is on.
|
||||
remoteVideoLive: false,
|
||||
// Seconds elapsed since the call was connected (remote media received).
|
||||
elapsedSeconds: 0,
|
||||
// Whether remote audio is routed to the loud speaker output (best-effort, see handleToggleSpeakerClick).
|
||||
speakerOn: true,
|
||||
};
|
||||
this.callStartedAt = null;
|
||||
this.elapsedTimerId = null;
|
||||
|
||||
this.localStreamConstraints = {
|
||||
audio: true,
|
||||
@@ -89,6 +103,11 @@ class CallPanel extends React.PureComponent {
|
||||
this.handleCloseClick = this.handleCloseClick.bind(this);
|
||||
this.handleToggleCameraClick = this.handleToggleCameraClick.bind(this);
|
||||
this.handleToggleMicClick = this.handleToggleMicClick.bind(this);
|
||||
this.handleToggleSpeakerClick = this.handleToggleSpeakerClick.bind(this);
|
||||
this.handleSwitchCameraClick = this.handleSwitchCameraClick.bind(this);
|
||||
|
||||
this.startElapsedTimer = this.startElapsedTimer.bind(this);
|
||||
this.stopElapsedTimer = this.stopElapsedTimer.bind(this);
|
||||
|
||||
this.handleRemoteHangup = this.handleRemoteHangup.bind(this);
|
||||
this.handleVideoCallAccepted = this.handleVideoCallAccepted.bind(this);
|
||||
@@ -106,24 +125,29 @@ class CallPanel extends React.PureComponent {
|
||||
this.bounds = {};
|
||||
|
||||
this.dragStart = (event) => {
|
||||
if (!event.target.classList.contains('draggable')) {
|
||||
if (!event.target.closest('.draggable')) {
|
||||
// Prevent dragging when clicking on buttons or other non-draggable elements.
|
||||
return;
|
||||
}
|
||||
if (window.matchMedia('(width<=640px)')) {
|
||||
if (window.matchMedia('(width<=640px)').matches) {
|
||||
// No dragging on small screens.
|
||||
return;
|
||||
}
|
||||
if (this.props.minimized && this.containerRef) {
|
||||
event.stopPropagation();
|
||||
this.containerRef.current.setPointerCapture(event.pointerId);
|
||||
this.containerRef.current.style.cursor = 'grabbing';
|
||||
this.bounds.left = this.containerRef.current.offsetLeft;
|
||||
this.bounds.top = this.containerRef.current.offsetTop;
|
||||
this.bounds.width = this.containerRef.current.offsetWidth;
|
||||
this.bounds.height = this.containerRef.current.offsetHeight;
|
||||
const el = this.containerRef.current;
|
||||
el.setPointerCapture(event.pointerId);
|
||||
el.style.cursor = 'grabbing';
|
||||
this.bounds.left = el.offsetLeft;
|
||||
this.bounds.top = el.offsetTop;
|
||||
this.bounds.width = el.offsetWidth;
|
||||
this.bounds.height = el.offsetHeight;
|
||||
// Switch from right/auto-left CSS positioning to explicit left/top so drag() can move the element freely.
|
||||
el.style.right = 'auto';
|
||||
el.style.left = `${this.bounds.left}px`;
|
||||
el.style.top = `${this.bounds.top}px`;
|
||||
|
||||
const parent = this.containerRef.current.offsetParent;
|
||||
const parent = el.offsetParent;
|
||||
this.bounds.maxX = parent.offsetWidth;
|
||||
this.bounds.maxY = parent.offsetHeight;
|
||||
}
|
||||
@@ -274,6 +298,7 @@ class CallPanel extends React.PureComponent {
|
||||
CALL_ENDED_SOUND.currentTime = 0;
|
||||
RING_SOUND.pause();
|
||||
RING_SOUND.currentTime = 0;
|
||||
this.stopElapsedTimer();
|
||||
|
||||
this.stopTracks(this.state.localStream);
|
||||
this.stopTracks(this.state.remoteStream);
|
||||
@@ -480,6 +505,27 @@ class CallPanel extends React.PureComponent {
|
||||
// Remote video becomes available.
|
||||
this.remoteRef.current.srcObject = event.streams[0];
|
||||
this.setState({remoteStream: event.streams[0]});
|
||||
// First remote media (audio or video) means the call is actually connected.
|
||||
this.startElapsedTimer();
|
||||
}
|
||||
|
||||
startElapsedTimer() {
|
||||
if (this.elapsedTimerId) {
|
||||
// Already running.
|
||||
return;
|
||||
}
|
||||
this.callStartedAt = Date.now();
|
||||
this.elapsedTimerId = setInterval(_ => {
|
||||
this.setState({elapsedSeconds: Math.floor((Date.now() - this.callStartedAt) / 1000)});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
stopElapsedTimer() {
|
||||
if (this.elapsedTimerId) {
|
||||
clearInterval(this.elapsedTimerId);
|
||||
this.elapsedTimerId = null;
|
||||
}
|
||||
this.callStartedAt = null;
|
||||
}
|
||||
|
||||
handleGetUserMediaError(e) {
|
||||
@@ -573,47 +619,104 @@ class CallPanel extends React.PureComponent {
|
||||
this.props.onHangup(this.props.topic, this.props.seq);
|
||||
}
|
||||
|
||||
// Ends video track and turns off the camera.
|
||||
// Ends video track and turns off the camera. Also usable before the peer connection
|
||||
// exists yet (e.g. caller previewing/toggling their camera while waiting for pickup) --
|
||||
// in that case there is no peer to notify, so the data channel send is skipped.
|
||||
muteVideo() {
|
||||
if (!this.state.pc || !this.state.dataChannel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stream = this.state.localStream;
|
||||
const t = stream.getVideoTracks()[0];
|
||||
if (!t) {
|
||||
this.setState({videoToggleInProgress: false});
|
||||
return;
|
||||
}
|
||||
t.enabled = false;
|
||||
t.stop();
|
||||
|
||||
stream.removeTrack(t);
|
||||
this.state.dataChannel.send(VIDEO_MUTED_EVENT);
|
||||
|
||||
this.sendOnDataChannel(VIDEO_MUTED_EVENT);
|
||||
this.setState({videoToggleInProgress: false});
|
||||
}
|
||||
|
||||
unmuteVideo() {
|
||||
if (!this.state.pc || !this.state.dataChannel) {
|
||||
return;
|
||||
// Swaps in a fresh local video track (new camera, or re-enabling after mute), hot-swapping
|
||||
// the peer connection's sender if one exists, or adding one if the call never negotiated
|
||||
// video (e.g. started audio-only, or was muted before the peer connection was created --
|
||||
// handleVideoCallAccepted/handleVideoOfferMsg will pick up whatever is in localStream when
|
||||
// they run). Returns a promise that resolves once the local preview is updated.
|
||||
replaceLocalVideoTrack(track) {
|
||||
this.localRef.current.srcObject = null;
|
||||
// Drop any existing video track (e.g. the disabled placeholder from an audio-only
|
||||
// call, or the camera being switched away from) before adding the fresh one.
|
||||
this.state.localStream.getVideoTracks().forEach(old => this.state.localStream.removeTrack(old));
|
||||
this.state.localStream.addTrack(track);
|
||||
|
||||
let result = Promise.resolve();
|
||||
if (this.state.pc) {
|
||||
const sender = this.state.pc.getSenders().find(s => s.track && s.track.kind == 'video');
|
||||
if (sender) {
|
||||
result = sender.replaceTrack(track);
|
||||
} else {
|
||||
// No video sender was ever negotiated -- add one now; triggers renegotiation.
|
||||
this.state.pc.addTrack(track, this.state.localStream);
|
||||
}
|
||||
}
|
||||
return result.then(_ => { this.localRef.current.srcObject = this.state.localStream; });
|
||||
}
|
||||
|
||||
// Turns the camera back on. Works both before the peer connection exists (the fresh
|
||||
// track is picked up later by handleVideoCallAccepted/handleVideoOfferMsg when they add
|
||||
// all current local tracks to the new pc) and mid-call (hot-swaps or adds a sender).
|
||||
unmuteVideo() {
|
||||
navigator.mediaDevices.getUserMedia({ video: true })
|
||||
.then(stream => {
|
||||
// Will extract video track from stream and throw stream away,
|
||||
// and replace video track in the media sender.
|
||||
this.localRef.current.srcObject = null;
|
||||
const sender = this.state.pc.getSenders().find(s => s.track.kind == 'video');
|
||||
const track = stream.getVideoTracks()[0];
|
||||
// Remote track from new stream.
|
||||
stream.removeTrack(track);
|
||||
// Add this track to the existing local stream.
|
||||
this.state.localStream.addTrack(track);
|
||||
return sender.replaceTrack(track);
|
||||
})
|
||||
.then(_ => {
|
||||
this.localRef.current.srcObject = this.state.localStream;
|
||||
this.state.dataChannel.send(VIDEO_UNMUTED_EVENT);
|
||||
return this.replaceLocalVideoTrack(track);
|
||||
})
|
||||
.then(_ => { this.sendOnDataChannel(VIDEO_UNMUTED_EVENT); })
|
||||
.catch(err => this.handleGetUserMediaError(err))
|
||||
.finally(_ => { this.setState({videoToggleInProgress: false}); }); // Make sure we redraw the mute/unmute icons (e.g. camera -> camera_off).
|
||||
}
|
||||
|
||||
// Data channel exists as soon as the peer connection is created, but isn't actually usable
|
||||
// until the underlying handshake completes -- sending before then throws InvalidStateError.
|
||||
sendOnDataChannel(message) {
|
||||
if (this.state.dataChannel && this.state.dataChannel.readyState == 'open') {
|
||||
this.state.dataChannel.send(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Cycles to the next available camera (front/back on mobile, or the next webcam on desktop).
|
||||
handleSwitchCameraClick() {
|
||||
if (this.state.videoToggleInProgress || this.state.audioOnly) {
|
||||
return;
|
||||
}
|
||||
const currentTrack = this.state.localStream.getVideoTracks()[0];
|
||||
const currentDeviceId = currentTrack && currentTrack.getSettings && currentTrack.getSettings().deviceId;
|
||||
this.setState({videoToggleInProgress: true}, _ => {
|
||||
navigator.mediaDevices.enumerateDevices()
|
||||
.then(devices => {
|
||||
const cameras = devices.filter(d => d.kind == 'videoinput');
|
||||
if (cameras.length < 2) {
|
||||
// Nothing to switch to.
|
||||
return null;
|
||||
}
|
||||
const idx = cameras.findIndex(d => d.deviceId == currentDeviceId);
|
||||
const next = cameras[(idx + 1) % cameras.length];
|
||||
return navigator.mediaDevices.getUserMedia({ video: { deviceId: { exact: next.deviceId } } });
|
||||
})
|
||||
.then(stream => {
|
||||
if (!stream) {
|
||||
return null;
|
||||
}
|
||||
const track = stream.getVideoTracks()[0];
|
||||
stream.removeTrack(track);
|
||||
return this.replaceLocalVideoTrack(track);
|
||||
})
|
||||
.catch(err => this.handleGetUserMediaError(err))
|
||||
.finally(_ => { this.setState({videoToggleInProgress: false}); });
|
||||
});
|
||||
}
|
||||
|
||||
handleToggleCameraClick() {
|
||||
if (this.state.videoToggleInProgress) {
|
||||
// Toggle currently in progress.
|
||||
@@ -638,14 +741,44 @@ class CallPanel extends React.PureComponent {
|
||||
this.forceUpdate();
|
||||
}
|
||||
|
||||
static speakerSwitchingSupported() {
|
||||
return typeof HTMLMediaElement !== 'undefined' && 'setSinkId' in HTMLMediaElement.prototype;
|
||||
}
|
||||
|
||||
handleToggleSpeakerClick() {
|
||||
if (!CallPanel.speakerSwitchingSupported() || !this.remoteRef.current) {
|
||||
return;
|
||||
}
|
||||
const nextOn = !this.state.speakerOn;
|
||||
navigator.mediaDevices.enumerateDevices()
|
||||
.then(devices => {
|
||||
const outputs = devices.filter(d => d.kind == 'audiooutput');
|
||||
// Best-effort: look for a device labeled as the loud speaker vs. the earpiece/default output.
|
||||
// Device labels are not standardized, so fall back to the system default sink when no match is found.
|
||||
const match = nextOn ?
|
||||
outputs.find(d => /speaker/i.test(d.label)) :
|
||||
outputs.find(d => /earpiece|receiver/i.test(d.label));
|
||||
return this.remoteRef.current.setSinkId(match ? match.deviceId : '');
|
||||
})
|
||||
.then(_ => this.setState({speakerOn: nextOn}))
|
||||
.catch(err => console.error('Failed to switch audio output device', err));
|
||||
}
|
||||
|
||||
render() {
|
||||
const audioTracks = this.state.localStream && this.state.localStream.getAudioTracks();
|
||||
const videoTracks = !this.state.audioOnly && this.state.localStream && this.state.localStream.getVideoTracks();
|
||||
const disabled = !this.state.pc || !this.state.dataChannel || !(audioTracks && audioTracks[0]);
|
||||
const audioIcon = audioTracks && audioTracks[0] && audioTracks[0].enabled ? 'mic' : 'mic_off';
|
||||
const videoIcon = videoTracks && videoTracks[0] && videoTracks[0].enabled && videoTracks[0].readyState == 'live' ? 'videocam' : 'videocam_off';
|
||||
// Mic/camera/speaker only need a local stream to exist -- toggling doesn't require a
|
||||
// peer connection (e.g. caller previewing their camera while waiting for pickup).
|
||||
const disabled = !(audioTracks && audioTracks[0]);
|
||||
const micOn = !!(audioTracks && audioTracks[0] && audioTracks[0].enabled);
|
||||
const audioIcon = micOn ? 'mic' : 'mic_off';
|
||||
const videoOn = !!(videoTracks && videoTracks[0] && videoTracks[0].enabled && videoTracks[0].readyState == 'live');
|
||||
const videoIcon = videoOn ? 'videocam' : 'videocam_off';
|
||||
const speakerSupported = CallPanel.speakerSwitchingSupported();
|
||||
const speakerIcon = this.state.speakerOn ? 'volume_up' : 'volume_off';
|
||||
const peerTitle = truncateString(this.props.title, MAX_PEER_TITLE_LENGTH);
|
||||
const pulseAnimation = this.state.waitingForPeer ? ' pulse' : '';
|
||||
const connected = !!this.state.remoteStream;
|
||||
|
||||
let remoteActive = false;
|
||||
if (this.remoteRef.current && this.remoteRef.current.srcObject && this.state.remoteVideoLive) {
|
||||
@@ -659,9 +792,26 @@ class CallPanel extends React.PureComponent {
|
||||
const minimizedClass = this.props.minimized ? 'minimized' : null;
|
||||
const fullScreen = this.props.minimized ? 'fullscreen' : 'fullscreen_exit';
|
||||
|
||||
if (this.props.minimized && this.state.audioOnly) {
|
||||
// Voice calls minimize to a small pill (icon + timer), not the video-shaped box.
|
||||
return (
|
||||
<div id="video-container" className="minimized audio-minimized" ref={this.containerRef}>
|
||||
<div className="call-pill draggable">
|
||||
<i className="material-icons">call</i>
|
||||
<span className="call-pill-duration">{formatElapsedTime(this.state.elapsedSeconds)}</span>
|
||||
<button className="full-screen" onClick={this.props.onToggleMinimize}>
|
||||
<i className="material-icons">{fullScreen}</i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="video-container" className={minimizedClass} ref={this.containerRef}>
|
||||
<div id="video-container-panel">
|
||||
{connected &&
|
||||
<div className="call-duration">{formatElapsedTime(this.state.elapsedSeconds)}</div>}
|
||||
<div id="video-container-panel" className="draggable">
|
||||
<div className="call-party self" disabled={this.state.audioOnly || this.props.minimized}>
|
||||
<video ref={this.localRef} autoPlay muted playsInline />
|
||||
<div className="caller-name inactive">
|
||||
@@ -698,12 +848,21 @@ class CallPanel extends React.PureComponent {
|
||||
<button className="danger" onClick={this.handleCloseClick}>
|
||||
<i className="material-icons">call_end</i>
|
||||
</button>
|
||||
<button className="secondary" onClick={this.handleToggleCameraClick} disabled={disabled}>
|
||||
<button className={`secondary${videoOn ? '' : ' off'}`} onClick={this.handleToggleCameraClick} disabled={disabled}>
|
||||
<i className="material-icons">{videoIcon}</i>
|
||||
</button>
|
||||
<button className="secondary" onClick={this.handleToggleMicClick} disabled={disabled}>
|
||||
<button className={`secondary${micOn ? '' : ' off'}`} onClick={this.handleToggleMicClick} disabled={disabled}>
|
||||
<i className="material-icons">{audioIcon}</i>
|
||||
</button>
|
||||
{speakerSupported &&
|
||||
<button className={`secondary${this.state.speakerOn ? '' : ' off'}`}
|
||||
onClick={this.handleToggleSpeakerClick} disabled={disabled}>
|
||||
<i className="material-icons">{speakerIcon}</i>
|
||||
</button>}
|
||||
{!this.state.audioOnly &&
|
||||
<button className="secondary" onClick={this.handleSwitchCameraClick} disabled={disabled || !videoOn}>
|
||||
<i className="material-icons">flip_camera_android</i>
|
||||
</button>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -188,15 +188,47 @@ class BaseChatMessage extends React.PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
// Reason caption shown under a failed outgoing message in a 1:1 chat, based on the real
|
||||
// relationship state (see docs/images/个人对话—拉黑状态/已读状态.png and .../个人对话—删除状态.png).
|
||||
// Returns null for relationships that don't explain a send failure (friend, pending, unknown) so
|
||||
// a plain network failure doesn't get a misleading relationship-based caption.
|
||||
renderFailReason() {
|
||||
if (this.props.response ||
|
||||
(this.props.received != Tinode.MESSAGE_STATUS_FAILED && this.props.received != Tinode.MESSAGE_STATUS_FATAL)) {
|
||||
return null;
|
||||
}
|
||||
if (this.props.relationship == 'blocked_by_peer' || this.props.relationship == 'blocked_by_me') {
|
||||
return <div className="message-fail-reason">消息已发送,但被对方拒收了</div>;
|
||||
}
|
||||
if (this.props.relationship == 'none') {
|
||||
return <div className="message-fail-reason">你还不是他(她)朋友。<a href="#"
|
||||
onClick={this.props.onSendFriendVerification}>发送朋友验证</a></div>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
render() {
|
||||
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;
|
||||
const isFailed = !this.props.response &&
|
||||
(this.props.received == Tinode.MESSAGE_STATUS_FAILED || this.props.received == Tinode.MESSAGE_STATUS_FATAL);
|
||||
const failReason = this.renderFailReason();
|
||||
let textSizeClass = 'message-content';
|
||||
const fullDisplay = (this.props.isGroup && this.props.response &&
|
||||
(this.props.sequence == 'single' || this.props.sequence == 'last'));
|
||||
|
||||
let content = this.props.content;
|
||||
// Server-generated conversation notices (currently just `friend_added`, sent once when two
|
||||
// users become friends -- see server's publishIMConversationSystemNotice) carry a custom JSON
|
||||
// shape ({template, text, actors, ...}), not Drafty and not a plain string, so without this it
|
||||
// falls through to the generic "invalid content" placeholder below. Reduce it to its `text`
|
||||
// upfront so the existing plain-string path renders it as an ordinary bubble -- these are
|
||||
// meant to read like a normal message ("你们已成为好友,现在可以开始聊天了。"), not a special banner.
|
||||
if (this.props.head && this.props.head['x-im-type'] === 'system_notice' &&
|
||||
content && typeof content == 'object' && typeof content.text == 'string') {
|
||||
content = content.text;
|
||||
}
|
||||
const attachments = [];
|
||||
if (this.props.mimeType == Drafty.getContentType() && Drafty.isValid(content)) {
|
||||
Drafty.attachments(content, (att, i) => {
|
||||
@@ -234,6 +266,11 @@ class BaseChatMessage extends React.PureComponent {
|
||||
|
||||
return (
|
||||
<li ref={this.props.innerRef} className={sideClass}>
|
||||
{this.props.selectMode ?
|
||||
<i className={'material-icons select-checkbox' + (this.props.selected ? ' blue' : '')}
|
||||
onClick={() => this.props.onToggleSelect(this.props.seq)}>
|
||||
{this.props.selected ? 'check_circle' : 'radio_button_unchecked'}
|
||||
</i> : null}
|
||||
{this.props.isGroup && this.props.response ?
|
||||
<div className="avatar-box">
|
||||
{fullDisplay ?
|
||||
@@ -245,6 +282,8 @@ class BaseChatMessage extends React.PureComponent {
|
||||
null}
|
||||
</div> :
|
||||
null}
|
||||
{isFailed && failReason ?
|
||||
<i className="material-icons red message-fail-icon">error</i> : null}
|
||||
<div>
|
||||
<div className={bubbleClass}>
|
||||
<div className="content-meta">
|
||||
@@ -259,7 +298,7 @@ class BaseChatMessage extends React.PureComponent {
|
||||
received={this.props.received} />
|
||||
: null}
|
||||
</div>
|
||||
{this.props.showContextMenu ?
|
||||
{this.props.showContextMenu && !this.props.selectMode ?
|
||||
<span className="menuTrigger">
|
||||
<a href="#" onClick={this.handleContextClick}>
|
||||
<i className="material-icons">expand_more</i>
|
||||
@@ -267,12 +306,15 @@ class BaseChatMessage extends React.PureComponent {
|
||||
</span> : null
|
||||
}
|
||||
</div>
|
||||
{failReason}
|
||||
{fullDisplay ?
|
||||
<div className="author">
|
||||
{this.props.userName ||
|
||||
<i><FormattedMessage id="user_not_found" defaultMessage="Not found"
|
||||
description="In place of a user's full name when the user is not found." /></i>
|
||||
}
|
||||
{this.props.senderRole == 'owner' ? <em className="role-badge owner">群主</em> :
|
||||
this.props.senderRole == 'admin' ? <em className="role-badge admin">管理员</em> : null}
|
||||
</div>
|
||||
: null
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import Contact from './contact.jsx';
|
||||
import ContactAction from './contact-action.jsx';
|
||||
|
||||
import { makeImageUrl } from '../lib/blob-helpers.js';
|
||||
import { topicDisplayName } from '../lib/utils.js';
|
||||
|
||||
import { MESSAGE_PREVIEW_LENGTH } from '../config.js';
|
||||
|
||||
@@ -35,8 +36,8 @@ class ContactList extends React.PureComponent {
|
||||
let contactsCount = 0;
|
||||
if (this.props.showSelfTopic) {
|
||||
const selected = showCheckmark ?
|
||||
(this.props.topicSelected.indexOf('slf') > -1) :
|
||||
(this.props.topicSelected === 'slf');
|
||||
this.props.topicSelected.some(Tinode.isSelfTopicName) :
|
||||
Tinode.isSelfTopicName(this.props.topicSelected);
|
||||
contactNodes.push(
|
||||
<Contact
|
||||
tinode={this.props.tinode}
|
||||
@@ -78,8 +79,9 @@ class ContactList extends React.PureComponent {
|
||||
if (c.private && c.private.comment) {
|
||||
filterOn.push(('' + c.private.comment).toLowerCase());
|
||||
}
|
||||
if (c.public && c.public.fn) {
|
||||
filterOn.push(('' + c.public.fn).toLowerCase());
|
||||
const displayName = topicDisplayName(key, c.public, null);
|
||||
if (displayName) {
|
||||
filterOn.push(('' + displayName).toLowerCase());
|
||||
}
|
||||
if (!this.props.filterFunc(this.props.filter, filterOn)) {
|
||||
return;
|
||||
@@ -124,7 +126,7 @@ class ContactList extends React.PureComponent {
|
||||
contactNodes.push(
|
||||
<Contact
|
||||
tinode={this.props.tinode}
|
||||
title={c.public ? c.public.fn : null}
|
||||
title={c.public ? topicDisplayName(key, c.public, null) : null}
|
||||
avatar={makeImageUrl(c.public ? c.public.photo : null)}
|
||||
comment={comment}
|
||||
preview={preview}
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
// Context Menu: popup/dropdown menu.
|
||||
import React from 'react';
|
||||
import { injectIntl, defineMessages } from 'react-intl';
|
||||
import { Drafty } from 'tinode-sdk';
|
||||
|
||||
import { REM_SIZE } from '../config.js';
|
||||
import { favoriteMessages, getFavorites, deleteFavorite, recallMessage } from '../lib/im-api.js';
|
||||
|
||||
// Session-local record of which (topic, seq) pairs are already favorited, and the favorite_id once
|
||||
// resolved. POST /favorites/batch's response only reports counts (created_count/existing_count/
|
||||
// failed), never the created id, so "取消收藏" (which needs a favorite_id, not a topic/seq) has
|
||||
// nothing to delete until a follow-up list lookup resolves it -- cached here so that only has to
|
||||
// happen once per message rather than on every menu open. A fresh ContextMenu instance is
|
||||
// constructed each time the menu opens (tinode-web.jsx conditionally mounts it), so reading this at
|
||||
// construction time is enough to pick the right title/action without any extra plumbing.
|
||||
const favoriteState = new Map();
|
||||
function favoriteKey(topicName, seq) { return topicName + ':' + seq; }
|
||||
function findFavorite(topicName, seq) {
|
||||
return getFavorites({type: 'all'}).then(data => (data.items || [])
|
||||
.find(it => it.source && it.source.topic === topicName && String(it.source.seq) === String(seq)));
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
info: {
|
||||
@@ -226,6 +242,33 @@ class ContextMenu extends React.Component {
|
||||
title: formatMessage(messages.forward),
|
||||
handler: _ => {} /* the action is taken directly in tinode-web */
|
||||
},
|
||||
'menu_item_copy': {
|
||||
id: 'menu_item_copy',
|
||||
title: '复制',
|
||||
handler: (params, errorHandler) => {
|
||||
return this.copyMessageText(params, errorHandler);
|
||||
}
|
||||
},
|
||||
'menu_item_favorite': {
|
||||
id: 'menu_item_favorite',
|
||||
title: favoriteState.has(favoriteKey(props.params && props.params.topicName, props.params && props.params.seq)) ? '取消收藏' : '收藏',
|
||||
handler: (params, errorHandler) => {
|
||||
return favoriteState.has(favoriteKey(params.topicName, params.seq)) ?
|
||||
this.removeFavorite(params, errorHandler) : this.addFavorite(params, errorHandler);
|
||||
}
|
||||
},
|
||||
'menu_item_recall': {
|
||||
id: 'menu_item_recall',
|
||||
title: '撤回',
|
||||
handler: (params, errorHandler) => {
|
||||
return this.recallOwnMessage(params, errorHandler);
|
||||
}
|
||||
},
|
||||
'menu_item_select': {
|
||||
id: 'menu_item_select',
|
||||
title: '多选',
|
||||
handler: _ => {} /* the action is taken directly in tinode-web, like forward */
|
||||
},
|
||||
'menu_item_edit': {
|
||||
id: 'menu_item_edit',
|
||||
title: formatMessage(messages.edit),
|
||||
@@ -487,6 +530,76 @@ class ContextMenu extends React.Component {
|
||||
});
|
||||
}
|
||||
|
||||
// Copies the message's plain-text content to the clipboard.
|
||||
copyMessageText(params, errorHandler) {
|
||||
const text = typeof params.content == 'string' ? params.content : Drafty.toPlainText(params.content);
|
||||
return navigator.clipboard.writeText(text || '').catch(err => {
|
||||
if (errorHandler) {
|
||||
errorHandler(err.message, 'err');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addFavorite(params, errorHandler) {
|
||||
return favoriteMessages([{topic: params.topicName, seq: params.seq}]).then(result => {
|
||||
// The request itself resolves 200 even when the specific message couldn't be favorited --
|
||||
// real per-item outcomes are only in this body (created_count/existing_count/failed), same
|
||||
// "batch call succeeds at the transport level, item can still fail" shape already handled for
|
||||
// forward/batch-delete elsewhere in this app. Left unchecked, a failed item here (e.g. the
|
||||
// backend's message-mirror table hasn't indexed this message yet) shows no error at all --
|
||||
// indistinguishable from success -- and silently never appears in 我的收藏.
|
||||
const failedItem = result && result.failed && result.failed[0];
|
||||
if (failedItem) {
|
||||
if (errorHandler) {
|
||||
errorHandler(failedItem.reason === 'NOT_FOUND' ? '收藏失败,该消息暂无法收藏,请稍后重试' : '收藏失败', 'err');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const key = favoriteKey(params.topicName, params.seq);
|
||||
favoriteState.set(key, true);
|
||||
// Resolve the real favorite_id in the background so a later "取消收藏" has it ready -- see the
|
||||
// module-level comment on favoriteState for why this needs a separate lookup at all.
|
||||
findFavorite(params.topicName, params.seq).then(match => {
|
||||
if (match) favoriteState.set(key, match.favorite_id);
|
||||
}).catch(() => {});
|
||||
}).catch(err => {
|
||||
if (errorHandler) {
|
||||
errorHandler(err.message, 'err');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
removeFavorite(params, errorHandler) {
|
||||
const key = favoriteKey(params.topicName, params.seq);
|
||||
const cached = favoriteState.get(key);
|
||||
const finish = favoriteId => deleteFavorite(favoriteId).then(() => favoriteState.delete(key));
|
||||
// A real favorite_id may not have resolved yet if this is opened right after addFavorite's own
|
||||
// background lookup started -- fall back to looking it up now rather than failing silently.
|
||||
const ready = (cached && cached !== true) ? Promise.resolve(cached) : findFavorite(params.topicName, params.seq).then(match => match && match.favorite_id);
|
||||
return ready.then(favoriteId => {
|
||||
if (!favoriteId) { if (errorHandler) errorHandler('未找到对应收藏', 'err'); return; }
|
||||
return finish(favoriteId);
|
||||
}).catch(err => {
|
||||
if (errorHandler) {
|
||||
errorHandler(err.message || '取消收藏失败', 'err');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Recall (Ch.4): a dedicated backend action distinct from delete -- server-enforced short time
|
||||
// window, permission check, and a 'message.recalled' business event for the other party. It does
|
||||
// not touch Tinode's own message store, so once the server accepts it we also hard-delete locally
|
||||
// (same call the plain 'Delete for All' item uses) to reflect it in this client's live view.
|
||||
recallOwnMessage(params, errorHandler) {
|
||||
return recallMessage(params.topicName, params.seq).then(_ => {
|
||||
return this.deleteMessages(false, true, params, errorHandler);
|
||||
}).catch(err => {
|
||||
if (errorHandler) {
|
||||
errorHandler(err.message, 'err');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Retries sending failed message.
|
||||
retryMessage(params, errorHandler) {
|
||||
const topic = this.props.tinode.getTopic(params.topicName);
|
||||
|
||||
@@ -9,6 +9,7 @@ import TagManager from './tag-manager.jsx';
|
||||
import { AVATAR_SIZE, MAX_AVATAR_BYTES, MAX_EXTERN_ATTACHMENT_SIZE, MAX_TITLE_LENGTH,
|
||||
MAX_TOPIC_DESCRIPTION_LENGTH } from '../config.js';
|
||||
import { imageScaled, blobToBase64, makeImageUrl } from '../lib/blob-helpers.js';
|
||||
import { createChatUploader } from '../lib/im-api.js';
|
||||
import { truncateString } from '../lib/strformat.js';
|
||||
|
||||
export default class NewTopicGroup extends React.PureComponent {
|
||||
@@ -69,7 +70,9 @@ export default class NewTopicGroup extends React.PureComponent {
|
||||
let {mime, blob} = image;
|
||||
if (blob.size > MAX_AVATAR_BYTES) {
|
||||
// Too large to send inband - uploading out of band and sending as a link.
|
||||
const uploader = this.props.tinode.getLargeFileHelper();
|
||||
// Same wrong-header bug messages-view.jsx's chat uploads had -- createChatUploader() is the
|
||||
// fixed-headers drop-in (see im-api.js for the full explanation).
|
||||
const uploader = createChatUploader(this.props.tinode);
|
||||
uploader.upload(blob)
|
||||
.then(url => this.setState({imageUrl: url}))
|
||||
.catch(err => this.props.onError(err.message, 'err'));
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// QR scanning ("扫一扫", entry points in message-center-view.jsx's quick menu and
|
||||
// contact-management-view.jsx's AddFriend page). Both entries used to just toast "not implemented
|
||||
// yet" -- this is the real camera capture + decode implementation, wired to `resolveQRCode` /
|
||||
// `createGroupJoinRequest` (im-api.js), which already existed but had zero call sites.
|
||||
//
|
||||
// No reference screenshot covers the scanning viewfinder itself -- docs/images/ only has the
|
||||
// generate-a-code screens (二维码.png, 群二维码页面.png). The full-screen dark camera view with a
|
||||
// bordered square cutout below follows this app's existing full-screen-overlay language (see
|
||||
// call-panel.jsx/call-incoming.jsx) rather than a specific design, since there is nothing to match.
|
||||
import React from 'react';
|
||||
|
||||
import jsQR from 'jsqr';
|
||||
|
||||
import { getFileUrl } from '../lib/im-api.js';
|
||||
|
||||
function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; }
|
||||
|
||||
// Decoding a full-resolution camera frame (often 1080p+) with jsQR on every tick is unnecessarily
|
||||
// expensive -- a QR code large enough to matter in frame still decodes fine downscaled, and it
|
||||
// keeps the scan loop smooth on lower-end phones. 'dontInvert' skips jsQR's extra dark/light
|
||||
// inversion pass, which normal on-screen/printed codes (dark modules on a light quiet zone) never
|
||||
// need -- also just a perf win for a loop that runs several times a second.
|
||||
const SCAN_INTERVAL_MS = 200;
|
||||
const MAX_SCAN_EDGE = 480;
|
||||
const JSQR_OPTIONS = {inversionAttempts: 'dontInvert'};
|
||||
|
||||
export default class QrScanner extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {status: 'starting', errorText: ''};
|
||||
this.videoRef = React.createRef();
|
||||
this.canvas = document.createElement('canvas');
|
||||
this.stream = null;
|
||||
this.timer = null;
|
||||
this.detected = false;
|
||||
|
||||
this.start = this.start.bind(this);
|
||||
this.stopStream = this.stopStream.bind(this);
|
||||
this.tick = this.tick.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.start();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.stopStream();
|
||||
}
|
||||
|
||||
start() {
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
this.setState({status: 'error', errorText: '当前浏览器不支持摄像头访问'});
|
||||
return;
|
||||
}
|
||||
this.detected = false;
|
||||
this.setState({status: 'starting', errorText: ''});
|
||||
// `facingMode` here is a hint, not `{exact: 'environment'}` -- a desktop/laptop with only a
|
||||
// front camera still gets a stream instead of failing with OverconstrainedError.
|
||||
navigator.mediaDevices.getUserMedia({video: {facingMode: 'environment'}, audio: false}).then(stream => {
|
||||
this.stream = stream;
|
||||
const video = this.videoRef.current;
|
||||
if (!video) {
|
||||
// Unmounted while the permission prompt was pending.
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
return;
|
||||
}
|
||||
video.srcObject = stream;
|
||||
video.play().catch(() => {});
|
||||
this.setState({status: 'scanning'});
|
||||
this.timer = window.setInterval(this.tick, SCAN_INTERVAL_MS);
|
||||
}).catch(err => {
|
||||
const name = err && err.name;
|
||||
const text = name === 'NotAllowedError' ? '未获得摄像头权限,请在浏览器设置中允许访问摄像头' :
|
||||
name === 'NotFoundError' ? '未检测到可用摄像头' :
|
||||
name === 'NotReadableError' ? '摄像头正被其他程序占用' : '摄像头启动失败,请重试';
|
||||
this.setState({status: 'error', errorText: text});
|
||||
});
|
||||
}
|
||||
|
||||
stopStream() {
|
||||
if (this.timer) {
|
||||
window.clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
if (this.stream) {
|
||||
this.stream.getTracks().forEach(track => track.stop());
|
||||
this.stream = null;
|
||||
}
|
||||
}
|
||||
|
||||
tick() {
|
||||
if (this.detected) {
|
||||
return;
|
||||
}
|
||||
const video = this.videoRef.current;
|
||||
if (!video || video.readyState !== video.HAVE_ENOUGH_DATA) {
|
||||
return;
|
||||
}
|
||||
const vw = video.videoWidth;
|
||||
const vh = video.videoHeight;
|
||||
if (!vw || !vh) {
|
||||
return;
|
||||
}
|
||||
const scale = Math.min(1, MAX_SCAN_EDGE / Math.max(vw, vh));
|
||||
const w = Math.round(vw * scale);
|
||||
const h = Math.round(vh * scale);
|
||||
this.canvas.width = w;
|
||||
this.canvas.height = h;
|
||||
const ctx = this.canvas.getContext('2d', {willReadFrequently: true});
|
||||
ctx.drawImage(video, 0, 0, w, h);
|
||||
let frame;
|
||||
try {
|
||||
frame = ctx.getImageData(0, 0, w, h);
|
||||
} catch (err) {
|
||||
// A transient decode error (e.g. a tainted canvas mid-permission-change) -- try again on the
|
||||
// next tick rather than surfacing an error the user can't act on.
|
||||
return;
|
||||
}
|
||||
const code = jsQR(frame.data, w, h, JSQR_OPTIONS);
|
||||
if (code && code.data) {
|
||||
this.detected = true;
|
||||
this.setState({status: 'resolving'});
|
||||
this.stopStream();
|
||||
this.props.onDetect(code.data);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {status, errorText} = this.state;
|
||||
return <div className="qr-scan-view">
|
||||
<video ref={this.videoRef} className="qr-scan-video" muted playsInline autoPlay />
|
||||
<div className="qr-scan-mask"><div className="qr-scan-frame">
|
||||
<i className="qr-scan-corner qr-scan-corner-tl" /><i className="qr-scan-corner qr-scan-corner-tr" />
|
||||
<i className="qr-scan-corner qr-scan-corner-bl" /><i className="qr-scan-corner qr-scan-corner-br" />
|
||||
</div></div>
|
||||
<header className="qr-scan-header">
|
||||
<button type="button" onClick={this.props.onClose} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button>
|
||||
<h1>扫一扫</h1><span />
|
||||
</header>
|
||||
{status === 'starting' ? <p className="qr-scan-hint">正在启动摄像头…</p> : null}
|
||||
{status === 'scanning' ? <p className="qr-scan-hint">将二维码放入框内,即可自动扫描</p> : null}
|
||||
{status === 'resolving' ? <p className="qr-scan-hint">识别中…</p> : null}
|
||||
{status === 'error' ? <div className="qr-scan-error">
|
||||
<Icon>error_outline</Icon><p>{errorText}</p>
|
||||
<button type="button" onClick={this.start}>重试</button>
|
||||
</div> : null}
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
// Shown after a "join_group" QR resolves to a group the scanner isn't a member of yet
|
||||
// (resolveQRCode's `status: "can_request"` -- see server/im_http.go's serveIMQRCodesResolve).
|
||||
// Joining always goes through the same pending-request/admin-approval pipeline as Ch.6's
|
||||
// JoinRequests, regardless of the group's `join_approval` governance toggle (CreateJoinRequest
|
||||
// never auto-accepts) -- so this never claims immediate membership, only that a request was sent.
|
||||
export function GroupJoinPreview({group, onSubmit, onCancel}) {
|
||||
const [message, setMessage] = React.useState('');
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [avatarUrl, setAvatarUrl] = React.useState(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!group.avatar_file_id) {
|
||||
return undefined;
|
||||
}
|
||||
let live = true;
|
||||
getFileUrl(group.avatar_file_id).then(url => { if (live) setAvatarUrl(url); }).catch(() => {});
|
||||
return () => { live = false; };
|
||||
}, [group.avatar_file_id]);
|
||||
|
||||
const submit = () => {
|
||||
setBusy(true);
|
||||
Promise.resolve(onSubmit(message.trim())).finally(() => setBusy(false));
|
||||
};
|
||||
|
||||
return <div className="qr-join-backdrop"><section className="qr-join-sheet" role="dialog" aria-modal="true">
|
||||
<span className="qr-join-avatar">{avatarUrl ? <img src={avatarUrl} alt="" /> : <Icon>group</Icon>}</span>
|
||||
<h2>{group.name || '群聊'}</h2>
|
||||
<p>{group.member_count || 0} 人</p>
|
||||
<input value={message} onChange={event => setMessage(event.target.value)} placeholder="验证消息(选填)" maxLength={60} disabled={busy} />
|
||||
<button type="button" className="primary" disabled={busy} onClick={submit}>{busy ? '提交中…' : '申请加入'}</button>
|
||||
<button type="button" onClick={onCancel} disabled={busy}>取消</button>
|
||||
</section></div>;
|
||||
}
|
||||
@@ -144,10 +144,13 @@ class SendMessage extends React.PureComponent {
|
||||
if (this.props.disabled) {
|
||||
return;
|
||||
}
|
||||
// onAttachImage/onAttachFile are explicitly null while forwarding a message
|
||||
// (messages-view.jsx) -- pasting a file in that state used to throw
|
||||
// "onAttachImage is not a function" instead of just not attaching anything.
|
||||
// FIXME: handle large files too.
|
||||
if (filePasted(e,
|
||||
file => { this.props.onAttachImage(file); },
|
||||
file => { this.props.onAttachFile(file); },
|
||||
file => { if (this.props.onAttachImage) { this.props.onAttachImage(file); } },
|
||||
file => { if (this.props.onAttachFile) { this.props.onAttachFile(file); } },
|
||||
this.props.onError)) {
|
||||
|
||||
// If a file was pasted, don't paste base64 data into input field.
|
||||
@@ -156,7 +159,7 @@ class SendMessage extends React.PureComponent {
|
||||
}
|
||||
|
||||
handleAttachImage(e) {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
if (e.target.files && e.target.files.length > 0 && this.props.onAttachImage) {
|
||||
this.props.onAttachImage(e.target.files[0]);
|
||||
}
|
||||
// Clear the value so the same file can be uploaded again.
|
||||
@@ -164,7 +167,7 @@ class SendMessage extends React.PureComponent {
|
||||
}
|
||||
|
||||
handleAttachFile(e) {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
if (e.target.files && e.target.files.length > 0 && this.props.onAttachFile) {
|
||||
this.props.onAttachFile(e.target.files[0]);
|
||||
}
|
||||
// Clear the value so the same file can be uploaded again.
|
||||
@@ -172,22 +175,30 @@ class SendMessage extends React.PureComponent {
|
||||
}
|
||||
|
||||
handleDropAttach(files) {
|
||||
if (files && files.length > 0) {
|
||||
if (files && files.length > 0 && this.props.onAttachFile) {
|
||||
this.props.onAttachFile(files[0]);
|
||||
}
|
||||
}
|
||||
|
||||
handleAttachAudio(url, preview, duration) {
|
||||
this.setState({audioRec: false});
|
||||
this.props.onAttachAudio(url, preview, duration);
|
||||
if (this.props.onAttachAudio) {
|
||||
this.props.onAttachAudio(url, preview, duration);
|
||||
}
|
||||
}
|
||||
|
||||
handleSend(e) {
|
||||
e.preventDefault();
|
||||
const message = this.state.message.trim();
|
||||
if (message || this.props.acceptBlank || this.props.noInput) {
|
||||
this.props.onSendMessage(message);
|
||||
this.setState({message: ''});
|
||||
const result = this.props.onSendMessage(message);
|
||||
// onSendMessage may return a promise resolving to `false` when a pre-send risk check blocked
|
||||
// or the user cancelled it -- in that case keep the composer text so nothing typed is lost.
|
||||
if (result && typeof result.then == 'function') {
|
||||
result.then(sent => { if (sent !== false) this.setState({message: ''}); });
|
||||
} else {
|
||||
this.setState({message: ''});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import TagManager from './tag-manager.jsx';
|
||||
import { AVATAR_SIZE, MAX_AVATAR_BYTES, MAX_EXTERN_ATTACHMENT_SIZE, MAX_TITLE_LENGTH,
|
||||
MAX_TOPIC_DESCRIPTION_LENGTH } from '../config.js';
|
||||
import { imageScaled, blobToBase64, makeImageUrl } from '../lib/blob-helpers.js';
|
||||
import { createChatUploader } from '../lib/im-api.js';
|
||||
import { truncateString } from '../lib/strformat.js';
|
||||
import { arrayEqual, theCard } from '../lib/utils.js';
|
||||
|
||||
@@ -149,7 +150,9 @@ class TopicDescEdit extends React.Component {
|
||||
let {mime, blob} = image;
|
||||
if (blob.size > MAX_AVATAR_BYTES) {
|
||||
// Too large to send inband - uploading out of band and sending as a link.
|
||||
const uploader = this.props.tinode.getLargeFileHelper();
|
||||
// Same wrong-header bug messages-view.jsx's chat uploads had -- createChatUploader() is the
|
||||
// fixed-headers drop-in (see im-api.js for the full explanation).
|
||||
const uploader = createChatUploader(this.props.tinode);
|
||||
uploader.upload(blob)
|
||||
.then(url => this.props.onUpdateTopicDesc(this.props.topic, theCard(null, url)))
|
||||
.catch(err => this.props.onError(err.message, 'err'));
|
||||
|
||||
Reference in New Issue
Block a user