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
+44524 -4124
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+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);
File diff suppressed because one or more lines are too long