Browse Source

Implement full text search of Message bodies using the PostgreSQL tsvector and tssearch features

pull/1456/head
Timothée Jaussoin 2 months ago
parent
commit
d4746a4453
  1. 1
      CHANGELOG.md
  2. 2
      INSTALL.md
  3. 20
      app/Message.php
  4. 31
      app/Widgets/Chat/Chat.php
  5. 16
      app/Widgets/Chat/_chat_header.tpl
  6. 1
      app/Widgets/Chat/chat.css
  7. 66
      app/Widgets/ChatActions/ChatActions.php
  8. 123
      app/Widgets/ChatActions/_chatactions_message.tpl
  9. 84
      app/Widgets/ChatActions/_chatactions_message_dialog.tpl
  10. 15
      app/Widgets/ChatActions/_chatactions_search.tpl
  11. 5
      app/Widgets/ChatActions/_chatactions_search_placeholder.tpl
  12. 15
      app/Widgets/ChatActions/_chatactions_search_result.tpl
  13. 27
      app/Widgets/ChatActions/chatactions.css
  14. 10
      app/Widgets/ChatActions/chatactions.js
  15. 2
      app/Widgets/ChatActions/locales.ini
  16. 2
      app/Widgets/Search/_search.tpl
  17. 2
      app/Widgets/Search/_search_results_empty.tpl
  18. 1
      app/Widgets/Search/locales.ini
  19. 26
      database/migrations/20250806055432_add_body_ts_vector_to_messages_table.php
  20. 1
      locales/locales.ini
  21. 5
      public/theme/css/chip.css
  22. 1
      public/theme/css/color.css
  23. 7
      src/Movim/Widget/Base.php

1
CHANGELOG.md

@ -12,6 +12,7 @@ v0.31.1 (master)
* Fix #1270 Add PeerTube embedding support in Posts * Fix #1270 Add PeerTube embedding support in Posts
* Refactor the embbeding code and optimize the eager loading of attachments in Posts * Refactor the embbeding code and optimize the eager loading of attachments in Posts
* Add a "Scroll to Message in History" feature * Add a "Scroll to Message in History" feature
* Implement full text search of Message bodies using the PostgreSQL tsvector and tssearch features
v0.31 v0.31
--------------------------- ---------------------------

2
INSTALL.md

@ -10,7 +10,7 @@ Movim requires some dependencies to be setup properly.
* A PHP process manager like php-fpm will usually be required for Nginx * A PHP process manager like php-fpm will usually be required for Nginx
* Root access by SSH with access to the webserver user (most of the time via the user www-data) * Root access by SSH with access to the webserver user (most of the time via the user www-data)
* A SQL server with a schema for Movim. * A SQL server with a schema for Movim.
* PostgreSQL (**_strongly recommended_**)
* PostgreSQL (**_strongly recommended_** some features are only available on this database)
* MariaDB 10.2 or higher with utf8mb4 encoding (necessary for emojis 😃 support) AND `utf8mb4_bin` collation. * MariaDB 10.2 or higher with utf8mb4 encoding (necessary for emojis 😃 support) AND `utf8mb4_bin` collation.
* MySQL is __NOT__ supported and will throw errors during the migrations, please use PostgreSQL or MariaDB. * MySQL is __NOT__ supported and will throw errors during the migrations, please use PostgreSQL or MariaDB.
* **PHP 8.2 minimum** with : * **PHP 8.2 minimum** with :

20
app/Message.php

