Message search WIP.

This commit is contained in:
Richard Lewis 2019-12-31 16:21:34 +00:00
parent 6b074a6660
commit fc4c2bc9d7
11 changed files with 440 additions and 0 deletions

View file

@ -39,6 +39,11 @@
:network="network"
:text="channel.topic"
/></span>
<MessageSearchForm
v-if="['channel', 'query'].includes(channel.type)"
:network="network"
:channel="channel"
/>
<button
class="mentions"
aria-label="Open your mentions"
@ -109,6 +114,7 @@ import MessageList from "./MessageList.vue";
import ChatInput from "./ChatInput.vue";
import ChatUserList from "./ChatUserList.vue";
import SidebarToggle from "./SidebarToggle.vue";
import MessageSearchForm from "./MessageSearchForm.vue";
import ListBans from "./Special/ListBans.vue";
import ListInvites from "./Special/ListInvites.vue";
import ListChannels from "./Special/ListChannels.vue";
@ -122,6 +128,7 @@ export default {
ChatInput,
ChatUserList,
SidebarToggle,
MessageSearchForm,
},
props: {
network: Object,

View file

@ -0,0 +1,71 @@
<template>
<form :class="['message-search', {opened: searchOpened}]" @submit.prevent="searchMessages">
<div class="input-wrapper">
<input
ref="searchInputField"
type="text"
name="search"
class="input"
placeholder="Search messages..."
@input="setSearchInput"
@blur="closeSearch"
/>
</div>
<button
class="search"
type="button"
aria-label="Search messages in this channel"
@click.prevent="toggleSearch"
/>
</form>
</template>
<script>
export default {
name: "MessageSearchForm",
props: {
network: Object,
channel: Object,
},
data() {
return {
searchOpened: false,
searchInput: "",
};
},
methods: {
setSearchInput(event) {
this.searchInput = event.target.value;
},
closeSearch() {
this.searchOpened = false;
},
toggleSearch(event) {
event.preventDefault();
this.searchOpened = !this.searchOpened;
if (this.searchOpened) {
this.$refs.searchInputField.focus();
}
},
searchMessages(event) {
event.preventDefault();
if (!this.searchInput) {
return;
}
this.searchOpened = false;
this.$router.push({
name: "SearchResults",
params: {
uuid: this.network.uuid,
target: this.channel.name,
term: this.searchInput,
},
});
},
},
};
</script>

View file

@ -0,0 +1,192 @@
<template>
<div id="chat-container" class="window">
<div
id="chat"
:class="{
'colored-nicks': $store.state.settings.coloredNicks,
'show-seconds': $store.state.settings.showSeconds,
}"
>
<div
class="chat-view"
data-type="search-results"
aria-label="Search results"
role="tabpanel"
>
<div class="header">
<SidebarToggle />
<span class="title"
>Search results for "{{ $route.params.term }}" in
{{ $route.params.target }}</span
>
<span class="topic"></span>
<MessageSearchForm :network="network" :channel="channel" />
</div>
<div class="chat-content">
<div ref="chat" class="chat" tabindex="-1">
<div v-show="moreResultsAvailable" class="show-more">
<button
ref="loadMoreButton"
:disabled="
$store.state.messageSearchInProgress ||
!$store.state.isConnected
"
class="btn"
@click="onShowMoreClick"
>
<span v-if="$store.state.messageSearchInProgress">Loading</span>
<span v-else>Show older messages</span>
</button>
</div>
<div v-if="$store.state.messageSearchInProgress" class="search-status">
Searching...
</div>
<div v-else-if="!messages.length" class="search-status">
No results found.
</div>
<div
v-else
class="messages"
role="log"
aria-live="polite"
aria-relevant="additions"
>
<template v-for="(message, id) in messages">
<DateMarker
v-if="shouldDisplayDateMarker(message, id)"
:key="message.time + '-date'"
:message="message"
/>
<Message
:key="message.time"
:channel="channel"
:network="network"
:message="message"
/>
</template>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import socket from "../../js/socket";
import SidebarToggle from "../SidebarToggle.vue";
import Message from "../Message.vue";
import MessageSearchForm from "../MessageSearchForm.vue";
import DateMarker from "../DateMarker.vue";
export default {
name: "SearchResults",
components: {
SidebarToggle,
Message,
DateMarker,
MessageSearchForm,
},
data() {
return {
offset: 0,
moreResultsAvailable: false,
};
},
computed: {
search() {
return this.$store.state.messageSearchResults;
},
messages() {
if (!this.search) {
return [];
}
return this.search.results.slice().reverse();
},
chan() {
if (!this.search) {
return;
}
const chan = this.$store.getters.findChannelOnNetwork(
this.search.networkUuid,
this.search.target
);
return chan;
},
network() {
if (!this.chan) {
return null;
}
return this.chan.network;
},
channel() {
if (!this.chan) {
return null;
}
return this.chan.channel;
},
},
watch: {
"$route.params.uuid"() {
this.doSearch();
},
"$route.params.target"() {
this.doSearch();
},
"$route.params.term"() {
this.doSearch();
},
messages() {
this.moreResultsAvailable = this.messages.length && !(this.messages.length % 100);
this.jumpToBottom();
},
},
mounted() {
this.doSearch();
},
methods: {
shouldDisplayDateMarker(message, id) {
const previousMessage = this.messages[id - 1];
if (!previousMessage) {
return true;
}
return new Date(previousMessage.time).getDay() !== new Date(message.time).getDay();
},
doSearch() {
this.offset = 0;
this.$store.commit("messageSearchInProgress", true);
this.$store.commit("messageSearchResults", null); // Only reset if not getting offset
socket.emit("search", {
networkUuid: this.$route.params.uuid,
channelName: this.$route.params.target,
searchTerm: this.$route.params.term,
offset: this.offset,
});
},
onShowMoreClick() {
this.offset += 100;
this.$store.commit("messageSearchInProgress", true);
socket.emit("search", {
networkUuid: this.$route.params.uuid,
channelName: this.$route.params.target,
searchTerm: this.$route.params.term,
offset: this.offset,
});
},
jumpToBottom() {
this.$nextTick(() => {
const el = this.$refs.chat;
el.scrollTop = el.scrollHeight;
});
},
},
};
</script>

