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
+44524
-4124
File diff suppressed because it is too large
Load Diff
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
@@ -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
Reference in New Issue
Block a user