Search improvements.

This commit is contained in:
Richard Lewis 2020-03-07 14:56:50 +02:00
parent 88644314ce
commit 9a1fb0c0a0
7 changed files with 451 additions and 91 deletions

View File

@ -20,6 +20,70 @@
</form> </form>
</template> </template>
<style>
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(128, 128, 128, 0.15);
}
form.message-search input::placeholder {
color: rgba(128, 128, 128, 0.4);
}
@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: 768px) {
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;
}
}
</style>
<script> <script>
export default { export default {
name: "MessageSearchForm", name: "MessageSearchForm",

View File

@ -55,14 +55,15 @@
<template v-for="(message, id) in messages"> <template v-for="(message, id) in messages">
<DateMarker <DateMarker
v-if="shouldDisplayDateMarker(message, id)" v-if="shouldDisplayDateMarker(message, id)"
:key="message.time + '-date'" :key="message.date"
:message="message" :message="message"
/> />
<Message <Message
:key="message.time" :key="message.id"
:channel="channel" :channel="channel"
:network="network" :network="network"
:message="message" :message="message"
:data-id="message.id"
/> />
</template> </template>
</div> </div>
@ -178,7 +179,7 @@ export default {
networkUuid: this.$route.params.uuid, networkUuid: this.$route.params.uuid,
channelName: this.$route.params.target, channelName: this.$route.params.target,
searchTerm: this.$route.params.term, searchTerm: this.$route.params.term,
offset: this.offset, offset: this.offset + 1,
}); });
}, },
jumpToBottom() { jumpToBottom() {

View File

@ -553,7 +553,6 @@ p {
#viewport .lt, #viewport .lt,
#viewport .rt, #viewport .rt,
<<<<<<< HEAD
#chat button.mentions, #chat button.mentions,
#chat button.search, #chat button.search,
#chat button.menu { #chat button.menu {
@ -2823,68 +2822,6 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
white-space: pre-wrap; 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 { .chat-view[data-type="search-results"] .search-status {
display: flex; display: flex;
height: 100%; height: 100%;

View File

@ -0,0 +1,340 @@
/*
"use strict";
const constants = require("./constants");
import Mousetrap from "mousetrap";
import {Textcomplete, Textarea} from "textcomplete";
import fuzzy from "fuzzy";
import emojiMap from "./helpers/simplemap.json";
import store from "./store";
export default enableAutocomplete;
export default class CustomTextarea extends Textarea {
}
const emojiSearchTerms = Object.keys(emojiMap);
const emojiStrategy = {
id: "emoji",
match: /(^|\s):([-+\w:?]{2,}):?$/,
search(term, callback) {
// Trim colon from the matched term,
// as we are unable to get a clean string from match regex
term = term.replace(/:$/, "");
callback(fuzzyGrep(term, emojiSearchTerms));
},
template([string, original]) {
return `<span class="emoji">${emojiMap[original]}</span> ${string}`;
},
replace([, original]) {
return "$1" + emojiMap[original];
},
index: 2,
};
const nicksStrategy = {
id: "nicks",
match: /(^|\s)(@([a-zA-Z_[\]\\^{}|`@][a-zA-Z0-9_[\]\\^{}|`-]*)?)$/,
search(term, callback) {
term = term.slice(1);
if (term[0] === "@") {
callback(completeNicks(term.slice(1), true).map((val) => ["@" + val[0], "@" + val[1]]));
} else {
callback(completeNicks(term, true));
}
},
template([string]) {
return string;
},
replace([, original]) {
return "$1" + replaceNick(original);
},
index: 2,
};
const chanStrategy = {
id: "chans",
match: /(^|\s)((?:#|\+|&|![A-Z0-9]{5})(?:[^\s]+)?)$/,
search(term, callback) {
callback(completeChans(term));
},
template([string]) {
return string;
},
replace([, original]) {
return "$1" + original;
},
index: 2,
};
const commandStrategy = {
id: "commands",
match: /^\/(\w*)$/,
search(term, callback) {
callback(completeCommands("/" + term));
},
template([string]) {
return string;
},
replace([, original]) {
return original;
},
index: 1,
};
const foregroundColorStrategy = {
id: "foreground-colors",
match: /\x03(\d{0,2}|[A-Za-z ]{0,10})$/,
search(term, callback) {
term = term.toLowerCase();
const matchingColorCodes = constants.colorCodeMap
.filter((i) => fuzzy.test(term, i[0]) || fuzzy.test(term, i[1]))
.map((i) => {
if (fuzzy.test(term, i[1])) {
return [
i[0],
fuzzy.match(term, i[1], {
pre: "<b>",
post: "</b>",
}).rendered,
];
}
return i;
});
callback(matchingColorCodes);
},
template(value) {
return `<span class="irc-fg${parseInt(value[0], 10)}">${value[1]}</span>`;
},
replace(value) {
return "\x03" + value[0];
},
index: 1,
};
const backgroundColorStrategy = {
id: "background-colors",
match: /\x03(\d{2}),(\d{0,2}|[A-Za-z ]{0,10})$/,
search(term, callback, match) {
term = term.toLowerCase();
const matchingColorCodes = constants.colorCodeMap
.filter((i) => fuzzy.test(term, i[0]) || fuzzy.test(term, i[1]))
.map((pair) => {
if (fuzzy.test(term, pair[1])) {
return [
pair[0],
fuzzy.match(term, pair[1], {
pre: "<b>",
post: "</b>",
}).rendered,
];
}
return pair;
})
.map((pair) => pair.concat(match[1])); // Needed to pass fg color to `template`...
callback(matchingColorCodes);
},
template(value) {
return `<span class="irc-fg${parseInt(value[2], 10)} irc-bg irc-bg${parseInt(
value[0],
10
)}">${value[1]}</span>`;
},
replace(value) {
return "\x03$1," + value[0];
},
index: 2,
};
function enableAutocomplete(input,) {
let tabCount = 0;
let lastMatch = "";
let currentMatches = [];
input.addEventListener("input", (e) => {
if (e.detail === "autocomplete") {
return;
}
tabCount = 0;
currentMatches = [];
lastMatch = "";
});
Mousetrap(input).bind(
"tab",
(e) => {
if (store.state.isAutoCompleting) {
return;
}
e.preventDefault();
const text = input.value;
if (tabCount === 0) {
lastMatch = text
.substring(0, input.selectionStart)
.split(/\s/)
.pop();
if (lastMatch.length === 0) {
return;
}
currentMatches = completeNicks(lastMatch, false);
if (currentMatches.length === 0) {
return;
}
}
const position = input.selectionStart - lastMatch.length;
const newMatch = replaceNick(
currentMatches[tabCount % currentMatches.length],
position
);
const remainder = text.substr(input.selectionStart);
input.value = text.substr(0, position) + newMatch + remainder;
input.selectionStart -= remainder.length;
input.selectionEnd = input.selectionStart;
// Propagate change to Vue model
input.dispatchEvent(
new CustomEvent("input", {
detail: "autocomplete",
})
);
lastMatch = newMatch;
tabCount++;
},
"keydown"
);
const editor = new CustomTextarea(input);
const textcomplete = new Textcomplete(editor, {
dropdown: {
className: "textcomplete-menu",
placement: "top",
},
});
textcomplete.register([
emojiStrategy,
nicksStrategy,
chanStrategy,
commandStrategy,
foregroundColorStrategy,
backgroundColorStrategy,
]);
// Activate the first item by default
// https://github.com/yuku-t/textcomplete/issues/93
textcomplete.on("rendered", () => {
if (textcomplete.dropdown.items.length > 0) {
textcomplete.dropdown.items[0].activate();
}
});
textcomplete.on("show", () => {
store.commit("isAutoCompleting", true);
});
textcomplete.on("hidden", () => {
store.commit("isAutoCompleting", false);
});
return {
hide() {
textcomplete.hide();
},
destroy() {
textcomplete.destroy();
store.commit("isAutoCompleting", false);
},
};
}
function replaceNick(original, position = 1) {
// If no postfix specified, return autocompleted nick as-is
if (!store.state.settings.nickPostfix) {
return original;
}
// If there is whitespace in the input already, append space to nick
if (position > 0 && /\s/.test(store.state.activeChannel.channel.pendingMessage)) {
return original + " ";
}
// If nick is first in the input, append specified postfix
return original + store.state.settings.nickPostfix;
}
function fuzzyGrep(term, array) {
const results = fuzzy.filter(term, array, {
pre: "<b>",
post: "</b>",
});
return results.map((el) => [el.string, el.original]);
}
function rawNicks() {
if (store.state.activeChannel.channel.users.length > 0) {
const users = store.state.activeChannel.channel.users.slice();
return users.sort((a, b) => b.lastMessage - a.lastMessage).map((u) => u.nick);
}
const me = store.state.activeChannel.network.nick;
const otherUser = store.state.activeChannel.channel.name;
// If this is a query, add their name to autocomplete
if (me !== otherUser && store.state.activeChannel.channel.type === "query") {
return [otherUser, me];
}
// Return our own name by default for anything that isn't a channel or query
return [me];
}
function completeNicks(word, isFuzzy) {
const users = rawNicks();
word = word.toLowerCase();
if (isFuzzy) {
return fuzzyGrep(word, users);
}
return users.filter((w) => !w.toLowerCase().indexOf(word));
}
function completeCommands(word) {
const words = constants.commands.slice();
return fuzzyGrep(word, words);
}
function completeChans(word) {
const words = [];
for (const channel of store.state.activeChannel.network.channels) {
// Push all channels that start with the same CHANTYPE
if (channel.type === "channel" && channel.name[0] === word[0]) {
words.push(channel.name);
}
}
return fuzzyGrep(word, words);
}
*/

View File

@ -529,13 +529,8 @@ Client.prototype.clearHistory = function (data) {
}; };
Client.prototype.search = function (query) { Client.prototype.search = function (query) {
if (this.messageStorage) { const messageStorage = this.messageStorage.find((s) => s.canProvideMessages());
for (const storage of this.messageStorage) { return messageStorage.search(query);
if (storage.database) {
return storage.search(query);
}
}
}
}; };
Client.prototype.open = function (socketId, target) { Client.prototype.open = function (socketId, target) {

View File

@ -208,16 +208,32 @@ class MessageStorage {
} }
let select = let select =
'SELECT msg, type, time, channel FROM messages WHERE type = "message" AND network = ? AND json_extract(msg, "$.text") LIKE ?'; 'SELECT msg, type, time, channel FROM messages WHERE type = "message" AND (json_extract(msg, "$.text") LIKE ?';
const params = [query.networkUuid, `%${query.searchTerm}%`]; const params = [`%${query.searchTerm}%`];
if (query.searchNicks) {
select += ' OR json_extract(msg, "$.from.nick") LIKE ?)';
params.push(query.searchTerm);
} else {
select += ")";
}
if (query.networkUuid) {
select += " AND network = ? ";
params.push(query.networkUuid);
}
if (query.channelName) { if (query.channelName) {
select += " AND channel = ? "; select += " AND channel = ? ";
params.push(query.channelName); params.push(query.channelName);
} }
select += " ORDER BY time DESC LIMIT 100 OFFSET ? "; const maxResults = 100;
params.push(query.offset || 0);
select += " ORDER BY time DESC LIMIT ? OFFSET ? ";
params.push(maxResults);
query.offset = parseInt(query.offset, 10) || 0;
params.push(query.offset);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.database.all(select, params, (err, rows) => { this.database.all(select, params, (err, rows) => {
@ -229,7 +245,7 @@ class MessageStorage {
target: query.channelName, target: query.channelName,
networkUuid: query.networkUuid, networkUuid: query.networkUuid,
offset: query.offset, offset: query.offset,
results: rows.map(parseRowToMessage), results: parseRowsToMessages(query.offset, rows),
}; };
resolve(response); resolve(response);
} }
@ -244,10 +260,17 @@ class MessageStorage {
module.exports = MessageStorage; module.exports = MessageStorage;
function parseRowToMessage(row) { function parseRowsToMessages(id, rows) {
const messages = [];
for (const row of rows) {
const msg = JSON.parse(row.msg); const msg = JSON.parse(row.msg);
msg.time = row.time; msg.time = row.time;
msg.type = row.type; msg.type = row.type;
msg.id = id;
return new Msg(msg); messages.push(new Msg(msg));
id += 1;
}
return messages;
} }

View File

@ -592,12 +592,6 @@ function initializeClient(socket, client, token, lastMessage, openChannel) {
socket.on("sessions:get", sendSessionList); socket.on("sessions:get", sendSessionList);
socket.on("search", (query) => {
client.search(query).then((results) => {
socket.emit("search:results", results);
});
});
if (!Helper.config.public) { if (!Helper.config.public) {
socket.on("setting:set", (newSetting) => { socket.on("setting:set", (newSetting) => {
if (!newSetting || typeof newSetting !== "object") { if (!newSetting || typeof newSetting !== "object") {
@ -645,6 +639,12 @@ function initializeClient(socket, client, token, lastMessage, openChannel) {
const clientSettings = client.config.clientSettings; const clientSettings = client.config.clientSettings;
socket.emit("setting:all", clientSettings); socket.emit("setting:all", clientSettings);
}); });
socket.on("search", (query) => {
client.search(query).then((results) => {
socket.emit("search:results", results);
});
});
} }
socket.on("sign-out", (tokenToSignOut) => { socket.on("sign-out", (tokenToSignOut) => {