@ -37,6 +37,26 @@ class Message extends Model
public static $inlinePlaceholder = 'inline-img:'; public static $inlinePlaceholder = 'inline-img:';
public const MESSAGE_TYPE = [
'chat',
'headline',
'invitation',
'jingle_incoming',
'jingle_outgoing',
'jingle_end',
'jingle_retract',
'jingle_reject'
];
public const MESSAGE_TYPE_MUC = [
'groupchat',
'muji_propose',
'muji_retract',
'muc_owner',
'muc_admin',
'muc_outcast',
'muc_member'
];
public static function boot() public static function boot()
{ {
parent::boot(); parent::boot();

31
app/Widgets/Chat/Chat.php

@ -40,25 +40,6 @@ class Chat extends \Movim\Widget\Base
{ {
private $_pagination = 50; private $_pagination = 50;
private $_wrapper = []; private $_wrapper = [];
private $_messageTypes = [
'chat',
'headline',
'invitation',
'jingle_incoming',
'jingle_outgoing',
'jingle_end',
'jingle_retract',
'jingle_reject'
];
private $_messageTypesMuc = [
'groupchat',
'muji_propose',
'muji_retract',
'muc_owner',
'muc_admin',
'muc_outcast',
'muc_member'
];
private $_mucPresences = []; private $_mucPresences = [];
public function load() public function load()
@ -1062,8 +1043,8 @@ class Chat extends \Movim\Widget\Base
->where('published', '>=', $contextMessage->published); ->where('published', '>=', $contextMessage->published);
$messages = $contextMessage->isMuc() $messages = $contextMessage->isMuc()
? $messages->whereIn('type', $this->_messageTypesMuc)->whereNull('subject')
: $messages->whereIn('type', $this->_messageTypes);
? $messages->whereIn('type', Message::MESSAGE_TYPE_MUC)->whereNull('subject')
: $messages->whereIn('type', Message::MESSAGE_TYPE);
$messages = $messages->orderBy('published', 'asc') $messages = $messages->orderBy('published', 'asc')
->withCount('reactions') ->withCount('reactions')
@ -1103,8 +1084,8 @@ class Chat extends \Movim\Widget\Base
} }
$messages = $muc $messages = $muc
? $messages->whereIn('type', $this->_messageTypesMuc)->whereNull('subject')
: $messages->whereIn('type', $this->_messageTypes);
? $messages->whereIn('type', Message::MESSAGE_TYPE_MUC)->whereNull('subject')
: $messages->whereIn('type', Message::MESSAGE_TYPE);
$messages = $messages->orderBy('published', 'desc') $messages = $messages->orderBy('published', 'desc')
->withCount('reactions') ->withCount('reactions')
@ -1279,8 +1260,8 @@ class Chat extends \Movim\Widget\Base
$messagesQuery = \App\Message::jid($jid); $messagesQuery = \App\Message::jid($jid);
$messagesQuery = $muc $messagesQuery = $muc
? $messagesQuery->whereIn('type', $this->_messageTypesMuc)->whereNull('subject')
: $messagesQuery->whereIn('type', $this->_messageTypes);
? $messagesQuery->whereIn('type', Message::MESSAGE_TYPE_MUC)->whereNull('subject')
: $messagesQuery->whereIn('type', Message::MESSAGE_TYPE);
/** /**
* The object need to be cloned there for MySQL, looks like the pagination/where is kept somewhere in between… * The object need to be cloned there for MySQL, looks like the pagination/where is kept somewhere in between…

16
app/Widgets/Chat/_chat_header.tpl

@ -43,8 +43,14 @@
{/if} {/if}
{/if} {/if}
{if="$c->database('pgsql')"}
<span class="control icon active" onclick="ChatActions_ajaxShowSearchDialog('{$jid|echapJS}', true)">
<i class="material-symbols">manage_search</i>
</span>
{/if}
{if="$conference && $conference->mujiCalls->isEmpty() && $conference->isGroupChat()"} {if="$conference && $conference->mujiCalls->isEmpty() && $conference->isGroupChat()"}
<span class="control icon active {if="$incall"}disabled{/if}" onclick="Visio_ajaxGetMujiLobby('{$conference->conference}', true, false);">
<span class="control icon active {if="$c->database('pgsql')"}divided{/if} {if="$incall"}disabled{/if}" onclick="Visio_ajaxGetMujiLobby('{$conference->conference}', true, false);">
<i class="material-symbols">call</i> <i class="material-symbols">call</i>
</span> </span>
<span class="control icon active {if="$incall"}disabled{/if}" onclick="Visio_ajaxGetMujiLobby('{$conference->conference}', true, true);"> <span class="control icon active {if="$incall"}disabled{/if}" onclick="Visio_ajaxGetMujiLobby('{$conference->conference}', true, true);">
@ -276,6 +282,12 @@
<img src="{if="$roster"}{$roster->getPicture()}{else}{$contact->getPicture()}{/if}"> <img src="{if="$roster"}{$roster->getPicture()}{else}{$contact->getPicture()}{/if}">
</span> </span>
{if="$c->database('pgsql')"}
<span class="control icon active" onclick="ChatActions_ajaxShowSearchDialog('{$jid|echapJS}')">
<i class="material-symbols">manage_search</i>
</span>
{/if}
{$call = false} {$call = false}
{if="!$incall"} {if="!$incall"}
@ -283,7 +295,7 @@
{loop="$roster->presences"} {loop="$roster->presences"}
{if="$value->capability && $value->capability->isJingleAudio() && $value->jid"} {if="$value->capability && $value->capability->isJingleAudio() && $value->jid"}
{$call = true} {$call = true}
<span title="{$c->__('button.audio_call')}" class="control icon active on_desktop"
<span title="{$c->__('button.audio_call')}" class="control icon active {if="$c->database('pgsql')"}divided{/if} on_desktop"
onclick="Visio_ajaxGetLobby('{$value->jid|echapJS}', true);"> onclick="Visio_ajaxGetLobby('{$value->jid|echapJS}', true);">
<i class="material-symbols">phone</i> <i class="material-symbols">phone</i>
</span> </span>

1
app/Widgets/Chat/chat.css

@ -646,6 +646,7 @@ ul.list li > div.bubble.file ul.card + p:empty:before {
ul.list li > div.bubble.file div.file { ul.list li > div.bubble.file div.file {
margin-bottom: 3rem; margin-bottom: 3rem;
border-radius: 0.75rem; border-radius: 0.75rem;
position: relative;
} }
li:not(.oppose) .bubble.file span.resource+div.file { li:not(.oppose) .bubble.file span.resource+div.file {

66
app/Widgets/ChatActions/ChatActions.php

@ -2,16 +2,20 @@
namespace App\Widgets\ChatActions; namespace App\Widgets\ChatActions;
use App\Message;
use App\Url; use App\Url;
use App\Widgets\Chat\Chat; use App\Widgets\Chat\Chat;
use App\Widgets\ContactActions\ContactActions; use App\Widgets\ContactActions\ContactActions;
use App\Widgets\Dialog\Dialog; use App\Widgets\Dialog\Dialog;
use App\Widgets\Drawer\Drawer;
use App\Widgets\Toast\Toast; use App\Widgets\Toast\Toast;
use Moxl\Xec\Action\Blocking\Block; use Moxl\Xec\Action\Blocking\Block;
use Moxl\Xec\Action\Blocking\Unblock; use Moxl\Xec\Action\Blocking\Unblock;
use Moxl\Xec\Action\Message\Moderate; use Moxl\Xec\Action\Message\Moderate;
use Moxl\Xec\Action\Message\Retract; use Moxl\Xec\Action\Message\Retract;
use Illuminate\Database\Capsule\Manager as DB;
class ChatActions extends \Movim\Widget\Base class ChatActions extends \Movim\Widget\Base
{ {
public function load() public function load()
@ -91,10 +95,70 @@ class ChatActions extends \Movim\Widget\Base
} }
$this->rpc('ChatActions.setMessage', $message); $this->rpc('ChatActions.setMessage', $message);
Dialog::fill($view->draw('_chatactions_message'));
Dialog::fill($view->draw('_chatactions_message_dialog'));
} }
} }
/**
* @brief Display the search dialog
*/
public function ajaxShowSearchDialog(string $jid, ?bool $muc = false)
{
if (DB::getDriverName() != 'pgsql') return;
$view = $this->tpl();
$view->assign('jid', $jid);
$view->assign('muc', $muc);
Drawer::fill('chat_search', $view->draw('_chatactions_search'));
$this->rpc('ChatActions.focusSearch');
}
public function ajaxSearchMessages(string $jid, string $keywords, ?bool $muc = false)
{
if (DB::getDriverName() != 'pgsql') return;
if (!validateJid($jid)) return;
if (!empty($keywords)) {
$keywords = str_replace(' ', ' & ', trim($keywords));
$messagesQuery = \App\Message::jid($jid)
->selectRaw('*, ts_headline(\'simple\', body, plainto_tsquery(\'simple\', ?), \'StartSel=<mark>,StopSel=</mark>\') AS headline', [$keywords])
->whereRaw('to_tsvector(\'simple\', body) @@ to_tsquery(\'simple\', ?)', [$keywords])
->orderBy('published', 'desc')
->where('encrypted', false)
->where('retracted', false)
->take(20);
$messagesQuery = $muc
? $messagesQuery->whereIn('type', Message::MESSAGE_TYPE_MUC)->whereNull('subject')
: $messagesQuery->whereIn('type', Message::MESSAGE_TYPE);
$messages = $messagesQuery->get();
$view = $this->tpl();
$view->assign('messages', $messages);
$this->rpc('MovimTpl.fill', '#chat_search', $view->draw('_chatactions_search_result'));
} else {
$this->rpc('MovimTpl.fill', '#chat_search', $this->prepareSearchPlaceholder());
}
}
public function prepareMessage(Message $message, ?bool $search = false)
{
$view = $this->tpl();
$view->assign('message', $message);
$view->assign('search', $search);
return $view->draw('_chatactions_message');
}
public function prepareSearchPlaceholder()
{
$view = $this->tpl();
return $view->draw('_chatactions_search_placeholder');
}
public function ajaxCopiedMessageText() public function ajaxCopiedMessageText()
{ {
Toast::send($this->__('chatactions.copied_text')); Toast::send($this->__('chatactions.copied_text'));

123
app/Widgets/ChatActions/_chatactions_message.tpl

@ -1,99 +1,26 @@
<section id="chat_actions">
<ul class="list" id="message_preview">
<li {if="$message->isMine()"}class="oppose"{/if}>
<div class="bubble {if="$message->file && $message->file->isPicture"}file{/if}" data-publishedprepared="{$message->published|prepareTime}">
<div class="message">
{if="$message->encrypted"}
<p class="encrypted">{$c->__('message.encrypted')} <i class="material-symbols fill">lock</i></p>
{elseif="$message->retracted"}
<p class="retracted">{$c->__('message.retracted')} <i class="material-symbols">delete</i></p>
{elseif="$message->file && $message->file->isPicture"}
<div class="file" data-type="{$message->file->type}">
<img src="{$message->file->url|protectPicture}">
</div>
{else}
<p>{autoescape="off"}{$message->body|trim|addEmojis}{/autoescape}</p>
{/if}
<span class="info"></span>
<li {if="$message->isMine()"}class="oppose"{/if} onclick="Chat_ajaxGetMessageContext('{$message->jid}', {$message->mid}); Drawer.clear()">
<div class="bubble {if="$message->file && $message->file->isPicture"}file{/if}"
data-publishedprepared="{if="$search"}{$message->published|prepareDate}{else}{$message->published|prepareTime}{/if}">
<div class="message">
{if="$message->isMuc()"}
<span class="resource {$message->resolveColor()}">{$message->resource}</span>
{/if}
{if="$message->encrypted"}
<p class="encrypted">{$c->__('message.encrypted')} <i class="material-symbols fill">lock</i></p>
{elseif="$message->retracted"}
<p class="retracted">{$c->__('message.retracted')} <i class="material-symbols">delete</i></p>
{elseif="$message->file && $message->file->isPicture"}
<div class="file" data-type="{$message->file->type}">
<img src="{$message->file->url|protectPicture}">
</div> </div>
</div>
</li>
<li class="reactions">
<div>
<p>{loop="$message->reactions->groupBy('emoji')"}<a class="chip" href="#" title="{$value->implode('truename', ', ')}">{autoescape="off"}{$key|addEmojis:true}{/autoescape}{$value->implode('truename', ', ')}</a>{/loop}</p>
</div>
</li>
</ul>
<ul class="list divided active middle">
{if="!$message->encrypted"}
<li onclick="Stickers_ajaxReaction({$message->mid}); Dialog_ajaxClear()">
<span class="primary icon gray">
<i class="material-symbols">add_reaction</i>
</span>
<div>
<p class="normal">{$c->__('message.react')}</p>
</div>
</li>
<li
onclick="MovimUtils.copyToClipboard(MovimUtils.decodeHTMLEntities(ChatActions.message.body)); ChatActions_ajaxCopiedMessageText(); Dialog_ajaxClear()">
<span class="primary icon gray">
<i class="material-symbols">content_copy</i>
</span>
<div>
<p class="normal">{$c->__('chatactions.copy_text')}</p>
</div>
</li>
{/if}
<li onclick="Chat_ajaxHttpDaemonReply({$message->mid}); Dialog_ajaxClear()">
<span class="primary icon gray">
<i class="material-symbols">reply</i>
</span>
<div>
<p class="normal">{$c->__('button.reply')}</p>
</div>
</li>
{if="$message->isLast()"}
<li onclick="Chat.editPrevious(); Dialog_ajaxClear();">
<span class="primary icon gray">
<i class="material-symbols">edit</i>
</span>
<div>
<p class="normal">{$c->__('button.edit')}</p>
</div>
</li>
{/if}
{if="$message->isMine()"}
<li onclick="ChatActions_ajaxHttpDaemonRetract({$message->mid})">
<span class="primary icon gray">
<i class="material-symbols">delete</i>
</span>
<div>
<p class="normal">{$c->__('message.retract')}</p>
</div>
</li>
{/if}
{if="$conference && $conference->presence && $conference->presence->mucrole == 'moderator' && $conference->info && $conference->info->hasModeration()"}
<li class="subheader">
<div>
<p>{$c->__('chatroom.administration')}</p>
</div>
</li>
<li onclick="ChatActions_ajaxHttpDaemonModerate({$message->mid})">
<span class="primary icon gray">
<i class="material-symbols">delete</i>
</span>
<div>
<p class="normal">{$c->__('message.retract')}</p>
</div>
</li>
{/if}
</ul>
</section>
<footer>
<button onclick="Dialog_ajaxClear()" class="button flat">
{$c->__('button.close')}
</button>
</footer>
{else}
{if="$search"}
<p>{autoescape="off"}{$message->headline|trim|addEmojis}{/autoescape}</p>
{else}
<p>{autoescape="off"}{$message->body|trim|addEmojis}{/autoescape}</p>
{/if}
{/if}
<span class="info"></span>
</div>
</div>
</li>

84
app/Widgets/ChatActions/_chatactions_message_dialog.tpl

@ -0,0 +1,84 @@
<section id="chat_actions">
<ul class="list" id="message_preview">
{autoescape="off"}
{$c->prepareMessage($message)}
{/autoescape}
<li class="reactions">
<div>
<p>{loop="$message->reactions->groupBy('emoji')"}<a class="chip" href="#" title="{$value->implode('truename', ', ')}">{autoescape="off"}{$key|addEmojis:true}{/autoescape}{$value->implode('truename', ', ')}</a>{/loop}</p>
</div>
</li>
</ul>
<ul class="list divided active middle">
{if="!$message->encrypted"}
<li onclick="Stickers_ajaxReaction({$message->mid}); Dialog_ajaxClear()">
<span class="primary icon gray">
<i class="material-symbols">add_reaction</i>
</span>
<div>
<p class="normal">{$c->__('message.react')}</p>
</div>
</li>
<li
onclick="MovimUtils.copyToClipboard(MovimUtils.decodeHTMLEntities(ChatActions.message.body)); ChatActions_ajaxCopiedMessageText(); Dialog_ajaxClear()">
<span class="primary icon gray">
<i class="material-symbols">content_copy</i>
</span>
<div>
<p class="normal">{$c->__('chatactions.copy_text')}</p>
</div>
</li>
{/if}
<li onclick="Chat_ajaxHttpDaemonReply({$message->mid}); Dialog_ajaxClear()">
<span class="primary icon gray">
<i class="material-symbols">reply</i>
</span>
<div>
<p class="normal">{$c->__('button.reply')}</p>
</div>
</li>
{if="$message->isLast()"}
<li onclick="Chat.editPrevious(); Dialog_ajaxClear();">
<span class="primary icon gray">
<i class="material-symbols">edit</i>
</span>
<div>
<p class="normal">{$c->__('button.edit')}</p>
</div>
</li>
{/if}
{if="$message->isMine()"}
<li onclick="ChatActions_ajaxHttpDaemonRetract({$message->mid})">
<span class="primary icon gray">
<i class="material-symbols">delete</i>
</span>
<div>
<p class="normal">{$c->__('message.retract')}</p>
</div>
</li>
{/if}
{if="$conference && $conference->presence && $conference->presence->mucrole == 'moderator' && $conference->info && $conference->info->hasModeration()"}
<li class="subheader">
<div>
<p>{$c->__('chatroom.administration')}</p>
</div>
</li>
<li onclick="ChatActions_ajaxHttpDaemonModerate({$message->mid})">
<span class="primary icon gray">
<i class="material-symbols">delete</i>
</span>
<div>
<p class="normal">{$c->__('message.retract')}</p>
</div>
</li>
{/if}
</ul>
</section>
<footer>
<button onclick="Dialog_ajaxClear()" class="button flat">
{$c->__('button.close')}
</button>
</footer>

15
app/Widgets/ChatActions/_chatactions_search.tpl

@ -0,0 +1,15 @@
<section id="chat_search">
{autoescape="off"}
{$c->prepareSearchPlaceholder()}
{/autoescape}
</section>
<ul class="list">
<li class="search">
<form name="search" onsubmit="return false;">
<div>
<input name="keyword" autocomplete="off"
placeholder="{$c->__('button.search')}" oninput="console.log(this.value); ChatActions_ajaxSearchMessages('{$jid|echapJS}', this.value, {if="$muc"}true{else}false{/if});" type=" text">
</div>
</form>
</li>
</ul>

5
app/Widgets/ChatActions/_chatactions_search_placeholder.tpl

@ -0,0 +1,5 @@
<div class="placeholder">
<i class="material-symbols">search</i>
<h1>{$c->__('chatactions.search_messages')}</h1>
<h4>{$c->__('input.open_me_using')} <span class="chip outline">Ctrl</span> + <span class="chip outline">F</span></h4>
</div>

15
app/Widgets/ChatActions/_chatactions_search_result.tpl

@ -0,0 +1,15 @@
{if="$messages->isEmpty()"}
<div class="placeholder">
<i class="material-symbols">search_off</i>
<h1>{$c->__('chatactions.search_messages')}</h1>
<h4>{$c->__('chatactions.search_messages_empty')}</h4>
</div>
{else}
<ul class="list active divided" id="message_preview">
{loop="$messages"}
{autoescape="off"}
{$c->prepareMessage($value, true)}
{/autoescape}
{/loop}
</ul>
{/if}

27
app/Widgets/ChatActions/chatactions.css

@ -1,34 +1,45 @@
#chat_actions ul#message_preview {
ul#message_preview {
background-color: rgb(var(--movim-background)); background-color: rgb(var(--movim-background));
padding-top: 1.5rem;
min-height: 100%;
box-sizing: border-box;
padding: 0.75rem 0;
} }
#chat_actions ul#message_preview li div.bubble {
ul#message_preview li {
padding: 0.75rem;
}
ul#message_preview li > div.bubble::after {
white-space: nowrap;
}
ul#message_preview li div.bubble {
max-width: calc(100% - 2rem); max-width: calc(100% - 2rem);
pointer-events: none; pointer-events: none;
} }
#chat_actions ul#message_preview li div.bubble:not(.file) {
ul#message_preview li div.bubble:not(.file) {
padding-right: 5rem; padding-right: 5rem;
margin-bottom: 0; margin-bottom: 0;
padding-bottom: 3rem;
} }
#chat_actions ul#message_preview li.oppose {
ul#message_preview li.oppose {
flex-direction: row-reverse; flex-direction: row-reverse;
} }
#chat_actions ul#message_preview li div.bubble div.message > p {
ul#message_preview li div.bubble div.message > p {
white-space: initial; white-space: initial;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 4; -webkit-line-clamp: 4;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
} }
#chat_actions ul#message_preview li.reactions {
ul#message_preview li.reactions {
padding: 0 0.5rem; padding: 0 0.5rem;
padding-bottom: 0.75rem; padding-bottom: 0.75rem;
} }
#chat_actions ul#message_preview li.reactions > p {
ul#message_preview li.reactions > p {
line-height: 4.75rem; line-height: 4.75rem;
} }

