Signal notification fixes and UI updates

This commit is contained in:
Darren Clarke 2026-02-09 22:18:35 +01:00
parent d93797172a
commit 59872f579a
11 changed files with 440 additions and 201 deletions

View file

@ -0,0 +1,220 @@
class ProfileNotification extends App.ControllerSubContent
@include App.TicketNotificationMatrix
@requiredPermission: 'user_preferences.notifications+ticket.agent'
header: __('Notifications')
events:
'submit form': 'update'
'click .js-reset' : 'reset'
'change .js-notificationSound': 'previewSound'
'change #profile-groups-limit': 'didSwitchGroupsLimit'
'change input[name=group_ids]': 'didChangeGroupIds'
'change input[name$=".channel.signal"]': 'didChangeSignalCheckbox'
elements:
'#profile-groups-limit': 'profileGroupsLimitInput'
'.profile-groups-limit-settings-inner': 'groupsLimitSettings'
'.profile-groups-all-unchecked': 'groupsAllUncheckedWarning'
sounds: [
{
name: 'Bell'
file: 'Bell.mp3'
},
{
name: 'Kalimba'
file: 'Kalimba.mp3'
},
{
name: 'Marimba'
file: 'Marimba.mp3'
},
{
name: 'Peep'
file: 'Peep.mp3'
},
{
name: 'Plop'
file: 'Plop.mp3'
},
{
name: 'Ring'
file: 'Ring.mp3'
},
{
name: 'Space'
file: 'Space.mp3'
},
{
name: 'Wood'
file: 'Wood.mp3'
},
{
name: 'Xylo'
file: 'Xylo.mp3'
}
]
constructor: ->
super
App.User.full(App.Session.get().id, @render, true, true)
render: =>
matrix =
create:
name: __('New Ticket')
update:
name: __('Ticket update')
reminder_reached:
name: __('Ticket reminder reached')
escalation:
name: __('Ticket escalation')
config =
group_ids: []
matrix: {}
user_config = @Session.get('preferences').notification_config
if user_config
config = $.extend(true, {}, config, user_config)
# groups
user_group_config = true
if !user_config || !user_config['group_ids'] || _.isEmpty(user_config['group_ids']) || user_config['group_ids'][0] is '-'
user_group_config = false
groups = []
group_ids = App.User.find(@Session.get('id')).allGroupIds()
if group_ids
for group_id in group_ids
group = App.Group.find(group_id)
groups.push group
if !user_group_config
if !config['group_ids']
config['group_ids'] = []
config['group_ids'].push group_id.toString()
groups = _.sortBy(groups, (item) -> return item.name)
for sound in @sounds
sound.selected = sound.file is App.OnlineNotification.soundFile() ? true : false
signal_notification_enabled = App.Config.get('signal_notification_enabled')
signal_uid = config.signal_uid || ''
# Check if any signal checkbox is currently checked in the matrix
signal_has_checked = false
if signal_notification_enabled
for key, val of config.matrix
if val?.channel?.signal
signal_has_checked = true
break
@html App.view('profile/notification')
matrixTableHTML: @renderNotificationMatrix(config.matrix)
groups: groups
config: config
sounds: @sounds
notificationSoundEnabled: App.OnlineNotification.soundEnabled()
user_group_config: user_group_config
signal_notification_enabled: signal_notification_enabled
signal_uid: signal_uid
signal_has_checked: signal_has_checked
update: (e) =>
#notification_config
e.preventDefault()
params = {}
params.notification_config = {}
formParams = @formParam(e.target)
params.notification_config.matrix = @updatedNotificationMatrixValues(formParams)
if formParams.signal_uid?
params.notification_config.signal_uid = formParams.signal_uid
if @profileGroupsLimitInput.is(':checked')
params.notification_config.group_ids = formParams['group_ids']
if typeof params.notification_config.group_ids isnt 'object'
params.notification_config.group_ids = [params.notification_config.group_ids]
if _.isEmpty(params.notification_config.group_ids)
delete params.notification_config.group_ids
@formDisable(e)
params.notification_sound = formParams.notification_sound
if !params.notification_sound.enabled
params.notification_sound.enabled = false
else
params.notification_sound.enabled = true
# get data
@ajax(
id: 'preferences'
type: 'PUT'
url: @apiPath + '/users/preferences'
data: JSON.stringify(params)
processData: true
success: @success
error: @error
)
reset: (e) =>
new App.ControllerConfirm(
message: __('Are you sure? Your notifications settings will be reset to default.')
buttonClass: 'btn--danger'
callback: =>
@ajax(
id: 'preferences_notifications_reset'
type: 'POST'
url: "#{@apiPath}/users/preferences_notifications_reset"
processData: true
success: @success
)
container: @el.closest('.content')
)
success: (data, status, xhr) =>
App.User.full(
App.Session.get('id'),
=>
App.Event.trigger('ui:rerender')
@notify(
type: 'success'
msg: __('Update successful.')
)
,
true
)
error: (xhr, status, error) =>
@render()
data = JSON.parse(xhr.responseText)
@notify(
type: 'error'
msg: data.message
)
previewSound: (e) =>
params = @formParam(e.target)
return if !params.notification_sound
return if !params.notification_sound.file
App.OnlineNotification.play(params.notification_sound.file)
didSwitchGroupsLimit: (e) =>
@groupsLimitSettings.collapse('toggle')
didChangeGroupIds: (e) =>
@groupsAllUncheckedWarning.toggleClass 'hide', @el.find('input[name=group_ids]:checked').length != 0
didChangeSignalCheckbox: (e) =>
hasChecked = @el.find('input[name$=".channel.signal"]:checked').length > 0
@el.find('.js-signal-phone-container').toggle(hasChecked)
App.Config.set('Notifications', { prio: 2600, name: __('Notifications'), parent: '#profile', target: '#profile/notifications', permission: ['user_preferences.notifications+ticket.agent'], controller: ProfileNotification }, 'NavBarProfile')

