From 235b7cba18377389985505999f5a9538363ca43b Mon Sep 17 00:00:00 2001 From: Patrik Oberschmid Date: Mon, 13 Apr 2026 12:14:01 +0200 Subject: [PATCH] Add availability indicators on people tiles and share fetch service New Availability module decorates each people-tile with a vertical status bar on the avatar and a dot+timestamp under the status text, showing ZeitConsens loggedIn state per extension. Status and Availability now share a single AvailabilityService that polls /availability every 30s, halving the API load. Avatars are enlarged to 57px and vertically centered to fit the new layout. --- 3CX_TAPI.user.js | 696 +++++++++++++++++++---------- client/src/availability-info.ts | 5 + client/src/availability-service.ts | 43 ++ client/src/availability.css | 69 +++ client/src/availability.ts | 83 ++++ client/src/index.js | 10 +- client/src/status.ts | 67 +-- client/src/zc-status.ts | 4 - 8 files changed, 718 insertions(+), 259 deletions(-) create mode 100644 client/src/availability-info.ts create mode 100644 client/src/availability-service.ts create mode 100644 client/src/availability.css create mode 100644 client/src/availability.ts delete mode 100644 client/src/zc-status.ts diff --git a/3CX_TAPI.user.js b/3CX_TAPI.user.js index 056d2f0..1915297 100644 --- a/3CX_TAPI.user.js +++ b/3CX_TAPI.user.js @@ -122,6 +122,98 @@ module.exports = function (i) { return i[1]; }; +/***/ }, + +/***/ "./node_modules/css-loader/dist/cjs.js!./src/availability.css" +(module, __webpack_exports__, __webpack_require__) { + +"use strict"; +/* harmony export */ __webpack_require__.d(__webpack_exports__, { +/* harmony export */ A: () => (__WEBPACK_DEFAULT_EXPORT__) +/* harmony export */ }); +/* harmony import */ var _node_modules_css_loader_dist_runtime_noSourceMaps_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./node_modules/css-loader/dist/runtime/noSourceMaps.js"); +/* harmony import */ var _node_modules_css_loader_dist_runtime_noSourceMaps_js__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(_node_modules_css_loader_dist_runtime_noSourceMaps_js__WEBPACK_IMPORTED_MODULE_0__); +/* harmony import */ var _node_modules_css_loader_dist_runtime_api_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__("./node_modules/css-loader/dist/runtime/api.js"); +/* harmony import */ var _node_modules_css_loader_dist_runtime_api_js__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(_node_modules_css_loader_dist_runtime_api_js__WEBPACK_IMPORTED_MODULE_1__); +// Imports + + +var ___CSS_LOADER_EXPORT___ = _node_modules_css_loader_dist_runtime_api_js__WEBPACK_IMPORTED_MODULE_1___default()((_node_modules_css_loader_dist_runtime_noSourceMaps_js__WEBPACK_IMPORTED_MODULE_0___default())); +// Module +___CSS_LOADER_EXPORT___.push([module.id, `.tapi-availability { + grid-column: 2; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 4px; + font-size: 9px; + opacity: 0.7; + line-height: 1; + white-space: nowrap; + overflow: hidden; + margin-top: -4px; + margin-bottom: 6px; +} + +.tapi-availability small { + font-size: inherit; +} + +.tapi-dot { + width: 6px; + height: 6px; + border-radius: 50%; + display: inline-block; + flex-shrink: 0; +} + +.tapi-dot-on { + background-color: #5cb85c; +} + +.tapi-dot-off { + background-color: #d9534f; +} + +.tapi-avatar-square { + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 5px; + z-index: 10; +} + +people-tile .avatar-size-m, +people-tile .avatar-size-m .avatar-container, +people-tile .avatar-size-m .avatar-content { + width: 57px !important; + height: 57px !important; + line-height: 57px !important; + margin-top: 10px; +} + +people-tile { + grid-template-rows: 29px 23px; +} + +people-tile app-avatar { + grid-row: 1 / span 2 !important; + align-self: center !important; +} + +.tapi-square-on { + background-color: #5cb85c; +} + +.tapi-square-off { + background-color: #d9534f; +} +`, ""]); +// Exports +/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (___CSS_LOADER_EXPORT___); + + /***/ }, /***/ "./node_modules/css-loader/dist/cjs.js!./src/presence.css" @@ -658,6 +750,335 @@ var __webpack_exports__ = {}; (() => { "use strict"; +// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js +var injectStylesIntoStyleTag = __webpack_require__("./node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js"); +var injectStylesIntoStyleTag_default = /*#__PURE__*/__webpack_require__.n(injectStylesIntoStyleTag); +// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/styleDomAPI.js +var styleDomAPI = __webpack_require__("./node_modules/style-loader/dist/runtime/styleDomAPI.js"); +var styleDomAPI_default = /*#__PURE__*/__webpack_require__.n(styleDomAPI); +// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/insertBySelector.js +var insertBySelector = __webpack_require__("./node_modules/style-loader/dist/runtime/insertBySelector.js"); +var insertBySelector_default = /*#__PURE__*/__webpack_require__.n(insertBySelector); +// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/setAttributesWithoutAttributes.js +var setAttributesWithoutAttributes = __webpack_require__("./node_modules/style-loader/dist/runtime/setAttributesWithoutAttributes.js"); +var setAttributesWithoutAttributes_default = /*#__PURE__*/__webpack_require__.n(setAttributesWithoutAttributes); +// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/insertStyleElement.js +var insertStyleElement = __webpack_require__("./node_modules/style-loader/dist/runtime/insertStyleElement.js"); +var insertStyleElement_default = /*#__PURE__*/__webpack_require__.n(insertStyleElement); +// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/styleTagTransform.js +var styleTagTransform = __webpack_require__("./node_modules/style-loader/dist/runtime/styleTagTransform.js"); +var styleTagTransform_default = /*#__PURE__*/__webpack_require__.n(styleTagTransform); +// EXTERNAL MODULE: ./node_modules/css-loader/dist/cjs.js!./src/availability.css +var availability = __webpack_require__("./node_modules/css-loader/dist/cjs.js!./src/availability.css"); +;// ./src/availability.css + + + + + + + + + + + +var options = {}; + +options.styleTagTransform = (styleTagTransform_default()); +options.setAttributes = (setAttributesWithoutAttributes_default()); +options.insert = insertBySelector_default().bind(null, "head"); +options.domAPI = (styleDomAPI_default()); +options.insertStyleElement = (insertStyleElement_default()); + +var update = injectStylesIntoStyleTag_default()(availability/* default */.A, options); + + + + + /* harmony default export */ const src_availability = (availability/* default */.A && availability/* default */.A.locals ? availability/* default */.A.locals : undefined); + +;// ./src/availability.ts + +class Availability { + _service; + _availabilities = []; + constructor(service) { + this._service = service; + } + start() { + this._service.subscribe((avs) => { + this._availabilities = avs; + this.updateAllIndicators(); + }); + waitForKeyElements('people-tile', (element) => { this.decorateTile(element); }, false); + } + decorateTile(tile) { + if (tile.querySelector('.tapi-availability')) { + return; + } + var extEl = tile.querySelector('span[data-id="extPhone"]'); + if (!extEl) { + return; + } + var extension = extEl.textContent.trim(); + var indicator = document.createElement('div'); + indicator.classList.add('tapi-availability'); + indicator.dataset.tapiExtension = extension; + tile.appendChild(indicator); + this.updateIndicator(indicator); + var avatarContainer = tile.querySelector('app-avatar .avatar-container'); + if (avatarContainer) { + var square = document.createElement('i'); + square.classList.add('tapi-avatar-square'); + square.dataset.tapiExtension = extension; + avatarContainer.appendChild(square); + this.updateSquare(square); + } + } + updateAllIndicators() { + var indicators = document.getElementsByClassName('tapi-availability'); + for (var i of indicators) { + this.updateIndicator(i); + } + var squares = document.getElementsByClassName('tapi-avatar-square'); + for (var s of squares) { + this.updateSquare(s); + } + } + updateIndicator(indicator) { + var extension = indicator.dataset.tapiExtension; + var entry = this._availabilities.find(a => a.extension === extension); + if (!entry) { + indicator.innerHTML = ''; + return; + } + var dotClass = entry.loggedIn ? 'tapi-dot-on' : 'tapi-dot-off'; + var mockedTime = '13.04. 08:30'; + indicator.innerHTML = '' + mockedTime + ''; + } + updateSquare(square) { + var extension = square.dataset.tapiExtension; + var entry = this._availabilities.find(a => a.extension === extension); + square.classList.remove('tapi-square-on', 'tapi-square-off'); + if (!entry) { + square.style.display = 'none'; + return; + } + square.style.display = ''; + square.classList.add(entry.loggedIn ? 'tapi-square-on' : 'tapi-square-off'); + } +} + +;// ./src/config.ts +class _Config { + tapi_server_url = 'https://3cxtapi.cp-austria.at'; +} +const Config = new _Config(); + +;// ./node_modules/@trim21/gm-fetch/dist/index.mjs +function parseRawHeaders(h) { + const s = h.trim(); + if (!s) { + return new Headers(); + } + const array = s.split("\r\n").map((value) => { + let s = value.split(":"); + return [s[0].trim(), s[1].trim()]; + }); + return new Headers(array); +} +function parseGMResponse(req, res) { + // workaround TamperMonkey bug(?) where sometimes response is string despite responseType being "blob" + const headers = parseRawHeaders(res.responseHeaders); + const body = typeof res.response === "string" + ? new Blob([res.response], { type: headers.get("Content-Type") || "text/plain" }) + : res.response; + return new ResImpl(body, { + statusCode: res.status, + statusText: res.statusText, + headers, + finalUrl: res.finalUrl, + redirected: res.finalUrl === req.url, + }); +} +class ResImpl { + constructor(body, init) { + this.rawBody = body; + this.init = init; + this.body = body.stream(); + const { headers, statusCode, statusText, finalUrl, redirected } = init; + this.headers = headers; + this.status = statusCode; + this.statusText = statusText; + this.url = finalUrl; + this.type = "basic"; + this.redirected = redirected; + this._bodyUsed = false; + } + get bodyUsed() { + return this._bodyUsed; + } + get ok() { + return this.status < 300; + } + arrayBuffer() { + if (this.bodyUsed) { + throw new TypeError("Failed to execute 'arrayBuffer' on 'Response': body stream already read"); + } + this._bodyUsed = true; + return this.rawBody.arrayBuffer(); + } + blob() { + if (this.bodyUsed) { + throw new TypeError("Failed to execute 'blob' on 'Response': body stream already read"); + } + this._bodyUsed = true; + // `slice` will use empty string as default value, so need to pass all arguments. + return Promise.resolve(this.rawBody.slice(0, this.rawBody.size, this.rawBody.type)); + } + clone() { + if (this.bodyUsed) { + throw new TypeError("Failed to execute 'clone' on 'Response': body stream already read"); + } + return new ResImpl(this.rawBody, this.init); + } + formData() { + if (this.bodyUsed) { + throw new TypeError("Failed to execute 'formData' on 'Response': body stream already read"); + } + this._bodyUsed = true; + return this.rawBody.text().then(decode); + } + async json() { + if (this.bodyUsed) { + throw new TypeError("Failed to execute 'json' on 'Response': body stream already read"); + } + this._bodyUsed = true; + return JSON.parse(await this.rawBody.text()); + } + text() { + if (this.bodyUsed) { + throw new TypeError("Failed to execute 'text' on 'Response': body stream already read"); + } + this._bodyUsed = true; + return this.rawBody.text(); + } + async bytes() { + if (this.bodyUsed) { + throw new TypeError("Failed to execute 'bytes' on 'Response': body stream already read"); + } + this._bodyUsed = true; + return new Uint8Array(await this.rawBody.arrayBuffer()); + } +} +function decode(body) { + const form = new FormData(); + body + .trim() + .split("&") + .forEach(function (bytes) { + if (bytes) { + const split = bytes.split("="); + const name = split.shift()?.replace(/\+/g, " "); + const value = split.join("=").replace(/\+/g, " "); + form.append(decodeURIComponent(name), decodeURIComponent(value)); + } + }); + return form; +} + +async function GM_fetch(input, init) { + const request = new Request(input, init); + let data; + if (init?.body) { + data = await request.text(); + } + return await XHR(request, init, data); +} +function XHR(request, init, data) { + return new Promise((resolve, reject) => { + if (request.signal && request.signal.aborted) { + return reject(new DOMException("Aborted", "AbortError")); + } + GM.xmlHttpRequest({ + url: request.url, + method: gmXHRMethod(request.method.toUpperCase()), + headers: Object.fromEntries(new Headers(init?.headers).entries()), + data: data, + responseType: "blob", + onload(res) { + try { + resolve(parseGMResponse(request, res)); + } + catch (e) { + reject(e); + } + }, + onabort() { + reject(new DOMException("Aborted", "AbortError")); + }, + ontimeout() { + reject(new TypeError("Network request failed, timeout")); + }, + onerror(err) { + reject(new TypeError("Failed to fetch: " + err.finalUrl)); + }, + }); + }); +} +const httpMethods = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "TRACE", "OPTIONS", "CONNECT"]; +// a ts type helper to narrow type +function includes(array, element) { + return array.includes(element); +} +function gmXHRMethod(method) { + if (includes(httpMethods, method)) { + return method; + } + throw new Error(`unsupported http method ${method}`); +} + + +//# sourceMappingURL=index.mjs.map + +;// ./src/availability-service.ts + + +class AvailabilityService { + _availabilities = []; + _listeners = []; + _started = false; + start() { + if (this._started) { + return; + } + this._started = true; + this.fetch(); + setInterval(() => this.fetch(), 30000); + } + subscribe(listener) { + this._listeners.push(listener); + if (this._availabilities.length > 0) { + listener(this._availabilities); + } + } + get availabilities() { + return this._availabilities; + } + async fetch() { + try { + var response = await GM_fetch(Config.tapi_server_url + '/availability'); + if (response.status === 200) { + this._availabilities = await response.json(); + this._listeners.forEach(l => l(this._availabilities)); + } + } + catch (error) { + console.log(error); + } + } +} + ;// ./node_modules/chrono-node/dist/esm/types.js var Meridiem; (function (Meridiem) { @@ -4248,178 +4669,6 @@ function fireChangeEvents(element) { console.debug('change event dispatched for element: ' + element.id); } -;// ./node_modules/@trim21/gm-fetch/dist/index.mjs -function parseRawHeaders(h) { - const s = h.trim(); - if (!s) { - return new Headers(); - } - const array = s.split("\r\n").map((value) => { - let s = value.split(":"); - return [s[0].trim(), s[1].trim()]; - }); - return new Headers(array); -} -function parseGMResponse(req, res) { - // workaround TamperMonkey bug(?) where sometimes response is string despite responseType being "blob" - const headers = parseRawHeaders(res.responseHeaders); - const body = typeof res.response === "string" - ? new Blob([res.response], { type: headers.get("Content-Type") || "text/plain" }) - : res.response; - return new ResImpl(body, { - statusCode: res.status, - statusText: res.statusText, - headers, - finalUrl: res.finalUrl, - redirected: res.finalUrl === req.url, - }); -} -class ResImpl { - constructor(body, init) { - this.rawBody = body; - this.init = init; - this.body = body.stream(); - const { headers, statusCode, statusText, finalUrl, redirected } = init; - this.headers = headers; - this.status = statusCode; - this.statusText = statusText; - this.url = finalUrl; - this.type = "basic"; - this.redirected = redirected; - this._bodyUsed = false; - } - get bodyUsed() { - return this._bodyUsed; - } - get ok() { - return this.status < 300; - } - arrayBuffer() { - if (this.bodyUsed) { - throw new TypeError("Failed to execute 'arrayBuffer' on 'Response': body stream already read"); - } - this._bodyUsed = true; - return this.rawBody.arrayBuffer(); - } - blob() { - if (this.bodyUsed) { - throw new TypeError("Failed to execute 'blob' on 'Response': body stream already read"); - } - this._bodyUsed = true; - // `slice` will use empty string as default value, so need to pass all arguments. - return Promise.resolve(this.rawBody.slice(0, this.rawBody.size, this.rawBody.type)); - } - clone() { - if (this.bodyUsed) { - throw new TypeError("Failed to execute 'clone' on 'Response': body stream already read"); - } - return new ResImpl(this.rawBody, this.init); - } - formData() { - if (this.bodyUsed) { - throw new TypeError("Failed to execute 'formData' on 'Response': body stream already read"); - } - this._bodyUsed = true; - return this.rawBody.text().then(decode); - } - async json() { - if (this.bodyUsed) { - throw new TypeError("Failed to execute 'json' on 'Response': body stream already read"); - } - this._bodyUsed = true; - return JSON.parse(await this.rawBody.text()); - } - text() { - if (this.bodyUsed) { - throw new TypeError("Failed to execute 'text' on 'Response': body stream already read"); - } - this._bodyUsed = true; - return this.rawBody.text(); - } - async bytes() { - if (this.bodyUsed) { - throw new TypeError("Failed to execute 'bytes' on 'Response': body stream already read"); - } - this._bodyUsed = true; - return new Uint8Array(await this.rawBody.arrayBuffer()); - } -} -function decode(body) { - const form = new FormData(); - body - .trim() - .split("&") - .forEach(function (bytes) { - if (bytes) { - const split = bytes.split("="); - const name = split.shift()?.replace(/\+/g, " "); - const value = split.join("=").replace(/\+/g, " "); - form.append(decodeURIComponent(name), decodeURIComponent(value)); - } - }); - return form; -} - -async function GM_fetch(input, init) { - const request = new Request(input, init); - let data; - if (init?.body) { - data = await request.text(); - } - return await XHR(request, init, data); -} -function XHR(request, init, data) { - return new Promise((resolve, reject) => { - if (request.signal && request.signal.aborted) { - return reject(new DOMException("Aborted", "AbortError")); - } - GM.xmlHttpRequest({ - url: request.url, - method: gmXHRMethod(request.method.toUpperCase()), - headers: Object.fromEntries(new Headers(init?.headers).entries()), - data: data, - responseType: "blob", - onload(res) { - try { - resolve(parseGMResponse(request, res)); - } - catch (e) { - reject(e); - } - }, - onabort() { - reject(new DOMException("Aborted", "AbortError")); - }, - ontimeout() { - reject(new TypeError("Network request failed, timeout")); - }, - onerror(err) { - reject(new TypeError("Failed to fetch: " + err.finalUrl)); - }, - }); - }); -} -const httpMethods = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "TRACE", "OPTIONS", "CONNECT"]; -// a ts type helper to narrow type -function includes(array, element) { - return array.includes(element); -} -function gmXHRMethod(method) { - if (includes(httpMethods, method)) { - return method; - } - throw new Error(`unsupported http method ${method}`); -} - - -//# sourceMappingURL=index.mjs.map - -;// ./src/config.ts -class _Config { - tapi_server_url = 'https://3cxtapi.cp-austria.at'; -} -const Config = new _Config(); - ;// ./src/call-history.ts @@ -4531,24 +4780,6 @@ class CallNotification { } } -// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js -var injectStylesIntoStyleTag = __webpack_require__("./node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js"); -var injectStylesIntoStyleTag_default = /*#__PURE__*/__webpack_require__.n(injectStylesIntoStyleTag); -// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/styleDomAPI.js -var styleDomAPI = __webpack_require__("./node_modules/style-loader/dist/runtime/styleDomAPI.js"); -var styleDomAPI_default = /*#__PURE__*/__webpack_require__.n(styleDomAPI); -// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/insertBySelector.js -var insertBySelector = __webpack_require__("./node_modules/style-loader/dist/runtime/insertBySelector.js"); -var insertBySelector_default = /*#__PURE__*/__webpack_require__.n(insertBySelector); -// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/setAttributesWithoutAttributes.js -var setAttributesWithoutAttributes = __webpack_require__("./node_modules/style-loader/dist/runtime/setAttributesWithoutAttributes.js"); -var setAttributesWithoutAttributes_default = /*#__PURE__*/__webpack_require__.n(setAttributesWithoutAttributes); -// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/insertStyleElement.js -var insertStyleElement = __webpack_require__("./node_modules/style-loader/dist/runtime/insertStyleElement.js"); -var insertStyleElement_default = /*#__PURE__*/__webpack_require__.n(insertStyleElement); -// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/styleTagTransform.js -var styleTagTransform = __webpack_require__("./node_modules/style-loader/dist/runtime/styleTagTransform.js"); -var styleTagTransform_default = /*#__PURE__*/__webpack_require__.n(styleTagTransform); // EXTERNAL MODULE: ./node_modules/css-loader/dist/cjs.js!./src/presence.css var presence = __webpack_require__("./node_modules/css-loader/dist/cjs.js!./src/presence.css"); ;// ./src/presence.css @@ -4563,15 +4794,15 @@ var presence = __webpack_require__("./node_modules/css-loader/dist/cjs.js!./src/ -var options = {}; +var presence_options = {}; -options.styleTagTransform = (styleTagTransform_default()); -options.setAttributes = (setAttributesWithoutAttributes_default()); -options.insert = insertBySelector_default().bind(null, "head"); -options.domAPI = (styleDomAPI_default()); -options.insertStyleElement = (insertStyleElement_default()); +presence_options.styleTagTransform = (styleTagTransform_default()); +presence_options.setAttributes = (setAttributesWithoutAttributes_default()); +presence_options.insert = insertBySelector_default().bind(null, "head"); +presence_options.domAPI = (styleDomAPI_default()); +presence_options.insertStyleElement = (insertStyleElement_default()); -var update = injectStylesIntoStyleTag_default()(presence/* default */.A, options); +var presence_update = injectStylesIntoStyleTag_default()(presence/* default */.A, presence_options); @@ -4864,48 +5095,53 @@ var status_update = injectStylesIntoStyleTag_default()(cjs_js_src_status/* defau ;// ./src/status.ts - - const zcIcon = __webpack_require__("./src/stopwatch-regular-full.svg"); class Status { + _service; _user; _enabled = false; _statusOn = 'menuAvailable'; _statusOff = 'menuAway'; _currentStatus = undefined; + _subscribed = false; + constructor(service) { + this._service = service; + } async showStatus(element) { this._user = await GM.getValue('tapi-zc-user', ''); this._enabled = await GM.getValue('tapi-zc-enabled', false); this._statusOn = await GM.getValue('tapi-zc-on', 'menuAvailable'); this._statusOff = await GM.getValue('tapi-zc-off', 'menuAvailable'); console.log('tapi-zc-user', this._user, 'tapi-zc-enabled', this._enabled, 'tapi-zc-on', this._statusOn, 'tapi-zc-off', this._statusOff); - this.checkStatus(); + this.subscribeOnce(); waitForKeyElements("wc-account-menu > div > ul", (element) => { this.addZcStatusPopup(element); }, true); } - async checkStatus() { - if (this._enabled) { - try { - var response = await GM_fetch(Config.tapi_server_url + '/availability/' + encodeURIComponent(this._user)); - if (response.status == 200) { - var status = await response.json(); - if (this._currentStatus !== status.loggedIn) { - this._currentStatus = status.loggedIn; - console.log('New status, loggedIn', this._currentStatus); - var accMenu = document.getElementsByTagName("wc-account-menu")[0]; - var avatar = accMenu.getElementsByTagName("app-avatar")[0]; - avatar.click(); - setTimeout(() => { - var statusId = this._currentStatus ? this._statusOn : this._statusOff; - console.log('Clicking status', statusId); - document.getElementById(statusId).click(); - }, 1000); - } - } - } - catch (error) { - console.log(error); - } - setTimeout(() => this.checkStatus(), 30000); + subscribeOnce() { + if (this._subscribed) { + return; + } + this._subscribed = true; + this._service.subscribe((avs) => this.onAvailabilities(avs)); + } + onAvailabilities(avs) { + if (!this._enabled || !this._user) { + return; + } + var entry = avs.find(a => a.user === this._user); + if (!entry) { + return; + } + if (this._currentStatus !== entry.loggedIn) { + this._currentStatus = entry.loggedIn; + console.log('New status, loggedIn', this._currentStatus); + var accMenu = document.getElementsByTagName("wc-account-menu")[0]; + var avatar = accMenu.getElementsByTagName("app-avatar")[0]; + avatar.click(); + setTimeout(() => { + var statusId = this._currentStatus ? this._statusOn : this._statusOff; + console.log('Clicking status', statusId); + document.getElementById(statusId).click(); + }, 1000); } } addZcStatusPopup(element) { @@ -4998,7 +5234,9 @@ class Status { GM.setValue('tapi-zc-enabled', this._enabled); console.log('tapi-zc-enabled', this._enabled); this._currentStatus = undefined; - this.checkStatus(); + if (this._enabled) { + this.onAvailabilities(this._service.availabilities); + } }; var zcOn = document.getElementById('tapi-zc-on'); zcOn.value = this._statusOn; @@ -5054,6 +5292,8 @@ class Status { + + console.log('script start'); const src_search_0 = new Search(); // eslint-disable-next-line no-undef @@ -5075,7 +5315,9 @@ const callHistory = new CallHistory(); waitForKeyElements('call', element => { callHistory.showCallHistory(element); }, false); -const src_status_0 = new Status(); +const availabilityService = new AvailabilityService(); +availabilityService.start(); +const src_status_0 = new Status(availabilityService); // eslint-disable-next-line no-undef waitForKeyElements('wc-account-menu', element => { src_status_0.showStatus(element); @@ -5083,6 +5325,8 @@ waitForKeyElements('wc-account-menu', element => { waitForKeyElements('wc-account-menu i.status-indicator', element => { src_status_0.watchStatus(element); }, false); +const src_availability_0 = new Availability(availabilityService); +src_availability_0.start(); })(); /******/ })() diff --git a/client/src/availability-info.ts b/client/src/availability-info.ts new file mode 100644 index 0000000..06a96ef --- /dev/null +++ b/client/src/availability-info.ts @@ -0,0 +1,5 @@ +export class AvailabilityInfo { + public user: string; + public loggedIn: boolean; + public extension: string; +} diff --git a/client/src/availability-service.ts b/client/src/availability-service.ts new file mode 100644 index 0000000..7f72dec --- /dev/null +++ b/client/src/availability-service.ts @@ -0,0 +1,43 @@ +import { Config } from './config' +import { AvailabilityInfo } from './availability-info' +import GM_fetch from '@trim21/gm-fetch' + +type Listener = (availabilities: AvailabilityInfo[]) => void + +export class AvailabilityService { + private _availabilities: AvailabilityInfo[] = [] + private _listeners: Listener[] = [] + private _started = false + + public start() { + if (this._started) { + return + } + this._started = true + this.fetch() + setInterval(() => this.fetch(), 30000) + } + + public subscribe(listener: Listener) { + this._listeners.push(listener) + if (this._availabilities.length > 0) { + listener(this._availabilities) + } + } + + public get availabilities(): AvailabilityInfo[] { + return this._availabilities + } + + private async fetch() { + try { + var response = await GM_fetch(Config.tapi_server_url + '/availability') + if (response.status === 200) { + this._availabilities = await response.json() as AvailabilityInfo[] + this._listeners.forEach(l => l(this._availabilities)) + } + } catch (error) { + console.log(error) + } + } +} diff --git a/client/src/availability.css b/client/src/availability.css new file mode 100644 index 0000000..81d8027 --- /dev/null +++ b/client/src/availability.css @@ -0,0 +1,69 @@ +.tapi-availability { + grid-column: 2; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 4px; + font-size: 9px; + opacity: 0.7; + line-height: 1; + white-space: nowrap; + overflow: hidden; + margin-top: -4px; + margin-bottom: 6px; +} + +.tapi-availability small { + font-size: inherit; +} + +.tapi-dot { + width: 6px; + height: 6px; + border-radius: 50%; + display: inline-block; + flex-shrink: 0; +} + +.tapi-dot-on { + background-color: #5cb85c; +} + +.tapi-dot-off { + background-color: #d9534f; +} + +.tapi-avatar-square { + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 5px; + z-index: 10; +} + +people-tile .avatar-size-m, +people-tile .avatar-size-m .avatar-container, +people-tile .avatar-size-m .avatar-content { + width: 57px !important; + height: 57px !important; + line-height: 57px !important; + margin-top: 10px; +} + +people-tile { + grid-template-rows: 29px 23px; +} + +people-tile app-avatar { + grid-row: 1 / span 2 !important; + align-self: center !important; +} + +.tapi-square-on { + background-color: #5cb85c; +} + +.tapi-square-off { + background-color: #d9534f; +} diff --git a/client/src/availability.ts b/client/src/availability.ts new file mode 100644 index 0000000..275aadd --- /dev/null +++ b/client/src/availability.ts @@ -0,0 +1,83 @@ +import './availability.css' +import { AvailabilityInfo } from './availability-info' +import { AvailabilityService } from './availability-service' + +declare function waitForKeyElements(selectorOrFunction: any, callback: any, waitOnce: boolean): any; + +export class Availability { + private _service: AvailabilityService + private _availabilities: AvailabilityInfo[] = [] + + constructor(service: AvailabilityService) { + this._service = service + } + + public start() { + this._service.subscribe((avs) => { + this._availabilities = avs + this.updateAllIndicators() + }) + waitForKeyElements('people-tile', (element: HTMLElement) => { this.decorateTile(element) }, false) + } + + private decorateTile(tile: HTMLElement) { + if (tile.querySelector('.tapi-availability')) { + return + } + var extEl = tile.querySelector('span[data-id="extPhone"]') as HTMLElement + if (!extEl) { + return + } + var extension = extEl.textContent.trim() + + var indicator = document.createElement('div') + indicator.classList.add('tapi-availability') + indicator.dataset.tapiExtension = extension + tile.appendChild(indicator) + this.updateIndicator(indicator) + + var avatarContainer = tile.querySelector('app-avatar .avatar-container') as HTMLElement + if (avatarContainer) { + var square = document.createElement('i') + square.classList.add('tapi-avatar-square') + square.dataset.tapiExtension = extension + avatarContainer.appendChild(square) + this.updateSquare(square) + } + } + + private updateAllIndicators() { + var indicators = document.getElementsByClassName('tapi-availability') + for (var i of indicators) { + this.updateIndicator(i as HTMLElement) + } + var squares = document.getElementsByClassName('tapi-avatar-square') + for (var s of squares) { + this.updateSquare(s as HTMLElement) + } + } + + private updateIndicator(indicator: HTMLElement) { + var extension = indicator.dataset.tapiExtension + var entry = this._availabilities.find(a => a.extension === extension) + if (!entry) { + indicator.innerHTML = '' + return + } + var dotClass = entry.loggedIn ? 'tapi-dot-on' : 'tapi-dot-off' + var mockedTime = '13.04. 08:30' + indicator.innerHTML = '' + mockedTime + '' + } + + private updateSquare(square: HTMLElement) { + var extension = square.dataset.tapiExtension + var entry = this._availabilities.find(a => a.extension === extension) + square.classList.remove('tapi-square-on', 'tapi-square-off') + if (!entry) { + square.style.display = 'none' + return + } + square.style.display = '' + square.classList.add(entry.loggedIn ? 'tapi-square-on' : 'tapi-square-off') + } +} diff --git a/client/src/index.js b/client/src/index.js index 2bf8e71..7c16033 100644 --- a/client/src/index.js +++ b/client/src/index.js @@ -1,5 +1,7 @@ // eslint-disable-next-line @typescript-eslint/no-unused-vars import * as chrono from 'chrono-node' +import { Availability } from './availability' +import { AvailabilityService } from './availability-service' import { CallHistory } from './call-history' import { CallNotification } from './call-notification' import { Presence } from './presence' @@ -24,8 +26,14 @@ const callHistory = new CallHistory() // eslint-disable-next-line no-undef waitForKeyElements('call', (element) => { callHistory.showCallHistory(element) }, false) -const status = new Status() +const availabilityService = new AvailabilityService() +availabilityService.start() + +const status = new Status(availabilityService) // eslint-disable-next-line no-undef waitForKeyElements('wc-account-menu', (element) => { status.showStatus(element) }, false) waitForKeyElements('wc-account-menu i.status-indicator', (element) => { status.watchStatus(element) }, false) + +const availability = new Availability(availabilityService) +availability.start() diff --git a/client/src/status.ts b/client/src/status.ts index 85c8d00..b44feb8 100644 --- a/client/src/status.ts +++ b/client/src/status.ts @@ -1,17 +1,22 @@ -import { Config } from './config'; import './status.css'; -import { ZcStatus } from './zc-status'; -import GM_fetch from "@trim21/gm-fetch"; +import { AvailabilityInfo } from './availability-info'; +import { AvailabilityService } from './availability-service'; const zcIcon = require('./stopwatch-regular-full.svg'); declare function waitForKeyElements(selectorOrFunction: any, callback: any, waitOnce: boolean): any; export class Status { + private _service: AvailabilityService; private _user: string; private _enabled = false; private _statusOn = 'menuAvailable'; private _statusOff = 'menuAway'; private _currentStatus: boolean = undefined; + private _subscribed = false; + + constructor(service: AvailabilityService) { + this._service = service; + } public async showStatus(element: HTMLElement) { this._user = await GM.getValue('tapi-zc-user', ''); @@ -20,34 +25,38 @@ export class Status { this._statusOff = await GM.getValue('tapi-zc-off', 'menuAvailable'); console.log('tapi-zc-user', this._user, 'tapi-zc-enabled', this._enabled, 'tapi-zc-on', this._statusOn, 'tapi-zc-off', this._statusOff); - this.checkStatus(); + this.subscribeOnce(); waitForKeyElements("wc-account-menu > div > ul", (element: HTMLElement) => { this.addZcStatusPopup(element) }, true); } - private async checkStatus() { - if (this._enabled) { - try { - var response = await GM_fetch(Config.tapi_server_url + '/availability/' + encodeURIComponent(this._user)); - if (response.status == 200) { - var status = await response.json() as ZcStatus; - if (this._currentStatus !== status.loggedIn) { - this._currentStatus = status.loggedIn; - console.log('New status, loggedIn', this._currentStatus); - var accMenu = document.getElementsByTagName("wc-account-menu")[0]; - var avatar = accMenu.getElementsByTagName("app-avatar")[0] as HTMLAnchorElement; - avatar.click(); - setTimeout(() => { - var statusId = this._currentStatus ? this._statusOn : this._statusOff; - console.log('Clicking status', statusId); - (document.getElementById(statusId) as HTMLSpanElement).click(); - }, 1000); - } - } - } catch (error) { - console.log(error); - } - setTimeout(() => this.checkStatus(), 30000); + private subscribeOnce() { + if (this._subscribed) { + return; + } + this._subscribed = true; + this._service.subscribe((avs) => this.onAvailabilities(avs)); + } + + private onAvailabilities(avs: AvailabilityInfo[]) { + if (!this._enabled || !this._user) { + return; + } + var entry = avs.find(a => a.user === this._user); + if (!entry) { + return; + } + if (this._currentStatus !== entry.loggedIn) { + this._currentStatus = entry.loggedIn; + console.log('New status, loggedIn', this._currentStatus); + var accMenu = document.getElementsByTagName("wc-account-menu")[0]; + var avatar = accMenu.getElementsByTagName("app-avatar")[0] as HTMLAnchorElement; + avatar.click(); + setTimeout(() => { + var statusId = this._currentStatus ? this._statusOn : this._statusOff; + console.log('Clicking status', statusId); + (document.getElementById(statusId) as HTMLSpanElement).click(); + }, 1000); } } @@ -149,7 +158,9 @@ export class Status { GM.setValue('tapi-zc-enabled', this._enabled); console.log('tapi-zc-enabled', this._enabled); this._currentStatus = undefined; - this.checkStatus(); + if (this._enabled) { + this.onAvailabilities(this._service.availabilities); + } } var zcOn = document.getElementById('tapi-zc-on') as HTMLSelectElement; diff --git a/client/src/zc-status.ts b/client/src/zc-status.ts deleted file mode 100644 index df5316c..0000000 --- a/client/src/zc-status.ts +++ /dev/null @@ -1,4 +0,0 @@ -export class ZcStatus { - public user: string; - public loggedIn: boolean; -}