10
app/Widgets/ChatActions/chatactions.js

@ -3,5 +3,15 @@ var ChatActions = {
setMessage: function (message) { setMessage: function (message) {
ChatActions.message = message; ChatActions.message = message;
},
focusSearch: function () {
document.querySelector('form[name=search] input').focus();
} }
} }
MovimEvents.registerWindow('keydown', 'search_message', (e) => {
if (e.key == 'f' && e.ctrlKey && MovimUtils.urlParts().page == 'chat' && Chat.getTextarea()) {
e.preventDefault();
ChatActions_ajaxShowSearchDialog(Chat.getTextarea().dataset.jid, Boolean(Chat.getTextarea().dataset.muc));
}
});

2
app/Widgets/ChatActions/locales.ini

@ -1,3 +1,5 @@
[chatactions] [chatactions]
copy_text = Copy the text copy_text = Copy the text
copied_text = Text copied copied_text = Text copied
search_messages = Search messages by content
search_messages_empty = No messages found

2
app/Widgets/Search/_search.tpl

@ -35,7 +35,7 @@
<div id="results"> <div id="results">
<div class="placeholder"> <div class="placeholder">
<i class="material-symbols">search</i> <i class="material-symbols">search</i>
<h4>{$c->__('search.subtitle')}</h4>
<h4>{$c->__('input.open_me_using')} <span class="chip outline">Ctrl</span> + <span class="chip outline">M</span></h4>
</div> </div>
</div> </div>

