Fix live updating (#7816)

This commit is contained in:
frytimo
2026-03-26 13:39:53 -03:00
committed by GitHub
parent c298035e3c
commit 340df86073
5 changed files with 377 additions and 40 deletions

View File

@@ -237,6 +237,94 @@
$apps[$x]['default_settings'][$y]['default_setting_enabled'] = "true";
$apps[$x]['default_settings'][$y]['default_setting_description'] = "Font Awesome icon class for connecting status.";
$y++;
$apps[$x]['default_settings'][$y]['default_setting_uuid'] = "ec8f0281-5d2b-4a3b-9481-f2c9880db101";
$apps[$x]['default_settings'][$y]['default_setting_category'] = "theme";
$apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "operator_panel_conference_icon_mute";
$apps[$x]['default_settings'][$y]['default_setting_name'] = "text";
$apps[$x]['default_settings'][$y]['default_setting_value'] = "fas fa-microphone";
$apps[$x]['default_settings'][$y]['default_setting_enabled'] = "true";
$apps[$x]['default_settings'][$y]['default_setting_description'] = "Font Awesome icon class for the conference mute action.";
$y++;
$apps[$x]['default_settings'][$y]['default_setting_uuid'] = "3d4dbe79-f1af-4bea-804e-27ee0ac31302";
$apps[$x]['default_settings'][$y]['default_setting_category'] = "theme";
$apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "operator_panel_conference_icon_unmute";
$apps[$x]['default_settings'][$y]['default_setting_name'] = "text";
$apps[$x]['default_settings'][$y]['default_setting_value'] = "fas fa-microphone-slash";
$apps[$x]['default_settings'][$y]['default_setting_enabled'] = "true";
$apps[$x]['default_settings'][$y]['default_setting_description'] = "Font Awesome icon class for the conference unmute action.";
$y++;
$apps[$x]['default_settings'][$y]['default_setting_uuid'] = "b15d1ae4-6087-48cf-a46f-148fb5236103";
$apps[$x]['default_settings'][$y]['default_setting_category'] = "theme";
$apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "operator_panel_conference_icon_deaf";
$apps[$x]['default_settings'][$y]['default_setting_name'] = "text";
$apps[$x]['default_settings'][$y]['default_setting_value'] = "fas fa-headphones";
$apps[$x]['default_settings'][$y]['default_setting_enabled'] = "true";
$apps[$x]['default_settings'][$y]['default_setting_description'] = "Font Awesome icon class for the conference deaf action.";
$y++;
$apps[$x]['default_settings'][$y]['default_setting_uuid'] = "2b560cff-58ef-42c2-b4e5-08d77c339a04";
$apps[$x]['default_settings'][$y]['default_setting_category'] = "theme";
$apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "operator_panel_conference_icon_undeaf";
$apps[$x]['default_settings'][$y]['default_setting_name'] = "text";
$apps[$x]['default_settings'][$y]['default_setting_value'] = "fas fa-deaf";
$apps[$x]['default_settings'][$y]['default_setting_enabled'] = "true";
$apps[$x]['default_settings'][$y]['default_setting_description'] = "Font Awesome icon class for the conference undeaf action.";
$y++;
$apps[$x]['default_settings'][$y]['default_setting_uuid'] = "21f8af30-d08f-4342-bddb-bb68804e1005";
$apps[$x]['default_settings'][$y]['default_setting_category'] = "theme";
$apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "operator_panel_conference_icon_energy_up";
$apps[$x]['default_settings'][$y]['default_setting_name'] = "text";
$apps[$x]['default_settings'][$y]['default_setting_value'] = "fas fa-plus";
$apps[$x]['default_settings'][$y]['default_setting_enabled'] = "true";
$apps[$x]['default_settings'][$y]['default_setting_description'] = "Font Awesome icon class for the conference energy up action.";
$y++;
$apps[$x]['default_settings'][$y]['default_setting_uuid'] = "4fdc30b2-7db8-4c9f-9b7e-4c78fa757706";
$apps[$x]['default_settings'][$y]['default_setting_category'] = "theme";
$apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "operator_panel_conference_icon_energy_down";
$apps[$x]['default_settings'][$y]['default_setting_name'] = "text";
$apps[$x]['default_settings'][$y]['default_setting_value'] = "fas fa-minus";
$apps[$x]['default_settings'][$y]['default_setting_enabled'] = "true";
$apps[$x]['default_settings'][$y]['default_setting_description'] = "Font Awesome icon class for the conference energy down action.";
$y++;
$apps[$x]['default_settings'][$y]['default_setting_uuid'] = "2b706528-8a6b-409c-84bb-95f905344707";
$apps[$x]['default_settings'][$y]['default_setting_category'] = "theme";
$apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "operator_panel_conference_icon_volume_down";
$apps[$x]['default_settings'][$y]['default_setting_name'] = "text";
$apps[$x]['default_settings'][$y]['default_setting_value'] = "fas fa-volume-down";
$apps[$x]['default_settings'][$y]['default_setting_enabled'] = "true";
$apps[$x]['default_settings'][$y]['default_setting_description'] = "Font Awesome icon class for the conference volume down action.";
$y++;
$apps[$x]['default_settings'][$y]['default_setting_uuid'] = "9220ec76-6760-4b75-a29e-503b94ca7508";
$apps[$x]['default_settings'][$y]['default_setting_category'] = "theme";
$apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "operator_panel_conference_icon_volume_up";
$apps[$x]['default_settings'][$y]['default_setting_name'] = "text";
$apps[$x]['default_settings'][$y]['default_setting_value'] = "fas fa-volume-up";
$apps[$x]['default_settings'][$y]['default_setting_enabled'] = "true";
$apps[$x]['default_settings'][$y]['default_setting_description'] = "Font Awesome icon class for the conference volume up action.";
$y++;
$apps[$x]['default_settings'][$y]['default_setting_uuid'] = "866dc078-68cb-43aa-b70b-b73d213dbd09";
$apps[$x]['default_settings'][$y]['default_setting_category'] = "theme";
$apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "operator_panel_conference_icon_gain_down";
$apps[$x]['default_settings'][$y]['default_setting_name'] = "text";
$apps[$x]['default_settings'][$y]['default_setting_value'] = "fas fa-sort-amount-down";
$apps[$x]['default_settings'][$y]['default_setting_enabled'] = "true";
$apps[$x]['default_settings'][$y]['default_setting_description'] = "Font Awesome icon class for the conference gain down action.";
$y++;
$apps[$x]['default_settings'][$y]['default_setting_uuid'] = "d4bde1f1-b0d8-4836-a78b-f8c4bb510a10";
$apps[$x]['default_settings'][$y]['default_setting_category'] = "theme";
$apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "operator_panel_conference_icon_gain_up";
$apps[$x]['default_settings'][$y]['default_setting_name'] = "text";
$apps[$x]['default_settings'][$y]['default_setting_value'] = "fas fa-sort-amount-up";
$apps[$x]['default_settings'][$y]['default_setting_enabled'] = "true";
$apps[$x]['default_settings'][$y]['default_setting_description'] = "Font Awesome icon class for the conference gain up action.";
$y++;
$apps[$x]['default_settings'][$y]['default_setting_uuid'] = "f55d2796-46b5-4ca2-998f-256184489b11";
$apps[$x]['default_settings'][$y]['default_setting_category'] = "theme";
$apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "operator_panel_conference_icon_kick";
$apps[$x]['default_settings'][$y]['default_setting_name'] = "text";
$apps[$x]['default_settings'][$y]['default_setting_value'] = "fas fa-ban";
$apps[$x]['default_settings'][$y]['default_setting_enabled'] = "true";
$apps[$x]['default_settings'][$y]['default_setting_description'] = "Font Awesome icon class for the conference kick action.";
$y++;
$apps[$x]['default_settings'][$y]['default_setting_uuid'] = "b367ce0d-beb0-424a-9fc9-0c83690001ee";
$apps[$x]['default_settings'][$y]['default_setting_category'] = "theme";
$apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "operator_panel_status_show_icon";

View File

@@ -81,6 +81,19 @@
'disconnected' => $settings->get('theme', 'operator_panel_status_icon_disconnected', 'fa-solid fa-plug-circle-xmark'),
'connecting' => $settings->get('theme', 'operator_panel_status_icon_connecting', 'fa-solid fa-plug fa-fade'),
];
$conference_action_icons = [
'mute' => $settings->get('theme', 'operator_panel_conference_icon_mute', 'fas fa-microphone'),
'unmute' => $settings->get('theme', 'operator_panel_conference_icon_unmute', 'fas fa-microphone-slash'),
'deaf' => $settings->get('theme', 'operator_panel_conference_icon_deaf', 'fas fa-headphones'),
'undeaf' => $settings->get('theme', 'operator_panel_conference_icon_undeaf', 'fas fa-deaf'),
'energy_up' => $settings->get('theme', 'operator_panel_conference_icon_energy_up', 'fas fa-plus'),
'energy_down' => $settings->get('theme', 'operator_panel_conference_icon_energy_down', 'fas fa-minus'),
'volume_down' => $settings->get('theme', 'operator_panel_conference_icon_volume_down', 'fas fa-volume-down'),
'volume_up' => $settings->get('theme', 'operator_panel_conference_icon_volume_up', 'fas fa-volume-up'),
'gain_down' => $settings->get('theme', 'operator_panel_conference_icon_gain_down', 'fas fa-sort-amount-down'),
'gain_up' => $settings->get('theme', 'operator_panel_conference_icon_gain_up', 'fas fa-sort-amount-up'),
'kick' => $settings->get('theme', 'operator_panel_conference_icon_kick', 'fas fa-ban'),
];
$status_show_icon = $settings->get('theme', 'operator_panel_status_show_icon', 'true') === 'true';
// Optional user status list for the presence dropdown
@@ -127,6 +140,7 @@
// Theme colors and icons for connection status indicator
const status_colors = <?= json_encode($status_colors, JSON_UNESCAPED_SLASHES) ?>;
const status_icons = <?= json_encode($status_icons, JSON_UNESCAPED_SLASHES) ?>;
const conference_action_icons = <?= json_encode($conference_action_icons, JSON_UNESCAPED_SLASHES) ?>;
const status_tooltips = {
connected: <?= json_encode($text['status-connected'] ?? 'Connected') ?>,
warning: <?= json_encode($text['status-warning'] ?? 'Warning') ?>,

View File

@@ -27,33 +27,46 @@
*/
/**
* Filters conference events to only include keys relevant to the operator panel,
* and drops messages from domains other than the subscriber's.
* Filters conference events using conference-aware domain detection.
*
* Passes only the keys listed in {@see operator_panel_service::conf_event_keys}
* and enforces domain isolation using caller_context.
*
* @author Tim Fry <tim@fusionpbx.com>
* Conference maintenance events are not consistent about which field carries
* the domain. Accept a message when the domain can be proven from either the
* conference identifier or the caller context, and otherwise keep the message
* so operator panel conference updates are not dropped.
*/
class operator_panel_conf_filter implements filter {
/**
* Allowed domain names keyed for fast lookup
* Allowed domain names keyed for fast lookup.
*
* @var array
*/
private $domains;
private $domains = [];
/**
* Keys that are permitted through the filter
* Keys that are permitted through the filter.
*
* @var array
*/
private $allowed_keys;
private $allowed_keys = [];
/**
* @param array $domain_names Domain names this subscriber is allowed to see.
* @param array $allowed_keys Event keys to include in the forwarded payload.
* Whether the current event should be dropped.
*
* @var bool
*/
private $drop_message = false;
/**
* Whether a matching domain has been positively identified.
*
* @var bool
*/
private $matched_domain = false;
/**
* @param array $domain_names Domain names this subscriber is allowed to see.
* @param array $allowed_keys Event keys to include in the forwarded payload.
*/
public function __construct(array $domain_names, array $allowed_keys) {
foreach ($domain_names as $name) {
@@ -68,15 +81,69 @@ class operator_panel_conf_filter implements filter {
* @param string $key
* @param mixed $value
*
* @return bool|null true to keep, false to skip this key, null to drop the entire message.
* @return bool|null true to keep, false to skip this key, null to drop the entire message.
*/
public function __invoke($key, $value): ?bool {
// Domain guard — drop whole message if context is wrong
if ($key === 'caller_context') {
return isset($this->domains[$value]) ? true : null;
if ($this->drop_message) {
return null;
}
// Key allow-list
return isset($this->allowed_keys[$key]) ? true : false;
if ($this->is_domain_key($key)) {
$matched = $this->match_domain((string)$value, $key === 'caller_context');
if ($matched === false) {
$this->drop_message = true;
return null;
}
}
if ($this->matched_domain || !$this->is_domain_key($key)) {
return isset($this->allowed_keys[$key]) ? true : false;
}
return true;
}
/**
* Determine whether the key can identify the conference domain.
*
* @param string $key
*
* @return bool
*/
private function is_domain_key(string $key): bool {
return in_array($key, ['caller_context', 'conference_name', 'channel_presence_id'], true);
}
/**
* Match the event's domain against the allowed set.
*
* @param string $value
* @param bool $is_context
*
* @return bool|null false when the event belongs to another domain, true when it matches,
* or null when the key does not conclusively identify a domain.
*/
private function match_domain(string $value, bool $is_context): ?bool {
$value = trim($value);
if ($value === '') {
return null;
}
$domain = $value;
if (!$is_context && strpos($value, '@') !== false) {
$parts = explode('@', $value);
$domain = end($parts) ?: '';
}
if ($domain === '') {
return null;
}
if (isset($this->domains[$domain])) {
$this->matched_domain = true;
return true;
}
return false;
}
}

View File

@@ -180,6 +180,15 @@ class operator_panel_service extends base_websocket_system_service implements we
'recording_state' => 'operator_panel_record',
'registrations_state' => 'operator_panel_view',
'originate' => 'operator_panel_originate',
// Conference member actions
'mute' => 'operator_panel_manage',
'unmute' => 'operator_panel_manage',
'deaf' => 'operator_panel_manage',
'undeaf' => 'operator_panel_manage',
'energy' => 'operator_panel_manage',
'volume_in' => 'operator_panel_manage',
'volume_out' => 'operator_panel_manage',
'kick' => 'operator_panel_hangup',
// User presence status (own status only, enforced inside handler)
'user_status' => 'operator_panel_view',
// Call-center agent status (supervisor action)
@@ -854,6 +863,9 @@ class operator_panel_service extends base_websocket_system_service implements we
$destination = $payload['destination'] ?? '';
$domain_name = $payload['domain_name'] ?? '';
$context = $payload['context'] ?? ($domain_name !== '' ? $domain_name : 'default');
$conference_name = html_entity_decode(urldecode($payload['conference_name'] ?? ''));
$member_id = $payload['member_id'] ?? '';
$direction = $payload['direction'] ?? '';
try {
switch ($action) {
@@ -993,6 +1005,69 @@ class operator_panel_service extends base_websocket_system_service implements we
}
return ['success' => true, 'message' => 'Registrations state updated', 'states' => $states];
case 'mute':
case 'unmute':
if (empty($conference_name) || strpos($conference_name, '@') === false) {
return ['success' => false, 'message' => 'Invalid conference name'];
}
if (empty($member_id)) {
return ['success' => false, 'message' => 'Member ID required'];
}
event_socket::api("conference '$conference_name' $action $member_id");
if (!empty($uuid)) {
event_socket::api("uuid_setvar $uuid hand_raised false");
}
return ['success' => true, 'message' => 'Conference member updated'];
case 'deaf':
case 'undeaf':
if (empty($conference_name) || strpos($conference_name, '@') === false) {
return ['success' => false, 'message' => 'Invalid conference name'];
}
if (empty($member_id)) {
return ['success' => false, 'message' => 'Member ID required'];
}
event_socket::api("conference '$conference_name' $action $member_id");
return ['success' => true, 'message' => 'Conference member updated'];
case 'kick':
if (empty($uuid)) {
return ['success' => false, 'message' => 'UUID required'];
}
event_socket::api("uuid_kill $uuid");
return ['success' => true, 'message' => 'Conference member removed'];
case 'energy':
if (empty($conference_name) || strpos($conference_name, '@') === false) {
return ['success' => false, 'message' => 'Invalid conference name'];
}
if (empty($member_id) || empty($direction)) {
return ['success' => false, 'message' => 'Member ID and direction required'];
}
$current = trim((string) event_socket::api("conference '$conference_name' energy $member_id"));
if (preg_match('/=(\d+)/', $current, $matches)) {
$value = (int) $matches[1];
$value = ($direction === 'up') ? $value + 100 : $value - 100;
event_socket::api("conference '$conference_name' energy $member_id $value");
}
return ['success' => true, 'message' => 'Energy updated'];
case 'volume_in':
case 'volume_out':
if (empty($conference_name) || strpos($conference_name, '@') === false) {
return ['success' => false, 'message' => 'Invalid conference name'];
}
if (empty($member_id) || empty($direction)) {
return ['success' => false, 'message' => 'Member ID and direction required'];
}
$current = trim((string) event_socket::api("conference '$conference_name' $action $member_id"));
if (preg_match('/=(-?\d+)/', $current, $matches)) {
$value = (int) $matches[1];
$value = ($direction === 'up') ? $value + 1 : $value - 1;
event_socket::api("conference '$conference_name' $action $member_id $value");
}
return ['success' => true, 'message' => 'Volume updated'];
case 'originate':
$source = $payload['source'] ?? '';
$dest = $payload['destination'] ?? '';
@@ -1282,7 +1357,7 @@ class operator_panel_service extends base_websocket_system_service implements we
case 'energy_level':
case 'gain_level':
case 'volume_level':
$this->broadcast_call_event($event_message, $action);
$this->broadcast_conference_event($event_message, $action, $conference_name);
break;
case 'add_member':
@@ -1445,6 +1520,53 @@ class operator_panel_service extends base_websocket_system_service implements we
websocket_client::send($this->ws_client->socket(), $message);
}
/**
* Broadcast a conference event with a normalized conference identity.
*
* @param event_message $event_message
* @param string $action
* @param string $conference_name
*
* @return void
*/
private function broadcast_conference_event(event_message $event_message, string $action, string $conference_name): void {
$event_data = $event_message->to_array();
$event_data = $this->normalize_conference_payload($event_data, $conference_name);
$message = new websocket_message();
$message
->service_name(self::get_service_name())
->topic($action)
->payload($event_data)
;
websocket_client::send($this->ws_client->socket(), $message);
}
/**
* Add a normalized conference identity and context to an event payload.
*
* @param array $event_data
* @param string $conference_name
*
* @return array
*/
private function normalize_conference_payload(array $event_data, string $conference_name): array {
$event_data['conference_name'] = $conference_name;
$event_data['conference_display_name'] = $this->lookup_conference_display_name($conference_name);
if (strpos($conference_name, '@') !== false) {
$parts = explode('@', $conference_name, 2);
$domain_name = $parts[1] ?? '';
if ($domain_name !== '') {
$event_data['domain_name'] = $domain_name;
$event_data['caller_context'] = $domain_name;
}
}
return $event_data;
}
/**
* Send an action response back to the requesting client.
*
@@ -1521,9 +1643,7 @@ class operator_panel_service extends base_websocket_system_service implements we
];
}
$event_data['conference_name'] = $conference_name;
$event_data['conference_display_name'] = $this->lookup_conference_display_name($conference_name);
return $event_data;
return $this->normalize_conference_payload($event_data, $conference_name);
}
/**
@@ -1546,10 +1666,8 @@ class operator_panel_service extends base_websocket_system_service implements we
: 0;
}
$event_data['member_count'] = $member_count ?? 0;
$event_data['conference_name'] = $conference_name;
$event_data['conference_display_name'] = $this->lookup_conference_display_name($conference_name);
return $event_data;
$event_data['member_count'] = $member_count ?? 0;
return $this->normalize_conference_payload($event_data, $conference_name);
}
/**
@@ -1562,10 +1680,8 @@ class operator_panel_service extends base_websocket_system_service implements we
*/
private function enrich_conference_create_event(event_message $event_message, string $conference_name): array {
$event_data = $event_message->to_array();
$event_data['conference_name'] = $conference_name;
$event_data['conference_display_name'] = $this->lookup_conference_display_name($conference_name);
$event_data['member_count'] = 0;
return $event_data;
return $this->normalize_conference_payload($event_data, $conference_name);
}
/**

View File

@@ -496,6 +496,17 @@ function esc(text) {
.replace(/'/g, '&#039;');
}
function jsq(value) {
return JSON.stringify(value === null || value === undefined ? '' : String(value));
}
function get_conference_action_icon(name, fallback) {
if (conference_action_icons && conference_action_icons[name]) {
return conference_action_icons[name];
}
return fallback;
}
/** Format a Unix microsecond timestamp as elapsed time hh:mm:ss */
function format_elapsed(us_timestamp) {
if (!us_timestamp || us_timestamp === '0') return '--:--:--';
@@ -571,7 +582,7 @@ function on_calls_snapshot_event(event) {
* @param {object} event
*/
function on_call_event(event) {
const name = (event.event_name || event.topic || '').toLowerCase();
const name = (event.topic || event.event_name || '').toLowerCase();
const uuid = get_call_uuid(event);
if (!uuid) return;
@@ -881,7 +892,7 @@ function load_conferences_snapshot() {
* @param {object} event
*/
function on_conference_event(event) {
const action = (event.event_name || event.topic || '').toLowerCase();
const action = (event.topic || event.event_name || '').toLowerCase();
const conference_name = event.conference_name || event.channel_presence_id || '';
if (!conference_name) return;
@@ -1023,32 +1034,57 @@ function render_conferences_tab() {
html += ` </tr>\n`;
members.forEach(m => {
const conf_name_js = jsq(name);
const member_id_js = jsq(String(m.id || ''));
const uuid_js = jsq(m.uuid || '');
const mid = esc(String(m.id || ''));
const muuid = esc(m.uuid || '');
const cid = esc(m.caller_id_name || '');
const cid_num = esc(m.caller_id_number || '');
const flags = m.flags || {};
let flags_html = '';
if (flags.talking) {
flags_html += ` <span class="badge bg-info" title="${esc(text['label-talking'] || 'Talking')}" style="margin:0 3px;">${esc(text['label-talking'] || 'Talking')}</span>`;
}
if (flags.can_speak === false) {
flags_html += ` <span class="badge bg-warning text-dark" title="${esc(text['label-muted'] || 'Muted')}" style="margin:0 3px;">${esc(text['label-muted'] || 'Muted')}</span>`;
}
if (flags.can_hear === false) {
flags_html += ` <span class="badge bg-warning text-dark" title="${esc(text['label-deaf'] || 'Deaf')}" style="margin:0 3px;">${esc(text['label-deaf'] || 'Deaf')}</span>`;
}
if (flags.has_floor) {
flags_html += ` <span class="badge bg-warning text-dark" title="${esc(text['label-floor'] || 'Floor')}" style="margin:0 3px;">${esc(text['label-floor'] || 'Floor')}</span>`;
}
if (flags.is_moderator) {
flags_html += ` <span class="badge bg-primary" title="${esc(text['label-moderator'] || 'Moderator')}" style="margin:0 3px;">${esc(text['label-moderator'] || 'Moderator')}</span>`;
}
html += ` <tr class="list-row" id="conf_member_${muuid}">\n`;
html += ` <td>${cid ? `${cid}<br><small>${cid_num}</small>` : cid_num}</td>\n`;
html += ` <td>${mid}</td>\n`;
html += ` <td style="white-space:nowrap;">`;
html += ` <span class="badge bg-${flags.talking ? 'info' : 'secondary'}" title="${esc(text['label-talking'] || 'Talking')}" style="margin:0 3px;">${esc(text['label-talking'] || 'Talking')}</span>`;
html += ` <span class="badge bg-${flags.can_speak === false ? 'warning text-dark' : 'success'}" title="${esc(text['label-muted'] || 'Muted')}" style="margin:0 3px;">${esc(text['label-muted'] || 'Muted')}</span>`;
html += ` <span class="badge bg-${flags.can_hear === false ? 'warning text-dark' : 'success'}" title="${esc(text['label-deaf'] || 'Deaf')}" style="margin:0 3px;">${esc(text['label-deaf'] || 'Deaf')}</span>`;
if (flags.has_floor) html += ` <span class="badge bg-warning text-dark" title="${esc(text['label-floor'] || 'Floor')}" style="margin:0 3px;">${esc(text['label-floor'] || 'Floor')}</span>`;
if (flags.is_moderator) html += ` <span class="badge bg-primary" title="${esc(text['label-moderator'] || 'Moderator')}" style="margin:0 3px;">${esc(text['label-moderator'] || 'Moderator')}</span>`;
html += flags_html;
html += ` </td>\n`;
if (permissions.operator_panel_hangup || permissions.operator_panel_manage) {
html += ` <td class="right">\n`;
if (permissions.operator_panel_hangup) {
html += ` <a class="btn-action" href="javascript:void(0)" title="${esc(text['button-hangup'] || 'Hangup')}" onclick="action_hangup('${muuid}')">`
+ `<img class="op-ext-action-icon" src="../operator_panel/resources/images/kill.png" alt="${esc(text['button-hangup'] || 'Hangup')}"></a> `;
html += ` <button type="button" class="btn btn-default btn-xs" title="${esc(text['button-hangup'] || 'Hangup')}" onclick='action_conference_member("kick", ${conf_name_js}, ${member_id_js}, ${uuid_js})'><span class="${esc(get_conference_action_icon('kick', 'fas fa-ban'))}" aria-hidden="true"></span></button> `;
}
if (permissions.operator_panel_manage) {
html += ` <a class="btn-action" href="javascript:void(0)" title="${esc(text['button-transfer'] || 'Transfer')}" onclick="open_transfer_modal('${muuid}')">`
+ `<img class="op-ext-action-icon" src="../operator_panel/resources/images/keypad_transfer.png" alt="${esc(text['button-transfer'] || 'Transfer')}"></a> `;
const mute_action = flags.can_speak === false ? 'unmute' : 'mute';
const mute_label = flags.can_speak === false ? (text['button-unmute'] || 'Unmute') : (text['button-mute'] || 'Mute');
const deaf_action = flags.can_hear === false ? 'undeaf' : 'deaf';
const deaf_label = flags.can_hear === false ? (text['button-undeaf'] || 'Undeaf') : (text['button-deaf'] || 'Deaf');
html += ` <button type="button" class="btn btn-default btn-xs" title="${esc(mute_label)}" onclick='action_conference_member(${jsq(mute_action)}, ${conf_name_js}, ${member_id_js}, ${uuid_js})'><span class="${esc(get_conference_action_icon(mute_action, mute_action === 'mute' ? 'fas fa-microphone-slash' : 'fas fa-microphone'))}" aria-hidden="true"></span></button> `;
html += ` <button type="button" class="btn btn-default btn-xs" title="${esc(deaf_label)}" onclick='action_conference_member(${jsq(deaf_action)}, ${conf_name_js}, ${member_id_js}, ${uuid_js})'><span class="${esc(get_conference_action_icon(deaf_action, deaf_action === 'deaf' ? 'fas fa-deaf' : 'fas fa-headphones'))}" aria-hidden="true"></span></button> `;
html += ` <button type="button" class="btn btn-default btn-xs" title="${esc(text['label-energy'] || 'Energy')}` + ` +" onclick='action_conference_member("energy", ${conf_name_js}, ${member_id_js}, ${uuid_js}, "up")'><span class="${esc(get_conference_action_icon('energy_up', 'fas fa-plus'))}" aria-hidden="true"></span></button> `;
html += ` <button type="button" class="btn btn-default btn-xs" title="${esc(text['label-energy'] || 'Energy')}` + ` -" onclick='action_conference_member("energy", ${conf_name_js}, ${member_id_js}, ${uuid_js}, "down")'><span class="${esc(get_conference_action_icon('energy_down', 'fas fa-minus'))}" aria-hidden="true"></span></button> `;
html += ` <button type="button" class="btn btn-default btn-xs" title="${esc(text['label-volume'] || 'Volume')}` + ` -" onclick='action_conference_member("volume_in", ${conf_name_js}, ${member_id_js}, ${uuid_js}, "down")'><span class="${esc(get_conference_action_icon('volume_down', 'fas fa-volume-down'))}" aria-hidden="true"></span></button> `;
html += ` <button type="button" class="btn btn-default btn-xs" title="${esc(text['label-volume'] || 'Volume')}` + ` +" onclick='action_conference_member("volume_in", ${conf_name_js}, ${member_id_js}, ${uuid_js}, "up")'><span class="${esc(get_conference_action_icon('volume_up', 'fas fa-volume-up'))}" aria-hidden="true"></span></button> `;
html += ` <button type="button" class="btn btn-default btn-xs" title="${esc(text['label-gain'] || 'Gain')}` + ` -" onclick='action_conference_member("volume_out", ${conf_name_js}, ${member_id_js}, ${uuid_js}, "down")'><span class="${esc(get_conference_action_icon('gain_down', 'fas fa-sort-amount-down'))}" aria-hidden="true"></span></button> `;
html += ` <button type="button" class="btn btn-default btn-xs" title="${esc(text['label-gain'] || 'Gain')}` + ` +" onclick='action_conference_member("volume_out", ${conf_name_js}, ${member_id_js}, ${uuid_js}, "up")'><span class="${esc(get_conference_action_icon('gain_up', 'fas fa-sort-amount-up'))}" aria-hidden="true"></span></button> `;
}
html += ` </td>\n`;
}
@@ -1351,6 +1387,22 @@ function action_agent_status(agent_name, status) {
send_action('agent_status', { agent_name, status }).catch(console.error);
}
function action_conference_member(action, conference_name, member_id, uuid, direction) {
const payload = { conference_name, member_id, uuid };
if (direction) {
payload.direction = direction;
}
send_action(action, payload)
.then(() => {
if (action === 'kick') {
show_toast(text['button-hangup'] || 'Member removed', 'success');
return;
}
show_toast(text['label-actions'] || 'Action executed', 'success');
})
.catch(console.error);
}
/**
* Show a brief Bootstrap toast notification.
* @param {string} message