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
+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>
);