2
app/Widgets/Search/_search_results_empty.tpl

@ -5,6 +5,6 @@
{else} {else}
<div class="placeholder"> <div class="placeholder">
<i class="material-symbols">search</i> <i class="material-symbols">search</i>
<h4>{$c->__('search.subtitle')}</h4>
<h4>{$c->__('input.open_me_using')} <span class="chip outline">Ctrl</span> + <span class="chip outline">M</span></h4>
</div> </div>
{/if} {/if}

1
app/Widgets/Search/locales.ini

@ -1,6 +1,5 @@
[search] [search]
keyword = What are you looking for? keyword = What are you looking for?
subtitle = Open me using Ctrl + M
placeholder = "#cats, username@server.com, John…" placeholder = "#cats, username@server.com, John…"
no_contacts_title = No contacts yet? no_contacts_title = No contacts yet?
no_contacts_text = Find one by searching for their name or address no_contacts_text = Find one by searching for their name or address

26
database/migrations/20250806055432_add_body_ts_vector_to_messages_table.php

@ -0,0 +1,26 @@
<?php
use Movim\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Capsule\Manager as DB;
class AddBodyTsVectorToMessagesTable extends Migration
{
public function up()
{
if ($this->schema->getConnection()->getDriverName() == 'pgsql') {
$this->schema->table('messages', function (Blueprint $table) {
DB::statement("create index messages_body_gin_index on messages using gin (to_tsvector('simple', body));");
});
}
}
public function down()
{
if ($this->schema->getConnection()->getDriverName() == 'pgsql') {
$this->schema->table('messages', function (Blueprint $table) {
$table->dropIndex('messages_body_gin_index');
});
}
}
}