View file

@ -1,89 +0,0 @@
class ProfileSignalNotifications extends App.ControllerSubContent
@requiredPermission: 'user_preferences.signal_notifications+ticket.agent'
header: __('Signal Notifications')
events:
'submit form': 'update'
constructor: ->
super
App.User.full(App.Session.get().id, @render, true, true)
render: =>
config =
enabled: false
events:
create: true
update: true
escalation: true
reminder_reached: true
user = App.User.find(App.Session.get().id)
user_config = user.preferences?.signal_notifications
if user_config
config = $.extend(true, {}, config, user_config)
@html App.view('profile/signal_notifications')
config: config
signal_uid: user.signal_uid || ''
signal_notification_enabled: App.Config.get('signal_notification_enabled')
update: (e) =>
e.preventDefault()
params = @formParam(e.target)
preferences = {}
preferences.signal_notifications =
enabled: params.enabled == 'true'
events:
create: params.event_create == 'true'
update: params.event_update == 'true'
escalation: params.event_escalation == 'true'
reminder_reached: params.event_reminder_reached == 'true'
@formDisable(e)
@ajax(
id: 'preferences_signal_notifications'
type: 'PUT'
url: @apiPath + '/users/preferences'
data: JSON.stringify(preferences)
processData: true
success: @successPreferences
error: @error
)
if params.signal_uid?
user = App.User.find(App.Session.get().id)
user.signal_uid = params.signal_uid
user.save(
done: =>
# User saved successfully
fail: (settings, details) =>
@notify(
type: 'error'
msg: details.error || __('Failed to save Signal phone number')
)
)
successPreferences: (data, status, xhr) =>
App.User.full(
App.Session.get('id'),
=>
App.Event.trigger('ui:rerender')
@notify(
type: 'success'
msg: __('Update successful.')
)
,
true
)
error: (xhr, status, error) =>
@render()
data = JSON.parse(xhr.responseText)
@notify(
type: 'error'
msg: data.message
)
App.Config.set('SignalNotifications', { prio: 2650, name: __('Signal Notifications'), parent: '#profile', target: '#profile/signal_notifications', permission: ['user_preferences.signal_notifications+ticket.agent'], controller: ProfileSignalNotifications }, 'NavBarProfile')

