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
+188 -29
View File
@@ -31,6 +31,13 @@ CALL_ENDED_SOUND.loop = true;
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 = (0,react_intl__WEBPACK_IMPORTED_MODULE_1__.defineMessages)({
already_in_call: {
id: 'already_in_call',
@@ -51,8 +58,12 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
callInitialSetupComplete: false,
audioOnly: props.callAudioOnly,
videoToggleInProgress: false,
remoteVideoLive: false
remoteVideoLive: false,
elapsedSeconds: 0,
speakerOn: true
};
this.callStartedAt = null;
this.elapsedTimerId = null;
this.localStreamConstraints = {
audio: true,
video: !props.callAudioOnly
@@ -86,6 +97,10 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
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);
this.muteVideo = this.muteVideo.bind(this);
@@ -98,21 +113,25 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
this.handleDataChannelClose = this.handleDataChannelClose.bind(this);
this.bounds = {};
this.dragStart = event => {
if (!event.target.classList.contains('draggable')) {
if (!event.target.closest('.draggable')) {
return;
}
if (window.matchMedia('(width<=640px)')) {
if (window.matchMedia('(width<=640px)').matches) {
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 parent = this.containerRef.current.offsetParent;
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;
el.style.right = 'auto';
el.style.left = `${this.bounds.left}px`;
el.style.top = `${this.bounds.top}px`;
const parent = el.offsetParent;
this.bounds.maxX = parent.offsetWidth;
this.bounds.maxY = parent.offsetHeight;
}
@@ -239,6 +258,7 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
CALL_ENDED_SOUND.currentTime = 0;
RING_SOUND.pause();
RING_SOUND.currentTime = 0;
this.stopElapsedTimer();
this.stopTracks(this.state.localStream);
this.stopTracks(this.state.remoteStream);
this.disconnectMedia(this.localRef.current);
@@ -431,6 +451,25 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
this.setState({
remoteStream: event.streams[0]
});
this.startElapsedTimer();
}
startElapsedTimer() {
if (this.elapsedTimerId) {
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) {
console.error("Error opening camera and/or microphone", e);
@@ -504,41 +543,96 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
this.props.onHangup(this.props.topic, this.props.seq);
}
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;
replaceLocalVideoTrack(track) {
this.localRef.current.srcObject = null;
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 {
this.state.pc.addTrack(track, this.state.localStream);
}
}
return result.then(_ => {
this.localRef.current.srcObject = this.state.localStream;
});
}
unmuteVideo() {
navigator.mediaDevices.getUserMedia({
video: true
}).then(stream => {
this.localRef.current.srcObject = null;
const sender = this.state.pc.getSenders().find(s => s.track.kind == 'video');
const track = stream.getVideoTracks()[0];
stream.removeTrack(track);
this.state.localStream.addTrack(track);
return sender.replaceTrack(track);
return this.replaceLocalVideoTrack(track);
}).then(_ => {
this.localRef.current.srcObject = this.state.localStream;
this.state.dataChannel.send(VIDEO_UNMUTED_EVENT);
this.sendOnDataChannel(VIDEO_UNMUTED_EVENT);
}).catch(err => this.handleGetUserMediaError(err)).finally(_ => {
this.setState({
videoToggleInProgress: false
});
});
}
sendOnDataChannel(message) {
if (this.state.dataChannel && this.state.dataChannel.readyState == 'open') {
this.state.dataChannel.send(message);
}
}
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) {
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) {
return;
@@ -563,14 +657,35 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
t.enabled = !t.enabled;
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');
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';
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 = (0,_lib_strformat_js__WEBPACK_IMPORTED_MODULE_5__.truncateString)(this.props.title, _config_js__WEBPACK_IMPORTED_MODULE_3__.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) {
const rstream = this.remoteRef.current.srcObject;
@@ -581,12 +696,40 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
}
const minimizedClass = this.props.minimized ? 'minimized' : null;
const fullScreen = this.props.minimized ? 'fullscreen' : 'fullscreen_exit';
if (this.props.minimized && this.state.audioOnly) {
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
id: "video-container",
className: "minimized audio-minimized",
ref: this.containerRef,
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
className: "call-pill draggable",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("i", {
className: "material-icons",
children: "call"
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("span", {
className: "call-pill-duration",
children: formatElapsedTime(this.state.elapsedSeconds)
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", {
className: "full-screen",
onClick: this.props.onToggleMinimize,
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("i", {
className: "material-icons",
children: fullScreen
}, void 0, false)
}, void 0, false)]
}, void 0, true)
}, void 0, false);
}
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
id: "video-container",
className: minimizedClass,
ref: this.containerRef,
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
children: [connected && (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
className: "call-duration",
children: formatElapsedTime(this.state.elapsedSeconds)
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
id: "video-container-panel",
className: "draggable",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
className: "call-party self",
disabled: this.state.audioOnly || this.props.minimized,
@@ -654,7 +797,7 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
children: "call_end"
}, void 0, false)
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", {
className: "secondary",
className: `secondary${videoOn ? '' : ' off'}`,
onClick: this.handleToggleCameraClick,
disabled: disabled,
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("i", {
@@ -662,13 +805,29 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
children: videoIcon
}, void 0, false)
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", {
className: "secondary",
className: `secondary${micOn ? '' : ' off'}`,
onClick: this.handleToggleMicClick,
disabled: disabled,
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("i", {
className: "material-icons",
children: audioIcon
}, void 0, false)
}, void 0, false), speakerSupported && (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", {
className: `secondary${this.state.speakerOn ? '' : ' off'}`,
onClick: this.handleToggleSpeakerClick,
disabled: disabled,
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("i", {
className: "material-icons",
children: speakerIcon
}, void 0, false)
}, void 0, false), !this.state.audioOnly && (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", {
className: "secondary",
onClick: this.handleSwitchCameraClick,
disabled: disabled || !videoOn,
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("i", {
className: "material-icons",
children: "flip_camera_android"
}, void 0, false)
}, void 0, false)]
}, void 0, true)]
}, void 0, true);