1
locales/locales.ini

@ -95,6 +95,7 @@ username = Username
password = Password password = Password
optional = Optional optional = Optional
muc_pubsub_node = Associated Community muc_pubsub_node = Associated Community
open_me_using = "Open me using"
[day] [day]
title = Day title = Day

5
public/theme/css/chip.css

@ -12,13 +12,14 @@
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
max-width: 80%; max-width: 80%;
vertical-align: middle;
} }
*:not(p)>.chip:first-child {
*:not(p):not(h4)>.chip:first-child {
margin-left: 0; margin-left: 0;
} }
*:not(p)>.chip:last-child {
*:not(p):not(h4)>.chip:last-child {
margin-right: 0; margin-right: 0;
} }

1
public/theme/css/color.css

@ -187,6 +187,7 @@ header.big ul.list li>div>p>span.second {
input[type=button].color, input[type=button].color,
header.big, header.big,
mark,
main > header { main > header {
background-color: var(--movim-accent); background-color: var(--movim-accent);
border-color: var(--movim-accent); border-color: var(--movim-accent);

7
src/Movim/Widget/Base.php

@ -13,6 +13,8 @@ use Moxl\Xec\Payload\Packet;
use WyriHaximus\React\Cron; use WyriHaximus\React\Cron;
use WyriHaximus\React\Cron\Action; use WyriHaximus\React\Cron\Action;
use Illuminate\Database\Capsule\Manager as DB;
class Base class Base
{ {
protected array $js = []; // Contains javascripts protected array $js = []; // Contains javascripts
@ -116,6 +118,11 @@ class Base
return \Movim\Route::urlize(...$args); return \Movim\Route::urlize(...$args);
} }
public function database(string $driver): bool
{
return DB::getDriverName() == $driver;
}
public function rpc(...$args) public function rpc(...$args)
{ {
\Movim\RPC::call(...$args); \Movim\RPC::call(...$args);

Loading…
Cancel
Save