View file

@ -0,0 +1,15 @@
# coffeelint: disable=camel_case_classes
class App.UiElement.notification_matrix
@render: (values, options = {}) ->
matrixYAxe =
create:
name: __('New Ticket')
update:
name: __('Ticket update')
reminder_reached:
name: __('Ticket reminder reached')
escalation:
name: __('Ticket escalation')
$( App.view('generic/notification_matrix')( matrixYAxe: matrixYAxe, values: values, signal_notification_enabled: options.signal_notification_enabled ) )

View file

@ -0,0 +1,29 @@
# Common handling for the notification matrix
App.TicketNotificationMatrix =
renderNotificationMatrix: (values) ->
App.UiElement.notification_matrix.render(values, signal_notification_enabled: App.Config.get('signal_notification_enabled'))[0].outerHTML
updatedNotificationMatrixValues: (formParams) ->
matrix = {}
for key, value of formParams
area = key.split('.')
continue if area[0] isnt 'matrix'
if !matrix[area[1]]
matrix[area[1]] = {}
switch area[2]
when 'criteria'
if !matrix[area[1]][area[2]]
matrix[area[1]][area[2]] = {}
matrix[area[1]][area[2]][area[3]] = value is 'true'
when 'channel'
if !matrix[area[1]][area[2]]
matrix[area[1]][area[2]] = { online: true }
matrix[area[1]][area[2]][area[3]] = value is 'true'
matrix

View file

@ -0,0 +1,70 @@
<% if @signal_notification_enabled: %>
<% colWidth = "13%" %>
<% channelWidth = "100px" %>
<% else: %>
<% colWidth = "16%" %>
<% channelWidth = "120px" %>
<% end %>
<table class="settings-list">
<thead>
<tr>
<th>
<th width="<%= colWidth %>" style="text-align: center;"><%- @T('My Tickets') %>
<th width="<%= colWidth %>" style="text-align: center;"><%- @T('Not Assigned') %>*
<th width="<%= colWidth %>" style="text-align: center;"><%- @T('Subscribed Tickets') %>
<th width="<%= colWidth %>" style="text-align: center;"><%- @T('All Tickets') %>*
<th width="<%= channelWidth %>" class="settings-list-separator" style="text-align: center;"><%- @T('Also notify via email') %>
<% if @signal_notification_enabled: %>
<th width="<%= channelWidth %>" class="settings-list-separator" style="text-align: center;"><%- @T('Also notify via Signal') %>
<% end %>
</thead>
<tbody>
<% if @matrixYAxe: %>
<% for key, value of @matrixYAxe: %>
<tr>
<td>
<%- @T(value.name) %>
<% criteria = @values[key]?.criteria %>
<% channel = @values[key]?.channel %>
<td class="u-positionOrigin">
<label class="checkbox-replacement checkbox-replacement--fullscreen">
<input type="checkbox" name="matrix.<%= key %>.criteria.owned_by_me" value="true"<% if criteria && criteria.owned_by_me: %> checked<% end %> />
<%- @Icon('checkbox', 'icon-unchecked') %>
<%- @Icon('checkbox-checked', 'icon-checked') %>
</label>
<td class="u-positionOrigin">
<label class="checkbox-replacement checkbox-replacement--fullscreen">
<input type="checkbox" name="matrix.<%= key %>.criteria.owned_by_nobody" value="true"<% if criteria && criteria.owned_by_nobody: %> checked<% end %> />
<%- @Icon('checkbox', 'icon-unchecked') %>
<%- @Icon('checkbox-checked', 'icon-checked') %>
</label>
<td class="u-positionOrigin">
<label class="checkbox-replacement checkbox-replacement--fullscreen">
<input type="checkbox" name="matrix.<%= key %>.criteria.subscribed" value="true"<% if criteria && criteria.subscribed: %> checked<% end %> />
<%- @Icon('checkbox', 'icon-unchecked') %>
<%- @Icon('checkbox-checked', 'icon-checked') %>
</label>
<td class="u-positionOrigin">
<label class="checkbox-replacement checkbox-replacement--fullscreen">
<input type="checkbox" name="matrix.<%= key %>.criteria.no" value="true"<% if criteria && criteria.no: %> checked<% end %> />
<%- @Icon('checkbox', 'icon-unchecked') %>
<%- @Icon('checkbox-checked', 'icon-checked') %>
</label>
<td class="u-positionOrigin settings-list-separator">
<label class="checkbox-replacement checkbox-replacement--fullscreen">
<input type="checkbox" name="matrix.<%= key %>.channel.email" value="true"<% if channel && channel.email: %> checked<% end %> />
<%- @Icon('checkbox', 'icon-unchecked') %>
<%- @Icon('checkbox-checked', 'icon-checked') %>
</label>
<% if @signal_notification_enabled: %>
<td class="u-positionOrigin settings-list-separator">
<label class="checkbox-replacement checkbox-replacement--fullscreen">
<input type="checkbox" name="matrix.<%= key %>.channel.signal" value="true"<% if channel && channel.signal: %> checked<% end %> />
<%- @Icon('checkbox', 'icon-unchecked') %>
<%- @Icon('checkbox-checked', 'icon-checked') %>
</label>
<% end %>
<% end %>
<% end %>
</tbody>
</table>

