diff --git a/CHANGELOG.md b/CHANGELOG.md index 30cefe6b5..f9b0add30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ v0.31.1 (master) * Fix #1270 Add PeerTube embedding support in Posts * Refactor the embbeding code and optimize the eager loading of attachments in Posts * Add a "Scroll to Message in History" feature +* Implement full text search of Message bodies using the PostgreSQL tsvector and tssearch features v0.31 --------------------------- diff --git a/INSTALL.md b/INSTALL.md index 2b16040d6..278adbce8 100644 --- a/INSTALL.md +++ b/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 * 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. - * 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. * MySQL is __NOT__ supported and will throw errors during the migrations, please use PostgreSQL or MariaDB. * **PHP 8.2 minimum** with : diff --git a/app/Message.php b/app/Message.php index 3888aa5ec..1e137d875 100644 --- a/app/Message.php +++ b/app/Message.php @@ -37,6 +37,26 @@ class Message extends Model 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() { parent::boot(); diff --git a/app/Widgets/Chat/Chat.php b/app/Widgets/Chat/Chat.php index a727929dd..a6f6cd09e 100644 --- a/app/Widgets/Chat/Chat.php +++ b/app/Widgets/Chat/Chat.php @@ -40,25 +40,6 @@ class Chat extends \Movim\Widget\Base { private $_pagination = 50; 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 = []; public function load() @@ -1062,8 +1043,8 @@ class Chat extends \Movim\Widget\Base ->where('published', '>=', $contextMessage->published); $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') ->withCount('reactions') @@ -1103,8 +1084,8 @@ class Chat extends \Movim\Widget\Base } $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') ->withCount('reactions') @@ -1279,8 +1260,8 @@ class Chat extends \Movim\Widget\Base $messagesQuery = \App\Message::jid($jid); $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… diff --git a/app/Widgets/Chat/_chat_header.tpl b/app/Widgets/Chat/_chat_header.tpl index 397ae8a7c..35654aab4 100644 --- a/app/Widgets/Chat/_chat_header.tpl +++ b/app/Widgets/Chat/_chat_header.tpl @@ -43,8 +43,14 @@ {/if} {/if} + {if="$c->database('pgsql')"} + + manage_search + + {/if} + {if="$conference && $conference->mujiCalls->isEmpty() && $conference->isGroupChat()"} - + database('pgsql')"}divided{/if} {if="$incall"}disabled{/if}" onclick="Visio_ajaxGetMujiLobby('{$conference->conference}', true, false);"> call @@ -276,6 +282,12 @@ getPicture()}{else}{$contact->getPicture()}{/if}"> + {if="$c->database('pgsql')"} + + manage_search + + {/if} + {$call = false} {if="!$incall"} @@ -283,7 +295,7 @@ {loop="$roster->presences"} {if="$value->capability && $value->capability->isJingleAudio() && $value->jid"} {$call = true} - database('pgsql')"}divided{/if} on_desktop" onclick="Visio_ajaxGetLobby('{$value->jid|echapJS}', true);"> phone diff --git a/app/Widgets/Chat/chat.css b/app/Widgets/Chat/chat.css index fd09a814e..4ea89c11f 100644 --- a/app/Widgets/Chat/chat.css +++ b/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 { margin-bottom: 3rem; border-radius: 0.75rem; + position: relative; } li:not(.oppose) .bubble.file span.resource+div.file { diff --git a/app/Widgets/ChatActions/ChatActions.php b/app/Widgets/ChatActions/ChatActions.php index 1a15bd09b..4f02af69a 100644 --- a/app/Widgets/ChatActions/ChatActions.php +++ b/app/Widgets/ChatActions/ChatActions.php @@ -2,16 +2,20 @@ namespace App\Widgets\ChatActions; +use App\Message; use App\Url; use App\Widgets\Chat\Chat; use App\Widgets\ContactActions\ContactActions; use App\Widgets\Dialog\Dialog; +use App\Widgets\Drawer\Drawer; use App\Widgets\Toast\Toast; use Moxl\Xec\Action\Blocking\Block; use Moxl\Xec\Action\Blocking\Unblock; use Moxl\Xec\Action\Message\Moderate; use Moxl\Xec\Action\Message\Retract; +use Illuminate\Database\Capsule\Manager as DB; + class ChatActions extends \Movim\Widget\Base { public function load() @@ -91,10 +95,70 @@ class ChatActions extends \Movim\Widget\Base } $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=,StopSel=\') 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() { Toast::send($this->__('chatactions.copied_text')); diff --git a/app/Widgets/ChatActions/_chatactions_message.tpl b/app/Widgets/ChatActions/_chatactions_message.tpl index 5510d176c..032176c69 100644 --- a/app/Widgets/ChatActions/_chatactions_message.tpl +++ b/app/Widgets/ChatActions/_chatactions_message.tpl @@ -1,99 +1,26 @@ -
- - -
- + {else} + {if="$search"} +

