+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "tinode-webapp",
|
||||
"description": "Tinode messenger for the web",
|
||||
"version": "0.22.2",
|
||||
"version": "0.22.3",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/tinode/webapp.git"
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1647,5 +1647,12 @@
|
||||
"description": "Label for busy call line",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"failed_to_init_audio": {
|
||||
"translation": "",
|
||||
"defaultMessage": "Failed to initialize audio recording",
|
||||
"description": "Error message when audio is not available",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1647,5 +1647,12 @@
|
||||
"description": "Label for busy call line",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"failed_to_init_audio": {
|
||||
"translation": "Failed to initialize audio recording",
|
||||
"defaultMessage": "Failed to initialize audio recording",
|
||||
"description": "Error message when audio is not available",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1647,5 +1647,12 @@
|
||||
"description": "Label for busy call line",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"failed_to_init_audio": {
|
||||
"translation": "",
|
||||
"defaultMessage": "Failed to initialize audio recording",
|
||||
"description": "Error message when audio is not available",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,6 +291,10 @@
|
||||
"defaultMessage": "Invalid ID",
|
||||
"description": "Error message"
|
||||
},
|
||||
"failed_to_init_audio": {
|
||||
"defaultMessage": "Failed to initialize audio recording",
|
||||
"description": "Error message when audio is not available"
|
||||
},
|
||||
"file_attachment_too_large": {
|
||||
"defaultMessage": "The file size {size} exceeds the {limit} limit.",
|
||||
"description": "Error message when attachment is too large"
|
||||
|
||||
@@ -1647,5 +1647,12 @@
|
||||
"description": "Label for busy call line",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"failed_to_init_audio": {
|
||||
"translation": "",
|
||||
"defaultMessage": "Failed to initialize audio recording",
|
||||
"description": "Error message when audio is not available",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1647,5 +1647,12 @@
|
||||
"description": "Label for busy call line",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"failed_to_init_audio": {
|
||||
"translation": "",
|
||||
"defaultMessage": "Failed to initialize audio recording",
|
||||
"description": "Error message when audio is not available",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1647,5 +1647,12 @@
|
||||
"description": "Label for busy call line",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"failed_to_init_audio": {
|
||||
"translation": "",
|
||||
"defaultMessage": "Failed to initialize audio recording",
|
||||
"description": "Error message when audio is not available",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1647,5 +1647,12 @@
|
||||
"description": "Label for busy call line",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"failed_to_init_audio": {
|
||||
"translation": "",
|
||||
"defaultMessage": "Failed to initialize audio recording",
|
||||
"description": "Error message when audio is not available",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1647,5 +1647,12 @@
|
||||
"description": "Label for busy call line",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"failed_to_init_audio": {
|
||||
"translation": "",
|
||||
"defaultMessage": "Failed to initialize audio recording",
|
||||
"description": "Error message when audio is not available",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1647,5 +1647,12 @@
|
||||
"description": "Label for busy call line",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"failed_to_init_audio": {
|
||||
"translation": "",
|
||||
"defaultMessage": "Failed to initialize audio recording",
|
||||
"description": "Error message when audio is not available",
|
||||
"missing": true,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
// This is a generated file. Don't edit.
|
||||
|
||||
export const PACKAGE_VERSION = "0.22.2";
|
||||
export const PACKAGE_VERSION = "0.22.3";
|
||||
|
||||
@@ -431,7 +431,7 @@ class TinodeWeb extends React.Component {
|
||||
}).then(token => {
|
||||
if (token) {
|
||||
return token;
|
||||
} else {
|
||||
} else if (typeof Notification != 'undefined') {
|
||||
// Try to request permissions.
|
||||
return Notification.requestPermission().then(permission => {
|
||||
if (permission === 'granted') {
|
||||
@@ -450,6 +450,7 @@ class TinodeWeb extends React.Component {
|
||||
}
|
||||
});
|
||||
}
|
||||
throw new Error("Notifications are not supported");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ class AudioPlayer extends React.PureComponent {
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.audioPlayer) {
|
||||
this.audioPlayer.oncanplay = null;
|
||||
this.audioPlayer.onloadedmetadata = null;
|
||||
this.audioPlayer.ontimeupdate = null;
|
||||
this.audioPlayer.onended = null;
|
||||
this.audioPlayer.pause();
|
||||
@@ -94,7 +94,7 @@ class AudioPlayer extends React.PureComponent {
|
||||
|
||||
initAudio() {
|
||||
this.audioPlayer = new Audio(this.props.src);
|
||||
this.audioPlayer.oncanplay = _ => this.setState({canPlay: true});
|
||||
this.audioPlayer.onloadedmetadata = _ => this.setState({canPlay: true});
|
||||
this.audioPlayer.ontimeupdate = _ => this.setState({
|
||||
currentTime: secondsToTime(this.audioPlayer.currentTime, this.state.longMin)
|
||||
});
|
||||
@@ -149,7 +149,7 @@ class AudioPlayer extends React.PureComponent {
|
||||
this.canvasContext.strokeStyle = BAR_COLOR_DARK;
|
||||
for (let i = 0; i < this.viewBuffer.length; i++) {
|
||||
let x = 1 + i * (LINE_WIDTH + SPACING) + LINE_WIDTH * 0.5;
|
||||
let y = this.viewBuffer[i] * height * 0.9;
|
||||
let y = Math.max(this.viewBuffer[i] * height * 0.9, 1);
|
||||
|
||||
const color = x < thumbAt ? BAR_COLOR_DARK : BAR_COLOR;
|
||||
if (this.canvasContext.strokeStyle != color) {
|
||||
@@ -159,7 +159,7 @@ class AudioPlayer extends React.PureComponent {
|
||||
}
|
||||
|
||||
this.canvasContext.moveTo(x, (height - y) * 0.5);
|
||||
this.canvasContext.lineTo(x, height * 0.5 + y * 0.5);
|
||||
this.canvasContext.lineTo(x, (height + y) * 0.5);
|
||||
}
|
||||
// Actually draw the bars on canvas.
|
||||
this.canvasContext.stroke();
|
||||
@@ -216,10 +216,11 @@ class AudioPlayer extends React.PureComponent {
|
||||
if (!this.state.canPlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state.playing) {
|
||||
this.audioPlayer.pause();
|
||||
this.setState({playing: false});
|
||||
} else {
|
||||
} else if (this.audioPlayer.readyState >= 2) {
|
||||
this.audioPlayer.play();
|
||||
this.setState({playing: true}, this.visualize);
|
||||
}
|
||||
|
||||
@@ -31,8 +31,11 @@ const VISUALIZATION_BARS = 96;
|
||||
// Maximum number of samples per bar.
|
||||
const MAX_SAMPLES_PER_BAR = 10;
|
||||
|
||||
// Recording format.
|
||||
const AUDIO_MIME_TYPE = 'audio/webm';
|
||||
// Default recording format (FF, Chrome except on iOS).
|
||||
const DEFAULT_AUDIO_MIME_TYPE = 'audio/webm';
|
||||
// Safari supports only mp4 as audio recording format.
|
||||
const SAFARI_AUDIO_MIME_TYPE = 'audio/mp4';
|
||||
const AUDIO_MIME_TYPES = [DEFAULT_AUDIO_MIME_TYPE, SAFARI_AUDIO_MIME_TYPE, ''];
|
||||
|
||||
const messages = defineMessages({
|
||||
icon_title_delete: {
|
||||
@@ -55,6 +58,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Send message',
|
||||
description: 'Icon tool tip for sending a message'
|
||||
},
|
||||
failed_to_init_audio: {
|
||||
id: 'failed_to_init_audio',
|
||||
defaultMessage: 'Failed to initialize audio recording',
|
||||
description: 'Error message when audio is not available'
|
||||
}
|
||||
});
|
||||
|
||||
class AudioRecorder extends React.PureComponent {
|
||||
@@ -188,10 +196,10 @@ class AudioRecorder extends React.PureComponent {
|
||||
this.canvasContext.beginPath();
|
||||
for (let i = 0; i < this.viewBuffer.length; i++) {
|
||||
let x = i * (LINE_WIDTH + SPACING) - dx;
|
||||
let y = Math.min(this.viewBuffer[i] / BAR_SCALE, 0.9) * height;
|
||||
let y = Math.max(Math.min(this.viewBuffer[i] / BAR_SCALE, 0.9) * height, 1);
|
||||
|
||||
this.canvasContext.moveTo(x, (height - y) * 0.5);
|
||||
this.canvasContext.lineTo(x, height * 0.5 + y * 0.5);
|
||||
this.canvasContext.lineTo(x, (height + y) * 0.5);
|
||||
}
|
||||
// Actually draw the bars on canvas.
|
||||
this.canvasContext.stroke();
|
||||
@@ -261,11 +269,29 @@ class AudioRecorder extends React.PureComponent {
|
||||
|
||||
initMediaRecording(stream) {
|
||||
this.stream = stream;
|
||||
this.mediaRecorder = new MediaRecorder(stream, {mimeType: AUDIO_MIME_TYPE, audioBitsPerSecond: 24_000});
|
||||
AUDIO_MIME_TYPES.some(mimeType => {
|
||||
if (MediaRecorder.isTypeSupported(mimeType)) {
|
||||
this.mediaRecorder = new MediaRecorder(stream, {mimeType: mimeType, audioBitsPerSecond: 24_000});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!this.mediaRecorder) {
|
||||
console.warn('MediaRecorder failed to initialize: no supported audio formats');
|
||||
this.props.onError(this.props.intl.formatMessage(messages.failed_to_init_audio));
|
||||
return;
|
||||
}
|
||||
|
||||
// The following code is needed for visualization.
|
||||
this.audioContext = new AudioContext();
|
||||
this.audioInput = this.audioContext.createMediaStreamSource(stream);
|
||||
if (!this.audioInput) {
|
||||
console.warn('createMediaStreamSource returned null: audio input unavailable');
|
||||
this.props.onError(this.props.intl.formatMessage(messages.failed_to_init_audio));
|
||||
return;
|
||||
}
|
||||
|
||||
this.analyser = this.audioContext.createAnalyser();
|
||||
this.analyser.fftSize = BUFFER_SIZE;
|
||||
this.audioInput.connect(this.analyser);
|
||||
@@ -305,11 +331,14 @@ class AudioRecorder extends React.PureComponent {
|
||||
}
|
||||
|
||||
// Obtain data in a form sutable for sending or playing back.
|
||||
// If duration is valid, apply fix for Chrome's WebM duration bug.
|
||||
getRecording(mimeType) {
|
||||
mimeType = mimeType || AUDIO_MIME_TYPE;
|
||||
mimeType = mimeType || DEFAULT_AUDIO_MIME_TYPE;
|
||||
let blob = new Blob(this.audioChunks, {type: mimeType});
|
||||
return fixWebmDuration(blob, mimeType)
|
||||
// If duration is valid, apply fix for Chrome's WebM duration bug.
|
||||
const result = mimeType == DEFAULT_AUDIO_MIME_TYPE ?
|
||||
fixWebmDuration(blob, mimeType) :
|
||||
Promise.resolve(blob);
|
||||
return result
|
||||
.then(fixedBlob => { blob = fixedBlob; return fixedBlob.arrayBuffer(); })
|
||||
.then(arrayBuff => this.audioContext.decodeAudioData(arrayBuff))
|
||||
.then(decoded => this.createPreview(decoded))
|
||||
@@ -347,7 +376,9 @@ class AudioRecorder extends React.PureComponent {
|
||||
}
|
||||
|
||||
cleanUp() {
|
||||
this.audioInput.disconnect();
|
||||
if (this.audioInput) {
|
||||
this.audioInput.disconnect();
|
||||
}
|
||||
this.stream.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+8
-7
@@ -3589,7 +3589,7 @@ __webpack_require__.r(__webpack_exports__);
|
||||
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
||||
/* harmony export */ "PACKAGE_VERSION": () => (/* binding */ PACKAGE_VERSION)
|
||||
/* harmony export */ });
|
||||
const PACKAGE_VERSION = "0.22.2";
|
||||
const PACKAGE_VERSION = "0.22.3";
|
||||
|
||||
/***/ }),
|
||||
|
||||
@@ -7596,7 +7596,7 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
||||
}).then(token => {
|
||||
if (token) {
|
||||
return token;
|
||||
} else {
|
||||
} else if (typeof Notification != 'undefined') {
|
||||
return Notification.requestPermission().then(permission => {
|
||||
if (permission === 'granted') {
|
||||
return (0,firebase_messaging__WEBPACK_IMPORTED_MODULE_3__.getToken)(fcm, {
|
||||
@@ -7614,6 +7614,7 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
||||
}
|
||||
});
|
||||
}
|
||||
throw new Error("Notifications are not supported");
|
||||
});
|
||||
}
|
||||
handleResize() {
|
||||
@@ -9576,7 +9577,7 @@ class AudioPlayer extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComp
|
||||
}
|
||||
componentWillUnmount() {
|
||||
if (this.audioPlayer) {
|
||||
this.audioPlayer.oncanplay = null;
|
||||
this.audioPlayer.onloadedmetadata = null;
|
||||
this.audioPlayer.ontimeupdate = null;
|
||||
this.audioPlayer.onended = null;
|
||||
this.audioPlayer.pause();
|
||||
@@ -9599,7 +9600,7 @@ class AudioPlayer extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComp
|
||||
}
|
||||
initAudio() {
|
||||
this.audioPlayer = new Audio(this.props.src);
|
||||
this.audioPlayer.oncanplay = _ => this.setState({
|
||||
this.audioPlayer.onloadedmetadata = _ => this.setState({
|
||||
canPlay: true
|
||||
});
|
||||
this.audioPlayer.ontimeupdate = _ => this.setState({
|
||||
@@ -9642,7 +9643,7 @@ class AudioPlayer extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComp
|
||||
this.canvasContext.strokeStyle = BAR_COLOR_DARK;
|
||||
for (let i = 0; i < this.viewBuffer.length; i++) {
|
||||
let x = 1 + i * (LINE_WIDTH + SPACING) + LINE_WIDTH * 0.5;
|
||||
let y = this.viewBuffer[i] * height * 0.9;
|
||||
let y = Math.max(this.viewBuffer[i] * height * 0.9, 1);
|
||||
const color = x < thumbAt ? BAR_COLOR_DARK : BAR_COLOR;
|
||||
if (this.canvasContext.strokeStyle != color) {
|
||||
this.canvasContext.stroke();
|
||||
@@ -9650,7 +9651,7 @@ class AudioPlayer extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComp
|
||||
this.canvasContext.strokeStyle = color;
|
||||
}
|
||||
this.canvasContext.moveTo(x, (height - y) * 0.5);
|
||||
this.canvasContext.lineTo(x, height * 0.5 + y * 0.5);
|
||||
this.canvasContext.lineTo(x, (height + y) * 0.5);
|
||||
}
|
||||
this.canvasContext.stroke();
|
||||
if (this.props.duration) {
|
||||
@@ -9701,7 +9702,7 @@ class AudioPlayer extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComp
|
||||
this.setState({
|
||||
playing: false
|
||||
});
|
||||
} else {
|
||||
} else if (this.audioPlayer.readyState >= 2) {
|
||||
this.audioPlayer.play();
|
||||
this.setState({
|
||||
playing: true
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -37,7 +37,9 @@ const BAR_COLOR = '#BBBD';
|
||||
const BAR_SCALE = 64.0;
|
||||
const VISUALIZATION_BARS = 96;
|
||||
const MAX_SAMPLES_PER_BAR = 10;
|
||||
const AUDIO_MIME_TYPE = 'audio/webm';
|
||||
const DEFAULT_AUDIO_MIME_TYPE = 'audio/webm';
|
||||
const SAFARI_AUDIO_MIME_TYPE = 'audio/mp4';
|
||||
const AUDIO_MIME_TYPES = [DEFAULT_AUDIO_MIME_TYPE, SAFARI_AUDIO_MIME_TYPE, ''];
|
||||
const messages = (0,react_intl__WEBPACK_IMPORTED_MODULE_1__.defineMessages)({
|
||||
icon_title_delete: {
|
||||
id: "icon_title_delete",
|
||||
@@ -66,6 +68,13 @@ const messages = (0,react_intl__WEBPACK_IMPORTED_MODULE_1__.defineMessages)({
|
||||
"type": 0,
|
||||
"value": "Send message"
|
||||
}]
|
||||
},
|
||||
failed_to_init_audio: {
|
||||
id: "failed_to_init_audio",
|
||||
defaultMessage: [{
|
||||
"type": 0,
|
||||
"value": "Failed to initialize audio recording"
|
||||
}]
|
||||
}
|
||||
});
|
||||
class AudioRecorder extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComponent) {
|
||||
@@ -170,9 +179,9 @@ class AudioRecorder extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCo
|
||||
this.canvasContext.beginPath();
|
||||
for (let i = 0; i < this.viewBuffer.length; i++) {
|
||||
let x = i * (LINE_WIDTH + SPACING) - dx;
|
||||
let y = Math.min(this.viewBuffer[i] / BAR_SCALE, 0.9) * height;
|
||||
let y = Math.max(Math.min(this.viewBuffer[i] / BAR_SCALE, 0.9) * height, 1);
|
||||
this.canvasContext.moveTo(x, (height - y) * 0.5);
|
||||
this.canvasContext.lineTo(x, height * 0.5 + y * 0.5);
|
||||
this.canvasContext.lineTo(x, (height + y) * 0.5);
|
||||
}
|
||||
this.canvasContext.stroke();
|
||||
const now = new Date().getTime();
|
||||
@@ -237,12 +246,28 @@ class AudioRecorder extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCo
|
||||
}
|
||||
initMediaRecording(stream) {
|
||||
this.stream = stream;
|
||||
this.mediaRecorder = new MediaRecorder(stream, {
|
||||
mimeType: AUDIO_MIME_TYPE,
|
||||
audioBitsPerSecond: 24_000
|
||||
AUDIO_MIME_TYPES.some(mimeType => {
|
||||
if (MediaRecorder.isTypeSupported(mimeType)) {
|
||||
this.mediaRecorder = new MediaRecorder(stream, {
|
||||
mimeType: mimeType,
|
||||
audioBitsPerSecond: 24_000
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (!this.mediaRecorder) {
|
||||
console.warn('MediaRecorder failed to initialize: no supported audio formats');
|
||||
this.props.onError(this.props.intl.formatMessage(messages.failed_to_init_audio));
|
||||
return;
|
||||
}
|
||||
this.audioContext = new AudioContext();
|
||||
this.audioInput = this.audioContext.createMediaStreamSource(stream);
|
||||
if (!this.audioInput) {
|
||||
console.warn('createMediaStreamSource returned null: audio input unavailable');
|
||||
this.props.onError(this.props.intl.formatMessage(messages.failed_to_init_audio));
|
||||
return;
|
||||
}
|
||||
this.analyser = this.audioContext.createAnalyser();
|
||||
this.analyser.fftSize = BUFFER_SIZE;
|
||||
this.audioInput.connect(this.analyser);
|
||||
@@ -275,11 +300,12 @@ class AudioRecorder extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCo
|
||||
this.recordingTimestamp = this.startedOn;
|
||||
}
|
||||
getRecording(mimeType) {
|
||||
mimeType = mimeType || AUDIO_MIME_TYPE;
|
||||
mimeType = mimeType || DEFAULT_AUDIO_MIME_TYPE;
|
||||
let blob = new Blob(this.audioChunks, {
|
||||
type: mimeType
|
||||
});
|
||||
return webm_duration_fix__WEBPACK_IMPORTED_MODULE_3___default()(blob, mimeType).then(fixedBlob => {
|
||||
const result = mimeType == DEFAULT_AUDIO_MIME_TYPE ? webm_duration_fix__WEBPACK_IMPORTED_MODULE_3___default()(blob, mimeType) : Promise.resolve(blob);
|
||||
return result.then(fixedBlob => {
|
||||
blob = fixedBlob;
|
||||
return fixedBlob.arrayBuffer();
|
||||
}).then(arrayBuff => this.audioContext.decodeAudioData(arrayBuff)).then(decoded => this.createPreview(decoded)).then(preview => ({
|
||||
@@ -311,7 +337,9 @@ class AudioRecorder extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCo
|
||||
return buffer;
|
||||
}
|
||||
cleanUp() {
|
||||
this.audioInput.disconnect();
|
||||
if (this.audioInput) {
|
||||
this.audioInput.disconnect();
|
||||
}
|
||||
this.stream.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
render() {
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,3 +1,3 @@
|
||||
// This is a generated file. Don't edit.
|
||||
|
||||
const PACKAGE_VERSION = "0.22.2";
|
||||
const PACKAGE_VERSION = "0.22.3";
|
||||
|
||||
Reference in New Issue
Block a user