View file

@ -0,0 +1,86 @@
<div class="page-header">
<div class="page-header-title"><h1><%- @T('Notifications') %></h1></div>
</div>
<form class="page-content form--flexibleWidth profile-settings-notifications-content">
<div class="settings-entry">
<%- @matrixTableHTML %>
</div>
<% if @signal_notification_enabled: %>
<div class="js-signal-phone-container" style="<% if !@signal_has_checked: %>display: none;<% end %>">
<h2><%- @T('Signal Phone Number') %></h2>
<div class="form-group">
<input type="text" name="signal_uid" class="form-control" value="<%= @signal_uid %>" placeholder="+1234567890">
<p class="help-block"><%- @T('Use international format with country code (e.g., +1234567890)') %></p>
</div>
</div>
<% end %>
<% if @groups: %>
<div class="zammad-switch zammad-switch--small" data-name="profile-groups-limit">
<input type="checkbox" id="profile-groups-limit" <% if @user_group_config: %> checked <% end %>>
<label for="profile-groups-limit"></label>
</div>
<h2>
<%- @T('Limit Groups') %>
</h2>
<div class="settings-entry profile-groups-limit-settings">
<div class="profile-groups-limit-settings-inner collapse <% if @user_group_config: %>in<% end %>">
<div class="alert alert--warning profile-groups-all-unchecked hide" role="alert">
<%- @T('Disabling the notifications from all groups will turn off the limit. Instead, to disable the notifications use the settings above.') %>
</div>
<table class="settings-list">
<thead>
<tr>
<th><%- @T('Group') %>
<th><%- @T('Not Assigned') %> & <%- @T('All Tickets') %>
</thead>
<tbody>
<% for group in @groups: %>
<tr>
<td><%- @P(group, 'name') %>
<td class="u-positionOrigin">
<label class="checkbox-replacement checkbox-replacement--fullscreen">
<input type="checkbox" name="group_ids" value="<%= group.id %>" <% if _.include(_.map(@config.group_ids, (group_id) -> group_id.toString()), group.id.toString()): %>checked<% end %>/>
<%- @Icon('checkbox', 'icon-unchecked') %>
<%- @Icon('checkbox-checked', 'icon-checked') %>
</label>
<% end %>
</tbody>
</table>
</div>
</div>
<% end %>
<h2><%- @T('Sounds') %></h2>
<div class="form-group">
<div class="formGroup-label">
<label for="notification-sound"><%- @T('Notification Sound') %></label>
</div>
<div class="controls controls--select">
<select class="form-control js-notificationSound" id="notification-sound" name="notification_sound::file">
<% for sound in @sounds: %>
<option value="<%= sound.file %>"<%= ' selected' if sound.selected %>><%= sound.name %></option>
<% end %>
</select>
<%- @Icon('arrow-down') %>
</div>
</div>
<div class="form-group">
<label class="inline-label">
<span class="checkbox-replacement checkbox-replacement--inline">
<input type="checkbox" name="notification_sound::enabled" value="true" <% if @notificationSoundEnabled: %> checked<% end %> class="js-SoundEnableDisable">
<%- @Icon('checkbox', 'icon-unchecked') %>
<%- @Icon('checkbox-checked', 'icon-checked') %>
</span>
<%- @T('Play user interface sound effects') %>
</label>
</div>
<button type="submit" class="btn btn--primary"><%- @T( 'Submit' ) %></button>
<input type="button" class="btn btn--danger js-reset" value="<%- @T( 'Reset to default settings' ) %>">
</form>

View file

@ -1,86 +0,0 @@
<div class="page-header">
<div class="page-header-title"><h1><%- @T('Signal Notifications') %></h1></div>
</div>
<% if !@signal_notification_enabled: %>
<div class="alert alert--warning" role="alert">
<%- @T('Signal notifications are currently disabled by the administrator.') %>
</div>
<% end %>
<form class="page-content form--flexibleWidth">
<h2><%- @T('Signal Phone Number') %></h2>
<p class="help-text">
<%- @T('Enter your Signal phone number to receive ticket notifications via Signal.') %>
</p>
<div class="form-group">
<label for="signal-uid"><%- @T('Signal Phone Number') %></label>
<input type="text" id="signal-uid" name="signal_uid" class="form-control" value="<%= @signal_uid %>" placeholder="+1234567890">
<p class="help-block"><%- @T('Use international format with country code (e.g., +1234567890)') %></p>
</div>
<h2><%- @T('Notification Settings') %></h2>
<div class="form-group">
<label class="inline-label">
<span class="checkbox-replacement checkbox-replacement--inline">
<input type="checkbox" name="enabled" value="true" <% if @config.enabled: %> checked<% end %>>
<%- @Icon('checkbox', 'icon-unchecked') %>
<%- @Icon('checkbox-checked', 'icon-checked') %>
</span>
<%- @T('Enable Signal notifications') %>
</label>
</div>
<h3><%- @T('Notification Events') %></h3>
<p class="help-text">
<%- @T('Select which events should trigger Signal notifications.') %>
</p>
<div class="form-group">
<label class="inline-label">
<span class="checkbox-replacement checkbox-replacement--inline">
<input type="checkbox" name="event_create" value="true" <% if @config.events?.create: %> checked<% end %>>
<%- @Icon('checkbox', 'icon-unchecked') %>
<%- @Icon('checkbox-checked', 'icon-checked') %>
</span>
<%- @T('New Ticket') %>
</label>
</div>
<div class="form-group">
<label class="inline-label">
<span class="checkbox-replacement checkbox-replacement--inline">
<input type="checkbox" name="event_update" value="true" <% if @config.events?.update: %> checked<% end %>>
<%- @Icon('checkbox', 'icon-unchecked') %>
<%- @Icon('checkbox-checked', 'icon-checked') %>
</span>
<%- @T('Ticket update') %>
</label>
</div>
<div class="form-group">
<label class="inline-label">
<span class="checkbox-replacement checkbox-replacement--inline">
<input type="checkbox" name="event_escalation" value="true" <% if @config.events?.escalation: %> checked<% end %>>
<%- @Icon('checkbox', 'icon-unchecked') %>
<%- @Icon('checkbox-checked', 'icon-checked') %>
</span>
<%- @T('Ticket escalation') %>
</label>
</div>
<div class="form-group">
<label class="inline-label">
<span class="checkbox-replacement checkbox-replacement--inline">
<input type="checkbox" name="event_reminder_reached" value="true" <% if @config.events?.reminder_reached: %> checked<% end %>>
<%- @Icon('checkbox', 'icon-unchecked') %>
<%- @Icon('checkbox-checked', 'icon-checked') %>
</span>
<%- @T('Ticket reminder reached') %>
</label>
</div>
<button type="submit" class="btn btn--primary"><%- @T('Submit') %></button>
</form>