{autoescape="off"}{$message->headline|trim|addEmojis}{/autoescape}

+ {else} +

{autoescape="off"}{$message->body|trim|addEmojis}{/autoescape}

+ {/if} + {/if} + + + + \ No newline at end of file diff --git a/app/Widgets/ChatActions/_chatactions_message_dialog.tpl b/app/Widgets/ChatActions/_chatactions_message_dialog.tpl new file mode 100644 index 000000000..76666daf4 --- /dev/null +++ b/app/Widgets/ChatActions/_chatactions_message_dialog.tpl @@ -0,0 +1,84 @@ +
+ + +
+ diff --git a/app/Widgets/ChatActions/_chatactions_search.tpl b/app/Widgets/ChatActions/_chatactions_search.tpl new file mode 100644 index 000000000..08fe62976 --- /dev/null +++ b/app/Widgets/ChatActions/_chatactions_search.tpl @@ -0,0 +1,15 @@ + + diff --git a/app/Widgets/ChatActions/_chatactions_search_placeholder.tpl b/app/Widgets/ChatActions/_chatactions_search_placeholder.tpl new file mode 100644 index 000000000..a8af1bd6f --- /dev/null +++ b/app/Widgets/ChatActions/_chatactions_search_placeholder.tpl @@ -0,0 +1,5 @@ +
+ search +

{$c->__('chatactions.search_messages')}

+

{$c->__('input.open_me_using')} Ctrl + F

+
\ No newline at end of file diff --git a/app/Widgets/ChatActions/_chatactions_search_result.tpl b/app/Widgets/ChatActions/_chatactions_search_result.tpl new file mode 100644 index 000000000..ed4021c40 --- /dev/null +++ b/app/Widgets/ChatActions/_chatactions_search_result.tpl @@ -0,0 +1,15 @@ +{if="$messages->isEmpty()"} +
+ search_off +

{$c->__('chatactions.search_messages')}

+

{$c->__('chatactions.search_messages_empty')}

+
+{else} + +{/if} \ No newline at end of file diff --git a/app/Widgets/ChatActions/chatactions.css b/app/Widgets/ChatActions/chatactions.css index 958fbf0e8..c32de4e4b 100644 --- a/app/Widgets/ChatActions/chatactions.css +++ b/app/Widgets/ChatActions/chatactions.css @@ -1,34 +1,45 @@ -#chat_actions ul#message_preview { +ul#message_preview { 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); pointer-events: none; } -#chat_actions ul#message_preview li div.bubble:not(.file) { +ul#message_preview li div.bubble:not(.file) { padding-right: 5rem; margin-bottom: 0; + padding-bottom: 3rem; } -#chat_actions ul#message_preview li.oppose { +ul#message_preview li.oppose { 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; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; } -#chat_actions ul#message_preview li.reactions { +ul#message_preview li.reactions { padding: 0 0.5rem; padding-bottom: 0.75rem; } -#chat_actions ul#message_preview li.reactions > p { +ul#message_preview li.reactions > p { line-height: 4.75rem; } \ No newline at end of file diff --git a/app/Widgets/ChatActions/chatactions.js b/app/Widgets/ChatActions/chatactions.js index 41dbe1e88..b5e471415 100644 --- a/app/Widgets/ChatActions/chatactions.js +++ b/app/Widgets/ChatActions/chatactions.js @@ -3,5 +3,15 @@ var ChatActions = { setMessage: function (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)); + } +}); diff --git a/app/Widgets/ChatActions/locales.ini b/app/Widgets/ChatActions/locales.ini index c5fc2ca37..c1f71c0bd 100644 --- a/app/Widgets/ChatActions/locales.ini +++ b/app/Widgets/ChatActions/locales.ini @@ -1,3 +1,5 @@ [chatactions] copy_text = Copy the text copied_text = Text copied +search_messages = Search messages by content +search_messages_empty = No messages found \ No newline at end of file diff --git a/app/Widgets/Search/_search.tpl b/app/Widgets/Search/_search.tpl index ed0b7bb34..41144e1ab 100644 --- a/app/Widgets/Search/_search.tpl +++ b/app/Widgets/Search/_search.tpl @@ -35,7 +35,7 @@
search -

