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:
Your Name
2026-09-03 11:40:53 +08:00
co-authored by Claude Sonnet 5
parent ba8f48ee83
commit 251c9de87c
36 changed files with 52812 additions and 5199 deletions
+6 -1
View File
@@ -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}),
+166
View File
@@ -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>
);
}
}
+35 -26
View File
@@ -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
View File
@@ -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>
);
+43 -1
View File
@@ -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
}
+7 -5
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 { 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}
+113
View File
@@ -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);
+4 -1
View File
@@ -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'));
+183
View File
@@ -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>;
}
+19 -8
View File
@@ -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: ''});
}
}
}
+4 -1
View File
@@ -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'));