View file

@ -11,7 +11,9 @@ class SignalNotificationJob < ApplicationJob
user = User.find_by(id: user_id)
return if !user
return if user.signal_uid.blank?
signal_uid = user.preferences.dig('notification_config', 'signal_uid').presence
return if signal_uid.blank?
article = article_id ? Ticket::Article.find_by(id: article_id) : nil
@ -30,13 +32,13 @@ class SignalNotificationJob < ApplicationJob
SignalNotificationSender.send_message(
channel: channel,
recipient: user.signal_uid,
recipient: signal_uid,
message: message
)
add_history(ticket, user, type)
add_history(ticket, user, signal_uid, type)
Rails.logger.info "Sent Signal notification to #{user.signal_uid} for ticket ##{ticket.number} (#{type})"
Rails.logger.info "Sent Signal notification to #{signal_uid} for ticket ##{ticket.number} (#{type})"
end
private
@ -48,8 +50,8 @@ class SignalNotificationJob < ApplicationJob
Channel.find_by(id: channel_id, area: 'Signal::Number', active: true)
end
def add_history(ticket, user, type)
identifier = user.signal_uid.presence || user.login
def add_history(ticket, user, signal_uid, type)
identifier = signal_uid.presence || user.login
recipient_list = "#{identifier}(#{type}:signal)"
History.add(

View file

@ -91,9 +91,8 @@ class Transaction::SignalNotification
possible_recipients_with_ooo.each do |user|
next if recipient_is_current_user?(user)
next if !user.active?
next if !user_has_signal_notifications_enabled?(user)
next if user.signal_uid.blank?
next if !should_notify_for_event?(user)
next if user_signal_uid(user).blank?
next if !user_wants_signal_for_event?(user)
recipients.push(user)
end
@ -123,11 +122,11 @@ class Transaction::SignalNotification
false
end
def user_has_signal_notifications_enabled?(user)
user.preferences.dig('signal_notifications', 'enabled') == true
def user_signal_uid(user)
user.preferences.dig('notification_config', 'signal_uid').presence
end
def should_notify_for_event?(user)
def user_wants_signal_for_event?(user)
event_type = @item[:type]
return false if event_type.blank?
@ -139,6 +138,6 @@ class Transaction::SignalNotification
else return false
end
user.preferences.dig('signal_notifications', 'events', event_key) == true
user.preferences.dig('notification_config', 'matrix', event_key, 'channel', 'signal') == true
end
end

View file

@ -57,20 +57,13 @@ class AddSignalNotificationSettings < ActiveRecord::Migration[5.2]
frontend: false
)
# Permission for Signal notifications profile page
Permission.create_if_not_exists(
name: 'user_preferences.signal_notifications',
description: 'Manage Signal notification preferences',
preferences: {
translations: ['Profile - Signal Notifications']
}
)
end
def self.down
# Only destroy the transaction backend registration.
# Preserve signal_notification_enabled and signal_notification_channel_id
# so admin configuration survives addon reinstalls (setup.rb runs
# uninstall + install on every container start).
Setting.find_by(name: '0105_signal_notification')&.destroy
Setting.find_by(name: 'signal_notification_enabled')&.destroy
Setting.find_by(name: 'signal_notification_channel_id')&.destroy
Permission.find_by(name: 'user_preferences.signal_notifications')&.destroy
end
end

View file

@ -23,8 +23,8 @@ class SignalNotificationSender
return if recipient.blank?
return if message.blank?
api_url = channel.options[:api_url]
api_token = channel.options[:api_token]
api_url = channel.options['bot_endpoint'] || channel.options[:bot_endpoint]
api_token = channel.options['bot_token'] || channel.options[:bot_token]
return if api_url.blank? || api_token.blank?