Move autocomplete list rendering to client side (#9832)

* basic support for autocomplete list rendering on client side
* remove 'contact_search_name' config var, add 'rcube_addressbook::compose_autocomplete_fields()'
* add contactlist_name_template config replacement for contact_search_name
This commit is contained in:
Philip Weir 2025-10-11 16:13:13 +01:00 committed by GitHub
parent dec1d668ed
commit 39821c8a56
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 134 additions and 56 deletions

View File

@ -1290,10 +1290,11 @@ $config['addressbook_search_mode'] = 0;
// Warning: These are field names not LDAP attributes (see 'fieldmap' setting)!
$config['contactlist_fields'] = ['name', 'firstname', 'surname', 'email'];
// Template of contact entry on the autocompletion list.
// You can use contact fields as: name, email, organization, department, etc.
// See program/actions/contacts/index.php for a list
$config['contact_search_name'] = '{name} <{email}>';
// Template of contact entry on contacts and autocompletion list.
// You can use any field listed in contactlist_fields.
// Example: '{name} ({organization})'
// Default: '{name}'.
$config['contactlist_name_template'] = '{name}';
// Contact mode. If your contacts are mostly business, switch it to 'business'.
// This will prioritize form fields related to 'work' (instead of 'home').

View File

@ -101,10 +101,10 @@ class acl extends rcube_plugin
}
if ($user) {
$display = rcube_addressbook::compose_search_name($record);
$user = ['name' => $user, 'display' => $display];
$fields = rcube_addressbook::compose_search_fields($record);
$user = ['name' => $user, 'fields' => $fields];
$users[] = $user;
$keys[] = $display ?: $user['name'];
$keys[] = $fields['name'] ?? $user['name'];
}
}
@ -118,7 +118,7 @@ class acl extends rcube_plugin
$group_id = is_array($record[$group_field]) ? $record[$group_field][0] : $record[$group_field];
if ($group) {
$users[] = ['name' => ($prefix ?: '') . $group_id, 'display' => $group, 'type' => 'group'];
$users[] = ['name' => ($prefix ?: '') . $group_id, 'fields' => ['name' => $group], 'type' => 'group'];
$keys[] = $group;
}
}

View File

