diff --git a/client/components/Chat.vue b/client/components/Chat.vue index 79be4d74..9c761ac0 100644 --- a/client/components/Chat.vue +++ b/client/components/Chat.vue @@ -39,6 +39,14 @@ :network="network" :text="channel.topic" /> + - + @@ -109,6 +122,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,10 +136,12 @@ export default { ChatInput, ChatUserList, SidebarToggle, + MessageSearchForm, }, props: { network: Object, channel: Object, + focused: String, }, computed: { specialComponent() { diff --git a/client/components/DateMarker.vue b/client/components/DateMarker.vue index c8cf5468..4b6fa37c 100644 --- a/client/components/DateMarker.vue +++ b/client/components/DateMarker.vue @@ -17,6 +17,7 @@ export default { name: "DateMarker", props: { message: Object, + focused: Boolean, }, computed: { localeDate() { diff --git a/client/components/Message.vue b/client/components/Message.vue index e931ac46..cff56be9 100644 --- a/client/components/Message.vue +++ b/client/components/Message.vue @@ -3,7 +3,11 @@ :id="'msg-' + message.id" :class="[ 'msg', - {self: message.self, highlight: message.highlight, 'previous-source': isPreviousSource}, + { + self: message.self, + highlight: message.highlight || focused, + 'previous-source': isPreviousSource, + }, ]" :data-type="message.type" :data-command="message.command" @@ -25,9 +29,12 @@ * - + < - + > @@ -55,7 +62,7 @@ - - + - @@ -107,6 +114,7 @@ export default { network: Object, keepScrollPosition: Function, isPreviousSource: Boolean, + focused: Boolean, }, computed: { timeFormat() { diff --git a/client/components/MessageCondensed.vue b/client/components/MessageCondensed.vue index b14e6dd6..279fd8b1 100644 --- a/client/components/MessageCondensed.vue +++ b/client/components/MessageCondensed.vue @@ -30,6 +30,7 @@ export default { network: Object, messages: Array, keepScrollPosition: Function, + focused: Boolean, }, data() { return { diff --git a/client/components/MessageList.vue b/client/components/MessageList.vue index d0dceb31..ca1f18cd 100644 --- a/client/components/MessageList.vue +++ b/client/components/MessageList.vue @@ -23,6 +23,7 @@ v-if="shouldDisplayDateMarker(message, id)" :key="message.id + '-date'" :message="message" + :focused="message.id == focused" /> @@ -75,6 +78,7 @@ export default { props: { network: Object, channel: Object, + focused: String, }, computed: { condensedMessages() { diff --git a/client/components/MessageSearchForm.vue b/client/components/MessageSearchForm.vue new file mode 100644 index 00000000..233e099f --- /dev/null +++ b/client/components/MessageSearchForm.vue @@ -0,0 +1,149 @@ + + + + + + + + + + + + diff --git a/client/components/RoutedChat.vue b/client/components/RoutedChat.vue index b84a2e89..46a79bea 100644 --- a/client/components/RoutedChat.vue +++ b/client/components/RoutedChat.vue @@ -1,5 +1,10 @@ - + diff --git a/client/css/style.css b/client/css/style.css index 8ee13368..4891253b 100644 --- a/client/css/style.css +++ b/client/css/style.css @@ -284,7 +284,9 @@ p { #viewport .lt::before, #viewport .rt::before, #chat button.mentions::before, +#chat button.close::before, #chat button.menu::before, +#chat button.search::before, .channel-list-item::before, #footer .icon, #chat .count::before, @@ -342,6 +344,8 @@ 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 */ } +#chat button.close::before { content: "\f00d"; /* https://fontawesome.com/icons/times?style=solid */ } .context-menu-join::before { content: "\f067"; /* http://fontawesome.io/icon/plus/ */ } .context-menu-user::before { content: "\f007"; /* http://fontawesome.io/icon/user/ */ } @@ -575,7 +579,9 @@ p { #viewport .lt, #viewport .rt, #chat button.mentions, -#chat button.menu { +#chat button.search, +#chat button.menu, +#chat button.close { color: #607992; display: flex; font-size: 14px; @@ -589,7 +595,9 @@ p { #viewport .lt::before, #viewport .rt::before, #chat button.mentions::before, -#chat button.menu::before { +#chat button.search::before, +#chat button.menu::before, +#chat button.close::before { width: 36px; line-height: 36px; /* Fix alignment in Microsoft Edge */ } @@ -1187,6 +1195,7 @@ textarea.input { } #chat .show-more { + margin-top: 50px; padding: 10px; padding-top: 15px; padding-bottom: 0; @@ -2848,3 +2857,10 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */ #chat table.channel-list .topic { white-space: pre-wrap; } + +.chat-view[data-type="search-results"] .search-status { + display: flex; + height: 100%; + justify-content: center; + align-items: center; +} diff --git a/client/js/helpers/contextMenu.js b/client/js/helpers/contextMenu.js index be1faee0..feadd15c 100644 --- a/client/js/helpers/contextMenu.js +++ b/client/js/helpers/contextMenu.js @@ -184,10 +184,12 @@ export function generateChannelContextMenu($root, channel, network) { } export function generateUserContextMenu($root, channel, network, user) { - const currentChannelUser = channel.users.find((u) => u.nick === network.nick) || {}; + const currentChannelUser = channel + ? channel.users.find((u) => u.nick === network.nick) || {} + : {}; const whois = () => { - const chan = $root.$store.getters.findChannelOnCurrentNetwork(user.nick); + const chan = network.channels.find((c) => c.name === user.nick); if (chan) { $root.switchToChannel(chan); diff --git a/client/js/helpers/parse.js b/client/js/helpers/parse.js index 0ea4df2c..9097d96f 100644 --- a/client/js/helpers/parse.js +++ b/client/js/helpers/parse.js @@ -87,6 +87,9 @@ function parse(createElement, text, message = undefined, network = undefined) { const parts = channelParts.concat(linkParts).concat(emojiParts).concat(nameParts); + // The channel the message belongs to might not exist if the user isn't joined to it. + const messageChannel = message ? message.channel : null; + // Merge the styling information with the channels / URLs / nicks / text objects and // generate HTML strings with the resulting fragments return merge(parts, styleFragments, cleanText).map((textPart) => { @@ -184,6 +187,8 @@ function parse(createElement, text, message = undefined, network = undefined) { user: { nick: textPart.nick, }, + channel: messageChannel, + network, }, attrs: { dir: "auto", diff --git a/client/js/router.js b/client/js/router.js index 58381238..a6be4368 100644 --- a/client/js/router.js +++ b/client/js/router.js @@ -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: "/chan-:id/search", + component: SearchResults, + }, ], }); diff --git a/client/js/settings.js b/client/js/settings.js index 3efe060a..403db792 100644 --- a/client/js/settings.js +++ b/client/js/settings.js @@ -109,6 +109,9 @@ export const config = normalizeConfig({ } }, }, + searchEnabled: { + default: false, + }, }); export function createState() { diff --git a/client/js/socket-events/index.js b/client/js/socket-events/index.js index b0b62aa2..ce963d33 100644 --- a/client/js/socket-events/index.js +++ b/client/js/socket-events/index.js @@ -25,3 +25,4 @@ import "./changelog"; import "./setting"; import "./history_clear"; import "./mentions"; +import "./search"; diff --git a/client/js/socket-events/search.js b/client/js/socket-events/search.js new file mode 100644 index 00000000..36cd1e4e --- /dev/null +++ b/client/js/socket-events/search.js @@ -0,0 +1,13 @@ +import socket from "../socket"; +import store from "../store"; + +socket.on("search:results", (response) => { + store.commit("messageSearchInProgress", false); + + if (store.state.messageSearchResults) { + store.commit("addMessageSearchResults", response); + return; + } + + store.commit("messageSearchResults", response); +}); diff --git a/client/js/store.js b/client/js/store.js index 3d336d06..c9508fc8 100644 --- a/client/js/store.js +++ b/client/js/store.js @@ -38,6 +38,9 @@ const store = new Vuex.Store({ versionStatus: "loading", versionDataExpired: false, serverHasSettings: false, + messageSearchResults: null, + messageSearchInProgress: false, + searchEnabled: false, }, mutations: { appLoaded(state) { @@ -112,12 +115,39 @@ const store = new Vuex.Store({ serverHasSettings(state, value) { state.serverHasSettings = value; }, + messageSearchInProgress(state, value) { + state.messageSearchInProgress = value; + }, + messageSearchResults(state, value) { + state.messageSearchResults = value; + }, + addMessageSearchResults(state, value) { + // Append the search results and add networks and channels to new messages + value.results = [...state.messageSearchResults.results, ...value.results]; + + 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) { diff --git a/client/themes/morning.css b/client/themes/morning.css index 0890385f..a0e0dca5 100644 --- a/client/themes/morning.css +++ b/client/themes/morning.css @@ -114,10 +114,20 @@ body { #viewport .rt, #chat button.mentions, #chat button.menu, +#chat button.close, #form #submit { color: #b7c5d1; } +/* Search Form */ +form.message-search input { + background-color: #28333d; +} + +#chat form.message-search button { + color: #b7c5d1; +} + /* Setup text colors */ #chat .msg[data-type="error"], #chat .msg[data-type="error"] .from { diff --git a/src/client.js b/src/client.js index f8b1daac..be37533b 100644 --- a/src/client.js +++ b/src/client.js @@ -63,6 +63,7 @@ function Client(manager, name, config = {}) { messageStorage: [], highlightRegex: null, highlightExceptionRegex: null, + messageProvider: undefined, }); const client = this; @@ -72,7 +73,8 @@ function Client(manager, name, config = {}) { if (!Helper.config.public && client.config.log) { if (Helper.config.messageStorage.includes("sqlite")) { - client.messageStorage.push(new MessageStorage(client)); + client.messageProvider = new MessageStorage(client); + client.messageStorage.push(client.messageProvider); } if (Helper.config.messageStorage.includes("text")) { @@ -111,6 +113,8 @@ function Client(manager, name, config = {}) { client.awayMessage = client.config.clientSettings.awayMessage; } + client.config.clientSettings.searchEnabled = client.messageProvider !== undefined; + client.compileCustomHighlights(); _.forOwn(client.config.sessions, (session) => { @@ -539,6 +543,14 @@ Client.prototype.clearHistory = function (data) { } }; +Client.prototype.search = function (query) { + if (this.messageProvider === undefined) { + return Promise.resolve([]); + } + + return this.messageProvider.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, diff --git a/src/models/chan.js b/src/models/chan.js index b0a4fffd..fe9494fb 100644 --- a/src/models/chan.js +++ b/src/models/chan.js @@ -236,17 +236,11 @@ Chan.prototype.writeUserLog = function (client, msg) { }; Chan.prototype.loadMessages = function (client, network) { - if (!this.isLoggable()) { + if (!this.isLoggable() || !client.messageProvider) { return; } - const messageStorage = client.messageStorage.find((s) => s.canProvideMessages()); - - if (!messageStorage) { - return; - } - - messageStorage + client.messageProvider .getMessages(network, this) .then((messages) => { if (messages.length === 0) { diff --git a/src/models/network.js b/src/models/network.js index bc2dbce1..621f7e1d 100644 --- a/src/models/network.js +++ b/src/models/network.js @@ -189,7 +189,7 @@ Network.prototype.createIrcFramework = function (client) { // Request only new messages from ZNC if we have sqlite logging enabled // See http://wiki.znc.in/Playback - if (client.config.log && client.messageStorage.find((s) => s.canProvideMessages())) { + if (client.messageProvider) { this.irc.requestCap("znc.in/playback"); } }; diff --git a/src/plugins/messageStorage/sqlite.js b/src/plugins/messageStorage/sqlite.js index da26e3b0..cc8ccdf8 100644 --- a/src/plugins/messageStorage/sqlite.js +++ b/src/plugins/messageStorage/sqlite.js @@ -200,9 +200,70 @@ class MessageStorage { }); } + search(query) { + if (!this.isEnabled) { + return Promise.resolve([]); + } + + let select = + 'SELECT msg, type, time, network, channel FROM messages WHERE type = "message" AND json_extract(msg, "$.text") LIKE ?'; + const params = [`%${query.searchTerm}%`]; + + if (query.networkUuid) { + select += " AND network = ? "; + params.push(query.networkUuid); + } + + if (query.channelName) { + select += " AND channel = ? "; + params.push(query.channelName.toLowerCase()); + } + + const maxResults = 100; + + 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) => { + 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: parseSearchRowsToMessages(query.offset, rows), + }; + resolve(response); + } + }); + }); + } + canProvideMessages() { return this.isEnabled; } } module.exports = MessageStorage; + +function parseSearchRowsToMessages(id, rows) { + const messages = []; + + for (const row of rows) { + const msg = JSON.parse(row.msg); + msg.time = row.time; + msg.type = row.type; + msg.networkUuid = row.network; + msg.channelName = row.channel; + msg.id = id; + messages.push(new Msg(msg)); + id += 1; + } + + return messages; +} diff --git a/src/server.js b/src/server.js index 3a796f13..f50ee078 100644 --- a/src/server.js +++ b/src/server.js @@ -643,6 +643,12 @@ function initializeClient(socket, client, token, lastMessage, openChannel) { const clientSettings = client.config.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) => {