View file

@ -285,6 +285,7 @@ p {
#viewport .rt::before,
#chat button.mentions::before,
#chat button.menu::before,
#chat button.search::before,
.channel-list-item::before,
#footer .icon,
#chat .count::before,
@ -342,6 +343,7 @@ p {
#viewport .rt::before { content: "\f0c0"; /* https://fontawesome.com/icons/users?style=solid */ }
#chat button.menu::before { content: "\f142"; /* http://fontawesome.io/icon/ellipsis-v/ */ }
#chat button.mentions::before { content: "\f1fa"; /* https://fontawesome.com/icons/at?style=solid */ }
#chat button.search::before { content: "\f002"; /* https://fontawesome.com/icons/search?style=solid */ }
.context-menu-join::before { content: "\f067"; /* http://fontawesome.io/icon/plus/ */ }
.context-menu-user::before { content: "\f007"; /* http://fontawesome.io/icon/user/ */ }
@ -574,7 +576,9 @@ p {
#viewport .lt,
#viewport .rt,
<<<<<<< HEAD
#chat button.mentions,
#chat button.search,
#chat button.menu {
color: #607992;
display: flex;
@ -589,6 +593,7 @@ p {
#viewport .lt::before,
#viewport .rt::before,
#chat button.mentions::before,
#chat button.search::before,
#chat button.menu::before {
width: 36px;
line-height: 36px; /* Fix alignment in Microsoft Edge */
@ -2848,3 +2853,72 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
#chat table.channel-list .topic {
white-space: pre-wrap;
}
form.message-search {
display: flex;
}
form.message-search .input-wrapper {
display: flex;
}
form.message-search button {
display: none !important;
}
form.message-search input {
width: 100%;
height: auto !important;
margin: 7px 0;
border: 0;
color: inherit;
background-color: rgba(255, 255, 255, 0.1);
}
form.message-search input::placeholder {
color: rgba(255, 255, 255, 0.35);
}
@media (min-width: 480px) {
form.message-search input {
min-width: 140px;
transition: min-width 0.2s;
}
form.message-search input:focus {
min-width: 220px;
}
}
@media (max-width: 479px) {
form.message-search .input-wrapper {
position: absolute;
top: 45px;
left: 0;
right: 0;
z-index: 1;
height: 0;
transition: height 0.2s;
overflow: hidden;
background: var(--window-bg-color);
}
form.message-search .input-wrapper input {
margin: 7px;
}
form.message-search.opened .input-wrapper {
height: 50px;
}
form.message-search button {
display: flex !important;
}
}
.chat-view[data-type="search-results"] .search-status {
display: flex;
height: 100%;
justify-content: center;
align-items: center;
}

View file

@ -13,6 +13,7 @@ import Settings from "../components/Windows/Settings.vue";
import Help from "../components/Windows/Help.vue";
import Changelog from "../components/Windows/Changelog.vue";
import NetworkEdit from "../components/Windows/NetworkEdit.vue";
import SearchResults from "../components/Windows/SearchResults.vue";
import RoutedChat from "../components/RoutedChat.vue";
import store from "./store";
@ -63,6 +64,11 @@ const router = new VueRouter({
path: "/chan-:id",
component: RoutedChat,
},
{
name: "SearchResults",
path: "/search/:uuid/:target/:term",
component: SearchResults,
},
],
});

View file

@ -25,3 +25,4 @@ import "./changelog";
import "./setting";
import "./history_clear";
import "./mentions";
import "./search";

View file

@ -0,0 +1,7 @@
import socket from "../socket";
import store from "../store";
socket.on("search:results", (response) => {
store.commit("messageSearchInProgress", false);
store.commit("messageSearchResults", response);
});

View file

@ -38,6 +38,8 @@ const store = new Vuex.Store({
versionStatus: "loading",
versionDataExpired: false,
serverHasSettings: false,
messageSearchResults: null,
messageSearchInProgress: false,
},
mutations: {
appLoaded(state) {
@ -112,12 +114,33 @@ const store = new Vuex.Store({
serverHasSettings(state, value) {
state.serverHasSettings = value;
},
messageSearchInProgress(state, value) {
state.messageSearchInProgress = value;
},
messageSearchResults(state, value) {
state.messageSearchResults = value;
},
},
getters: {
findChannelOnCurrentNetwork: (state) => (name) => {
name = name.toLowerCase();
return state.activeChannel.network.channels.find((c) => c.name.toLowerCase() === name);
},
findChannelOnNetwork: (state) => (networkUuid, channelName) => {
for (const network of state.networks) {
if (network.uuid !== networkUuid) {
continue;
}
for (const channel of network.channels) {
if (channel.name === channelName) {
return {network, channel};
}
}
}
return null;
},
findChannel: (state) => (id) => {
for (const network of state.networks) {
for (const channel of network.channels) {

View file

@ -534,6 +534,16 @@ Client.prototype.clearHistory = function (data) {
}
};
Client.prototype.search = function(query) {
if (this.messageStorage) {
for (const storage of this.messageStorage) {
if (storage.database) {
return storage.search(query);
}
}
}
};
Client.prototype.open = function (socketId, target) {
// Due to how socket.io works internally, normal events may arrive later than
// the disconnect event, and because we can't control this timing precisely,

View file

@ -200,9 +200,52 @@ class MessageStorage {
});
}
search(query) {
if (!this.isEnabled) {
return Promise.resolve([]);
}
let select =
'SELECT msg, type, time, channel FROM messages WHERE type = "message" AND network = ? AND (json_extract(msg, "$.text") LIKE ? OR json_extract(msg, "$.from") LIKE ?)';
const params = [query.networkUuid, `%${query.searchTerm}%`, `%${query.searchTerm}%`];
if (query.channelName) {
select += " AND channel = ? ";
params.push(query.channelName);
}
select += " ORDER BY time DESC LIMIT 100 OFFSET ? ";
params.push(query.offset || 0);
return new Promise((resolve, reject) => {
this.database.all(select, params, (err, rows) => {
if (err) {
reject(err);
} else {
const response = {
searchTerm: query.searchTerm,
target: query.channelName,
networkUuid: query.networkUuid,
offset: query.offset,
results: rows.map(parseRowToMessage),
};
resolve(response);
}
});
});
}
canProvideMessages() {
return this.isEnabled;
}
}
module.exports = MessageStorage;
function parseRowToMessage(row) {
const msg = JSON.parse(row.msg);
msg.time = row.time;
msg.type = row.type;
return new Msg(msg);
}

View file

@ -596,6 +596,12 @@ function initializeClient(socket, client, token, lastMessage, openChannel) {
socket.on("sessions:get", sendSessionList);
socket.on("search", (query) => {
client.search(query).then((results) => {
socket.emit("search:results", results);
});
});
if (!Helper.config.public) {
socket.on("setting:set", (newSetting) => {
if (!_.isPlainObject(newSetting)) {