@ -83,18 +83,14 @@ class rcmail_action_mail_autocomplete extends rcmail_action
'source' => $abook_id,
];
$display = rcube_addressbook::compose_search_name($record, $email, $name);
if ($display && $display != $contact['name']) {
$contact['display'] = $display;
}
$contact['fields'] = rcube_addressbook::compose_search_fields($record, $email, $name);
// groups with defined email address will not be expanded to its members' addresses
if ($contact['type'] == 'group') {
$contact['email'] = $email;
}
$name = !empty($contact['display']) ? $contact['display'] : $name;
$name = !empty($contact['fields']['name']) ? $contact['fields']['name'] : $name;
$contacts[$index] = $contact;
$sort_keys[$index] = sprintf('%s %03d', $name, $idx++);
@ -130,6 +126,7 @@ class rcmail_action_mail_autocomplete extends rcmail_action
$contacts[$index] = [
'name' => $index,
'email' => $email,
'fields' => ['name' => $index, 'email' => $email],
'type' => 'group',
'id' => $group['ID'],
'source' => $abook_id,
@ -147,6 +144,7 @@ class rcmail_action_mail_autocomplete extends rcmail_action
$sort_keys[$group['name']] = $group['name'];
$contacts[$group['name']] = [
'name' => $group['name'] . ' (' . intval($result->count) . ')',
'fields' => ['name' => $group['name'] . ' (' . intval($result->count) . ')'],
'type' => 'group',
'id' => $group['ID'],
'source' => $abook_id,

View File

@ -6345,10 +6345,11 @@ function rcube_webmail() {
if (results && (len = results.length)) {
for (i = 0; i < len && maxlen > 0; i++) {
text = typeof results[i] === 'object' ? (results[i].display || results[i].name) : results[i];
fields = typeof results[i] === 'object' && results[i].fields ? results[i].fields : { name: text };
type = typeof results[i] === 'object' ? results[i].type : '';
id = i + this.env.contacts.length;
$('<li>').attr({ id: 'rcmkSearchItem' + id, role: 'option' })
.html('<i class="icon"></i>' + this.quote_html(text.replace(new RegExp('(' + RegExp.escape(value) + ')', 'ig'), '##$1%%')).replace(/##([^%]+)%%/g, '<b>$1</b>'))
.html(this.ksearch_results_display(fields, value))
.addClass(type || '')
.appendTo(ul)
.mouseover(function () {
@ -6384,6 +6385,24 @@ function rcube_webmail() {
}
};
this.ksearch_results_display = function (fields, search_term) {
line = "<i class='icon'></i>{name} &lt;{email}&gt;";
$.each(fields, function (key, data) {
line = line.replace('{' + key + '}', data ? ref.ksearch_results_highlight(data, search_term) : '');
});
line = line.replace(/\{[a-z]+\}/ug, '');
line = line.replace(/\s*&lt;&gt;/ug, '');
line = line.replace(/\s+/ug, ' ');
line = line.trim();
return line;
};
this.ksearch_results_highlight = function (haystack, needle) {
return this.quote_html(haystack.replace(new RegExp('(' + RegExp.escape(needle) + ')', 'ig'), '##$1%%')).replace(/##([^%]+)%%/g, '<b>$1</b>');
};
// Getter for input value
// returns a string from the last comma to current cursor position
this.ksearch_input_get = function () {

View File

@ -679,7 +679,7 @@ abstract class rcube_addressbook
*/
public static function compose_list_name($contact)
{
static $compose_mode;
static $compose_mode, $template;
if (!isset($compose_mode)) {
$compose_mode = (int) rcube::get_instance()->config->get('addressbook_name_listing', 0);
@ -745,6 +745,16 @@ abstract class rcube_addressbook
}
}
if ($fn !== '') {
if (!isset($template)) { // cache this
$template = rcube::get_instance()->config->get('contactlist_name_template', '{name}');
}
if ($template !== '{name}') {
$fn = self::compose_search_name($contact, null, $fn, $template);
}
}
return $fn;
}
@ -754,64 +764,76 @@ abstract class rcube_addressbook
* @param array $contact Hash array with contact data as key-value pairs
* @param string $email Optional email address
* @param string $name Optional name (self::compose_list_name() result)
* @param string $templ Optional template to use (defaults to the 'contact_search_name' config option)
* @param string $templ Optional template to use (defaults to '{name} <{email}>')
*
* @return string Display name
*/
public static function compose_search_name($contact, $email = null, $name = null, $templ = null)
public static function compose_search_name($contact, $email = null, $name = null, $templ = '{name} <{email}>')
{
static $template;
if (empty($templ) && !isset($template)) { // cache this
$template = rcube::get_instance()->config->get('contact_search_name');
if (empty($template)) {
$template = '{name} <{email}>';
if (preg_match_all('/\{([a-z]+)\}/', $templ, $matches)) {
$values = self::compose_search_fields($contact, $email, $name, $matches[1]);
foreach ($values as $key => $value) {
$templ = str_replace('{' . $key . '}', $value, $templ);
}
}
$result = $templ ?: $template;
$templ = preg_replace('/\s+/u', ' ', $templ);
$templ = preg_replace('/\s*(<>|\(\)|\[\])/u', '', $templ);
$templ = trim($templ, '/ ');
if (preg_match_all('/\{[a-z]+\}/', $result, $matches)) {
foreach ($matches[0] as $key) {
$key = trim($key, '{}');
$value = '';
return $templ;
}
switch ($key) {
case 'name':
$value = $name ?: self::compose_list_name($contact);
/**
* Build contact display name for search result listing
*
* @param array $contact Hash array with contact data as key-value pairs
* @param string $email Optional email address
* @param string $name Optional name (self::compose_list_name() result)
* @param array $fields Optional fields to return (defaults to ['name', 'email'])
*
* @return array Fields
*/
public static function compose_search_fields($contact, $email = null, $name = null, $fields = ['name', 'email'])
{
$result = [];
// If name(s) are undefined compose_list_name() may return an email address
// here we prevent from returning the same name and email
if ($name === $email && str_contains($result, '{email}')) {
$value = '';
}
foreach ($fields as $key) {
$value = '';
break;
case 'email':
$value = $email;
break;
}
switch ($key) {
case 'name':
$value = $name ?: self::compose_list_name($contact);
if (empty($value)) {
$value = strpos($key, ':') ? $contact[$key] : self::get_col_values($key, $contact, true);
if (is_array($value) && isset($value[0])) {
$value = $value[0];
// If name(s) are undefined compose_list_name() may return an email address
// here we prevent from returning the same name and email
if ($name === $email && in_array('email', $fields) !== false) {
$value = '';
}
}
if (!is_string($value)) {
$value = '';
}
$result = str_replace('{' . $key . '}', $value, $result);
break;
case 'email':
$value = $email;
break;
}
if (empty($value)) {
$value = strpos($key, ':') ? $contact[$key] : self::get_col_values($key, $contact, true);
if (is_array($value) && isset($value[0])) {
$value = $value[0];
}
}
if (!is_string($value)) {
$value = '';
}
$result[$key] = $value;
}
$result = preg_replace('/\s+/u', ' ', $result);
$result = preg_replace('/\s*(<>|\(\)|\[\])/u', '', $result);
$result = trim($result, '/ ');
$plugin = rcube::get_instance()->plugins->exec_hook('compose_search_fields', ['contact' => $contact, 'email' => $email, 'name' => $name, 'fields' => $result]);
return $result;
return $plugin['fields'];
}
/**

View File

@ -335,12 +335,35 @@ html.touch {
&:extend(.font-icon-class);
content: @fa-var-user;
margin-left: .5rem;
line-height: normal;
}
&.group > i:before {
content: @fa-var-users;
}
}
#rcmKSearchpane > ul > li {
display: flex;
flex-flow: row nowrap;
align-items: center;
padding: .5em 0;
& > span.fields {
display: flex;
flex-flow: row wrap;
width: auto;
overflow: hidden;
margin: 0 .25em;
& > span.field {
flex: 0 0 100%;
font-size: 1rem;
line-height: normal;
.overflow-ellipsis();
}
}
}
html.ie11 .listing.iconized li a:before {
font-size: 1.25rem;
}

View File

@ -4450,6 +4450,21 @@ if (window.rcmail) {
// delegate to rcube_elastic_ui
return rcmail.triggerEvent('menu-close', { name: name, props: { menu: name }, originalEvent: event });
};
/**
* Elastic version of ksearch_results_display with small screen support
*/
rcmail.ksearch_results_display = function (fields, search_term) {
var line = $('<li>')
.append($('<i>').addClass('icon'))
.append($('<span>').addClass('fields'));
$.each(fields, function (key, data) {
line.children('span.fields').append($('<span>').addClass('field ' + key).html(data ? rcmail.ksearch_results_highlight(data, search_term) : ''));
});
return line.html();
};
} else {
// rcmail does not exists e.g. on the error template inside a frame
// we fake the engine a little