Browse Source
Implement full text search of Message bodies using the PostgreSQL tsvector and tssearch features
pull/1456/head
Implement full text search of Message bodies using the PostgreSQL tsvector and tssearch features
pull/1456/head
23 changed files with 323 additions and 140 deletions
-
1CHANGELOG.md
-
2INSTALL.md
-
20app/Message.php
-
31app/Widgets/Chat/Chat.php
-
16app/Widgets/Chat/_chat_header.tpl
-
1app/Widgets/Chat/chat.css
-
66app/Widgets/ChatActions/ChatActions.php
-
123app/Widgets/ChatActions/_chatactions_message.tpl
-
84app/Widgets/ChatActions/_chatactions_message_dialog.tpl
-
15app/Widgets/ChatActions/_chatactions_search.tpl
-
5app/Widgets/ChatActions/_chatactions_search_placeholder.tpl
-
15app/Widgets/ChatActions/_chatactions_search_result.tpl
-
27app/Widgets/ChatActions/chatactions.css
-
10app/Widgets/ChatActions/chatactions.js
-
2app/Widgets/ChatActions/locales.ini
-
2app/Widgets/Search/_search.tpl
-
2app/Widgets/Search/_search_results_empty.tpl
-
1app/Widgets/Search/locales.ini
-
26database/migrations/20250806055432_add_body_ts_vector_to_messages_table.php
-
1locales/locales.ini
-
5public/theme/css/chip.css
-
1public/theme/css/color.css
-
7src/Movim/Widget/Base.php
@ -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> |
@ -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> |
@ -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> |
@ -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> |
@ -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} |
@ -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; |
||||
} |
} |
@ -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 |
@ -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'); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue