Message search WIP.
This commit is contained in:
parent
4ac25d4bc5
commit
0f3c292098
@ -38,6 +38,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"
|
||||
@ -107,6 +112,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";
|
||||
@ -120,6 +126,7 @@ export default {
|
||||
ChatInput,
|
||||
ChatUserList,
|
||||
SidebarToggle,
|
||||
MessageSearchForm,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
|
71
client/components/MessageSearchForm.vue
Normal file
71
client/components/MessageSearchForm.vue
Normal 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>
|
192
client/components/Windows/SearchResults.vue
Normal file
192
client/components/Windows/SearchResults.vue
Normal 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>
|
@ -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,
|
||||
@ -339,6 +340,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/ */ }
|
||||
@ -551,7 +553,9 @@ p {
|
||||
|
||||
#viewport .lt,
|
||||
#viewport .rt,
|
||||
<<<<<<< HEAD
|
||||
#chat button.mentions,
|
||||
#chat button.search,
|
||||
#chat button.menu {
|
||||
color: #607992;
|
||||
display: flex;
|
||||
@ -566,6 +570,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 */
|
||||
@ -2817,3 +2822,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;
|
||||
}
|
||||
|
@ -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";
|
||||
|
||||
@ -124,6 +125,11 @@ function initialize() {
|
||||
path: "/chan-:id",
|
||||
component: RoutedChat,
|
||||
},
|
||||
{
|
||||
name: "SearchResults",
|
||||
path: "/search/:uuid/:target/:term",
|
||||
component: SearchResults,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
|
@ -25,3 +25,4 @@ import "./changelog";
|
||||
import "./setting";
|
||||
import "./history_clear";
|
||||
import "./mentions";
|
||||
import "./search";
|
||||
|
7
client/js/socket-events/search.js
Normal file
7
client/js/socket-events/search.js
Normal 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);
|
||||
});
|
@ -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) {
|
||||
|
@ -528,6 +528,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,
|
||||
|
@ -202,9 +202,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);
|
||||
}
|
||||
|
@ -592,6 +592,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 (!newSetting || typeof newSetting !== "object") {
|
||||
|
Loading…
Reference in New Issue
Block a user