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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user