{$c->__('search.subtitle')}

+

{$c->__('input.open_me_using')} Ctrl + M

diff --git a/app/Widgets/Search/_search_results_empty.tpl b/app/Widgets/Search/_search_results_empty.tpl index cafb7aa1a..ca8a1a7d8 100644 --- a/app/Widgets/Search/_search_results_empty.tpl +++ b/app/Widgets/Search/_search_results_empty.tpl @@ -5,6 +5,6 @@ {else}
search -

{$c->__('search.subtitle')}

+

{$c->__('input.open_me_using')} Ctrl + M

{/if} diff --git a/app/Widgets/Search/locales.ini b/app/Widgets/Search/locales.ini index 4c7b684a5..a23212f48 100644 --- a/app/Widgets/Search/locales.ini +++ b/app/Widgets/Search/locales.ini @@ -1,6 +1,5 @@ [search] keyword = What are you looking for? -subtitle = Open me using Ctrl + M placeholder = "#cats, username@server.com, John…" no_contacts_title = No contacts yet? no_contacts_text = Find one by searching for their name or address diff --git a/database/migrations/20250806055432_add_body_ts_vector_to_messages_table.php b/database/migrations/20250806055432_add_body_ts_vector_to_messages_table.php new file mode 100644 index 000000000..7699aa629 --- /dev/null +++ b/database/migrations/20250806055432_add_body_ts_vector_to_messages_table.php @@ -0,0 +1,26 @@ +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'); + }); + } + } +} diff --git a/locales/locales.ini b/locales/locales.ini index 23a2e3e2e..ad3541bcb 100644 --- a/locales/locales.ini +++ b/locales/locales.ini @@ -95,6 +95,7 @@ username = Username password = Password optional = Optional muc_pubsub_node = Associated Community +open_me_using = "Open me using" [day] title = Day diff --git a/public/theme/css/chip.css b/public/theme/css/chip.css index 43c937a35..a7a576c31 100644 --- a/public/theme/css/chip.css +++ b/public/theme/css/chip.css @@ -12,13 +12,14 @@ text-overflow: ellipsis; white-space: nowrap; max-width: 80%; + vertical-align: middle; } -*:not(p)>.chip:first-child { +*:not(p):not(h4)>.chip:first-child { margin-left: 0; } -*:not(p)>.chip:last-child { +*:not(p):not(h4)>.chip:last-child { margin-right: 0; } diff --git a/public/theme/css/color.css b/public/theme/css/color.css index 4f1c7921a..f70a69312 100644 --- a/public/theme/css/color.css +++ b/public/theme/css/color.css @@ -187,6 +187,7 @@ header.big ul.list li>div>p>span.second { input[type=button].color, header.big, +mark, main > header { background-color: var(--movim-accent); border-color: var(--movim-accent); diff --git a/src/Movim/Widget/Base.php b/src/Movim/Widget/Base.php index 85758eda7..10f9aa263 100644 --- a/src/Movim/Widget/Base.php +++ b/src/Movim/Widget/Base.php @@ -13,6 +13,8 @@ use Moxl\Xec\Payload\Packet; use WyriHaximus\React\Cron; use WyriHaximus\React\Cron\Action; +use Illuminate\Database\Capsule\Manager as DB; + class Base { protected array $js = []; // Contains javascripts @@ -116,6 +118,11 @@ class Base return \Movim\Route::urlize(...$args); } + public function database(string $driver): bool + { + return DB::getDriverName() == $driver; + } + public function rpc(...$args) { \Movim\RPC::call(...$args);