Merge branch 'master' into fix-search-query

This commit is contained in:
Max Leiter 2021-11-15 12:42:56 -08:00
commit 3cec329e3b
No known key found for this signature in database
GPG Key ID: A3512F2F2F17EBDA
50 changed files with 1428 additions and 674 deletions

View File

@ -9,10 +9,6 @@ jobs:
strategy: strategy:
matrix: matrix:
include: include:
# EOL: April 2021
- os: ubuntu-latest
node_version: 10.x
# EOL: April 2022 # EOL: April 2022
- os: ubuntu-latest - os: ubuntu-latest
node_version: 12.x node_version: 12.x
@ -20,15 +16,19 @@ jobs:
# EOL: April 2023 # EOL: April 2023
- os: ubuntu-latest - os: ubuntu-latest
node_version: 14.x node_version: 14.x
- os: macOS-latest
node_version: 14.x
- os: windows-latest
node_version: 14.x
# EOL: June 2021 # EOL: June 2021
- os: ubuntu-latest - os: ubuntu-latest
node_version: 15.x node_version: 15.x
# EOL: April 2024
- os: ubuntu-latest
node_version: 16.x
- os: macOS-latest
node_version: 16.x
- os: windows-latest
node_version: 16.x
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:

View File

@ -4,6 +4,36 @@ All notable changes to this project will be documented in this file.
<!-- New entries go after this line --> <!-- New entries go after this line -->
## v4.3.0-pre.6 - 2021-11-04 [Pre-release]
[See the full changelog](https://github.com/thelounge/thelounge/compare/v4.3.0-pre.5...v4.3.0-pre.6)
This is a pre-release for v4.3.0 to offer latest changes without having to wait for a stable release.
At this stage, features may still be added or modified until the first release candidate for this version gets released.
Please refer to the commit list given above for a complete list of changes, or wait for the stable release to get a thoroughly prepared change log entry.
As with all pre-releases, this version requires explicit use of the `next` tag to be installed:
```sh
yarn global add thelounge@next
```
## v4.3.0-pre.5 - 2021-11-03 [Pre-release]
[See the full changelog](https://github.com/thelounge/thelounge/compare/v4.3.0-pre.4...v4.3.0-pre.5)
This is a pre-release for v4.3.0 to offer latest changes without having to wait for a stable release.
At this stage, features may still be added or modified until the first release candidate for this version gets released.
Please refer to the commit list given above for a complete list of changes, or wait for the stable release to get a thoroughly prepared change log entry.
As with all pre-releases, this version requires explicit use of the `next` tag to be installed:
```sh
yarn global add thelounge@next
```
## v4.3.0-pre.4 - 2021-07-01 [Pre-release] ## v4.3.0-pre.4 - 2021-07-01 [Pre-release]
[See the full changelog](https://github.com/thelounge/thelounge/compare/v4.3.0-pre.3...v4.3.0-pre.4) [See the full changelog](https://github.com/thelounge/thelounge/compare/v4.3.0-pre.3...v4.3.0-pre.4)

View File

@ -35,7 +35,11 @@
<span type="button" aria-label="Save topic"></span> <span type="button" aria-label="Save topic"></span>
</span> </span>
</div> </div>
<span v-else :title="channel.topic" class="topic" @dblclick="editTopic" <span
v-else
:title="channel.topic"
:class="{topic: true, empty: !channel.topic}"
@dblclick="editTopic"
><ParsedMessage ><ParsedMessage
v-if="channel.topic" v-if="channel.topic"
:network="network" :network="network"

View File

@ -140,22 +140,53 @@ export default {
return; return;
} }
const oldValue = this.$refs.input.value;
const oldPosition = this.$refs.input.selectionStart;
const onRow = (oldValue.slice(null, oldPosition).match(/\n/g) || []).length;
const totalRows = (oldValue.match(/\n/g) || []).length;
const {channel} = this; const {channel} = this;
if (channel.inputHistoryPosition === 0) { if (channel.inputHistoryPosition === 0) {
channel.inputHistory[channel.inputHistoryPosition] = channel.pendingMessage; channel.inputHistory[channel.inputHistoryPosition] = channel.pendingMessage;
} }
if (key === "up") { if (key === "up" && onRow === 0) {
if (channel.inputHistoryPosition < channel.inputHistory.length - 1) { if (channel.inputHistoryPosition < channel.inputHistory.length - 1) {
channel.inputHistoryPosition++; channel.inputHistoryPosition++;
} else {
return;
} }
} else if (channel.inputHistoryPosition > 0) { } else if (key === "down" && channel.inputHistoryPosition > 0 && onRow === totalRows) {
channel.inputHistoryPosition--; channel.inputHistoryPosition--;
} else {
return;
} }
channel.pendingMessage = channel.inputHistory[channel.inputHistoryPosition]; channel.pendingMessage = channel.inputHistory[channel.inputHistoryPosition];
this.$refs.input.value = channel.pendingMessage; const newValue = channel.pendingMessage;
this.$refs.input.value = newValue;
let newPosition;
if (key === "up") {
const lastIndexOfNewLine = newValue.lastIndexOf("\n");
const lastLine = newValue.slice(null, lastIndexOfNewLine);
newPosition =
oldPosition > lastLine.length
? newValue.length
: lastIndexOfNewLine + oldPosition + 1;
} else {
const lastPositionOnFirstLine =
newValue.indexOf("\n") === -1 ? newValue.length + 1 : newValue.indexOf("\n");
const relativeRowPos = oldPosition - oldValue.lastIndexOf("\n") - 1;
newPosition =
relativeRowPos > lastPositionOnFirstLine
? lastPositionOnFirstLine
: relativeRowPos;
}
this.$refs.input.setSelectionRange(newPosition, newPosition);
this.setInputSize(); this.setInputSize();
return false; return false;

View File

@ -2,6 +2,7 @@
<div <div
v-if="isOpen" v-if="isOpen"
id="context-menu-container" id="context-menu-container"
:class="{passthrough}"
@click="containerClick" @click="containerClick"
@contextmenu.prevent="containerClick" @contextmenu.prevent="containerClick"
@keydown.exact.up.prevent="navigateMenu(-1)" @keydown.exact.up.prevent="navigateMenu(-1)"
@ -49,6 +50,7 @@ export default {
data() { data() {
return { return {
isOpen: false, isOpen: false,
passthrough: false,
previousActiveElement: null, previousActiveElement: null,
items: [], items: [],
activeItem: -1, activeItem: -1,
@ -60,18 +62,35 @@ export default {
}, },
mounted() { mounted() {
eventbus.on("escapekey", this.close); eventbus.on("escapekey", this.close);
eventbus.on("contextmenu:cancel", this.close);
eventbus.on("contextmenu:user", this.openUserContextMenu); eventbus.on("contextmenu:user", this.openUserContextMenu);
eventbus.on("contextmenu:channel", this.openChannelContextMenu); eventbus.on("contextmenu:channel", this.openChannelContextMenu);
}, },
destroyed() { destroyed() {
eventbus.off("escapekey", this.close); eventbus.off("escapekey", this.close);
eventbus.off("contextmenu:cancel", this.close);
eventbus.off("contextmenu:user", this.openUserContextMenu); eventbus.off("contextmenu:user", this.openUserContextMenu);
eventbus.off("contextmenu:channel", this.openChannelContextMenu); eventbus.off("contextmenu:channel", this.openChannelContextMenu);
this.close(); this.close();
}, },
methods: { methods: {
enablePointerEvents() {
this.passthrough = false;
document.body.removeEventListener("pointerup", this.enablePointerEvents, {
passive: true,
});
},
openChannelContextMenu(data) { openChannelContextMenu(data) {
if (data.event.type === "contextmenu") {
// Pass through all pointer events to allow the network list's
// dragging events to continue triggering.
this.passthrough = true;
document.body.addEventListener("pointerup", this.enablePointerEvents, {
passive: true,
});
}
const items = generateChannelContextMenu(this.$root, data.channel, data.network); const items = generateChannelContextMenu(this.$root, data.channel, data.network);
this.open(data.event, items); this.open(data.event, items);
}, },

View File

@ -10,10 +10,10 @@
Recent mentions Recent mentions
<button <button
v-if="resolvedMessages.length" v-if="resolvedMessages.length"
class="btn hide-all-mentions" class="btn dismiss-all-mentions"
@click="hideAllMentions()" @click="dismissAllMentions()"
> >
Hide all Dismiss all
</button> </button>
</div> </div>
<template v-if="resolvedMessages.length === 0"> <template v-if="resolvedMessages.length === 0">
@ -37,11 +37,14 @@
</span> </span>
</div> </div>
<div> <div>
<span class="close-tooltip tooltipped tooltipped-w" aria-label="Close"> <span
class="close-tooltip tooltipped tooltipped-w"
aria-label="Dismiss this mention"
>
<button <button
class="msg-hide" class="msg-dismiss"
aria-label="Hide this mention" aria-label="Dismiss this mention"
@click="hideMention(message)" @click="dismissMention(message)"
></button> ></button>
</span> </span>
</div> </div>
@ -102,7 +105,7 @@
word-break: break-word; /* Webkit-specific */ word-break: break-word; /* Webkit-specific */
} }
.mentions-popup .msg-hide::before { .mentions-popup .msg-dismiss::before {
font-size: 20px; font-size: 20px;
font-weight: normal; font-weight: normal;
display: inline-block; display: inline-block;
@ -111,11 +114,11 @@
content: "×"; content: "×";
} }
.mentions-popup .msg-hide:hover { .mentions-popup .msg-dismiss:hover {
color: var(--link-color); color: var(--link-color);
} }
.mentions-popup .hide-all-mentions { .mentions-popup .dismiss-all-mentions {
margin: 0; margin: 0;
padding: 4px 6px; padding: 4px 6px;
} }
@ -191,17 +194,17 @@ export default {
messageTime(time) { messageTime(time) {
return dayjs(time).fromNow(); return dayjs(time).fromNow();
}, },
hideMention(message) { dismissMention(message) {
this.$store.state.mentions.splice( this.$store.state.mentions.splice(
this.$store.state.mentions.findIndex((m) => m.msgId === message.msgId), this.$store.state.mentions.findIndex((m) => m.msgId === message.msgId),
1 1
); );
socket.emit("mentions:hide", message.msgId); socket.emit("mentions:dismiss", message.msgId);
}, },
hideAllMentions() { dismissAllMentions() {
this.$store.state.mentions = []; this.$store.state.mentions = [];
socket.emit("mentions:hide_all"); socket.emit("mentions:dismiss_all");
}, },
containerClick(event) { containerClick(event) {
if (event.currentTarget === event.target) { if (event.currentTarget === event.target) {

View File

@ -138,7 +138,15 @@ export default {
} }
} }
return condensed; return condensed.map((message) => {
// Skip condensing single messages, it doesn't save any
// space but makes useful information harder to see
if (message.type === "condensed" && message.messages.length === 1) {
return message.messages[0];
}
return message;
});
}, },
}, },
watch: { watch: {

View File

@ -37,6 +37,7 @@ form.message-search input {
border: 0; border: 0;
color: inherit; color: inherit;
background-color: #fafafa; background-color: #fafafa;
appearance: none;
} }
form.message-search input::placeholder { form.message-search input::placeholder {

View File

@ -166,7 +166,7 @@
class="input" class="input"
:type="slotProps.isVisible ? 'text' : 'password'" :type="slotProps.isVisible ? 'text' : 'password'"
placeholder="Proxy password" placeholder="Proxy password"
name="password" name="proxyPassword"
maxlength="300" maxlength="300"
/> />
</RevealPassword> </RevealPassword>

View File

@ -56,17 +56,18 @@
<Draggable <Draggable
v-else v-else
:list="$store.state.networks" :list="$store.state.networks"
:filter="isCurrentlyInTouch" :delay="LONG_TOUCH_DURATION"
:prevent-on-filter="false" :delay-on-touch-only="true"
:touch-start-threshold="10"
handle=".channel-list-item[data-type='lobby']" handle=".channel-list-item[data-type='lobby']"
draggable=".network" draggable=".network"
ghost-class="ui-sortable-ghost" ghost-class="ui-sortable-ghost"
drag-class="ui-sortable-dragged" drag-class="ui-sortable-dragging"
group="networks" group="networks"
class="networks" class="networks"
@change="onNetworkSort" @change="onNetworkSort"
@start="onDragStart" @choose="onDraggableChoose"
@end="onDragEnd" @unchoose="onDraggableUnchoose"
> >
<div <div
v-for="network in $store.state.networks" v-for="network in $store.state.networks"
@ -80,6 +81,10 @@
class="network" class="network"
role="region" role="region"
aria-live="polite" aria-live="polite"
@touchstart="onDraggableTouchStart"
@touchmove="onDraggableTouchMove"
@touchend="onDraggableTouchEnd"
@touchcancel="onDraggableTouchEnd"
> >
<NetworkLobby <NetworkLobby
:network="network" :network="network"
@ -100,15 +105,16 @@
<Draggable <Draggable
draggable=".channel-list-item" draggable=".channel-list-item"
ghost-class="ui-sortable-ghost" ghost-class="ui-sortable-ghost"
drag-class="ui-sortable-dragged" drag-class="ui-sortable-dragging"
:group="network.uuid" :group="network.uuid"
:filter="isCurrentlyInTouch"
:prevent-on-filter="false"
:list="network.channels" :list="network.channels"
:delay="LONG_TOUCH_DURATION"
:delay-on-touch-only="true"
:touch-start-threshold="10"
class="channels" class="channels"
@change="onChannelSort" @change="onChannelSort"
@start="onDragStart" @choose="onDraggableChoose"
@end="onDragEnd" @unchoose="onDraggableUnchoose"
> >
<template v-for="(channel, index) in network.channels"> <template v-for="(channel, index) in network.channels">
<Channel <Channel
@ -141,6 +147,7 @@
color: #fff; color: #fff;
background-color: rgba(255, 255, 255, 0.1); background-color: rgba(255, 255, 255, 0.1);
padding-right: 35px; padding-right: 35px;
appearance: none;
} }
.jump-to-input .input::placeholder { .jump-to-input .input::placeholder {
@ -199,6 +206,8 @@ import JoinChannel from "./JoinChannel.vue";
import socket from "../js/socket"; import socket from "../js/socket";
import collapseNetwork from "../js/helpers/collapseNetwork"; import collapseNetwork from "../js/helpers/collapseNetwork";
import isIgnoredKeybind from "../js/helpers/isIgnoredKeybind"; import isIgnoredKeybind from "../js/helpers/isIgnoredKeybind";
import distance from "../js/helpers/distance";
import eventbus from "../js/eventbus";
export default { export default {
name: "NetworkList", name: "NetworkList",
@ -246,6 +255,10 @@ export default {
this.setActiveSearchItem(); this.setActiveSearchItem();
}, },
}, },
created() {
// Number of milliseconds a touch has to last to be considered long
this.LONG_TOUCH_DURATION = 500;
},
mounted() { mounted() {
Mousetrap.bind("alt+shift+right", this.expandNetwork); Mousetrap.bind("alt+shift+right", this.expandNetwork);
Mousetrap.bind("alt+shift+left", this.collapseNetwork); Mousetrap.bind("alt+shift+left", this.collapseNetwork);
@ -279,16 +292,6 @@ export default {
return false; return false;
}, },
isCurrentlyInTouch(e) {
// TODO: Implement a way to sort on touch devices
return e.pointerType !== "mouse";
},
onDragStart(e) {
e.target.classList.add("ui-sortable-active");
},
onDragEnd(e) {
e.target.classList.remove("ui-sortable-active");
},
onNetworkSort(e) { onNetworkSort(e) {
if (!e.moved) { if (!e.moved) {
return; return;
@ -316,6 +319,59 @@ export default {
order: channel.network.channels.map((c) => c.id), order: channel.network.channels.map((c) => c.id),
}); });
}, },
isTouchEvent(event) {
// This is the same way Sortable.js detects a touch event. See
// SortableJS/Sortable@daaefeda:/src/Sortable.js#L465
return (
(event.touches && event.touches[0]) ||
(event.pointerType && event.pointerType === "touch")
);
},
onDraggableChoose(event) {
const original = event.originalEvent;
if (this.isTouchEvent(original)) {
// onDrag is only triggered when the user actually moves the
// dragged object but onChoose is triggered as soon as the
// item is eligible for dragging. This gives us an opportunity
// to tell the user they've held the touch long enough.
event.item.classList.add("ui-sortable-dragging-touch-cue");
if (original instanceof TouchEvent && original.touches.length > 0) {
this.startDrag = [original.touches[0].clientX, original.touches[0].clientY];
} else if (original instanceof PointerEvent) {
this.startDrag = [original.clientX, original.clientY];
}
}
},
onDraggableUnchoose(event) {
event.item.classList.remove("ui-sortable-dragging-touch-cue");
this.startDrag = null;
},
onDraggableTouchStart() {
if (event.touches.length === 1) {
// This prevents an iOS long touch default behavior: selecting
// the nearest selectable text.
document.body.classList.add("force-no-select");
}
},
onDraggableTouchMove(event) {
if (this.startDrag && event.touches.length > 0) {
const touch = event.touches[0];
const currentPosition = [touch.clientX, touch.clientY];
if (distance(this.startDrag, currentPosition) > 10) {
// Context menu is shown on Android after long touch.
// Dismiss it now that we're sure the user is dragging.
eventbus.emit("contextmenu:cancel");
}
}
},
onDraggableTouchEnd(event) {
if (event.touches.length === 0) {
document.body.classList.remove("force-no-select");
}
},
toggleSearch(event) { toggleSearch(event) {
if (isIgnoredKeybind(event)) { if (isIgnoredKeybind(event)) {
return true; return true;

View File

@ -195,6 +195,9 @@ export default {
document.body.addEventListener("touchstart", this.onTouchStart, {passive: true}); document.body.addEventListener("touchstart", this.onTouchStart, {passive: true});
}, },
destroyed() {
document.body.removeEventListener("touchstart", this.onTouchStart, {passive: true});
},
methods: { methods: {
isPublic: () => document.body.classList.contains("public"), isPublic: () => document.body.classList.contains("public"),
}, },

View File

@ -87,6 +87,36 @@
</p> </p>
</div> </div>
<h2 v-if="isTouch">Gestures</h2>
<div v-if="isTouch" class="help-item">
<div class="subject gesture">Single-Finger Swipe Left</div>
<div class="description">
<p>Hide sidebar.</p>
</div>
</div>
<div v-if="isTouch" class="help-item">
<div class="subject gesture">Single-Finger Swipe Right</div>
<div class="description">
<p>Show sidebar.</p>
</div>
</div>
<div v-if="isTouch" class="help-item">
<div class="subject gesture">Two-Finger Swipe Left</div>
<div class="description">
<p>Switch to the next window in the channel list.</p>
</div>
</div>
<div v-if="isTouch" class="help-item">
<div class="subject gesture">Two-Finger Swipe Right</div>
<div class="description">
<p>Switch to the previous window in the channel list.</p>
</div>
</div>
<h2>Keyboard Shortcuts</h2> <h2>Keyboard Shortcuts</h2>
<div class="help-item"> <div class="help-item">
@ -199,6 +229,16 @@
</div> </div>
</div> </div>
<div class="help-item">
<div class="subject">
<span v-if="!isApple"><kbd>Alt</kbd> <kbd>/</kbd></span>
<span v-else><kbd></kbd> <kbd>/</kbd></span>
</div>
<div class="description">
<p>Switch to the help menu.</p>
</div>
</div>
<div class="help-item"> <div class="help-item">
<div class="subject"> <div class="subject">
<span><kbd>Esc</kbd></span> <span><kbd>Esc</kbd></span>
@ -764,6 +804,7 @@ export default {
data() { data() {
return { return {
isApple: navigator.platform.match(/(Mac|iPhone|iPod|iPad)/i) || false, isApple: navigator.platform.match(/(Mac|iPhone|iPod|iPad)/i) || false,
isTouch: navigator.maxTouchPoints > 0,
}; };
}, },
}; };

View File

@ -124,7 +124,7 @@ export default {
return []; return [];
} }
return this.search.results.slice().reverse(); return this.search.results;
}, },
chan() { chan() {
const chanId = parseInt(this.$route.params.id, 10); const chanId = parseInt(this.$route.params.id, 10);

View File

@ -248,11 +248,18 @@
<div> <div>
<label class="opt"> <label class="opt">
<input <input
:checked="$store.state.settings.removeImageMetadata" :checked="$store.state.settings.uploadCanvas"
type="checkbox" type="checkbox"
name="removeImageMetadata" name="uploadCanvas"
/> />
Remove metadata from uploaded images Attempt to remove metadata from images before uploading
<span
class="tooltipped tooltipped-n tooltipped-no-delay"
aria-label="This option renders the image into a canvas element to remove metadata from the image.
This may break orientation if your browser does not support that."
>
<button class="extra-help" />
</span>
</label> </label>
</div> </div>
</div> </div>
@ -306,6 +313,7 @@
<input <input
id="desktopNotifications" id="desktopNotifications"
:checked="$store.state.settings.desktopNotifications" :checked="$store.state.settings.desktopNotifications"
:disabled="$store.state.desktopNotificationState === 'nohttps'"
type="checkbox" type="checkbox"
name="desktopNotifications" name="desktopNotifications"
/> />
@ -316,6 +324,14 @@
> >
<strong>Warning</strong>: Notifications are not supported by your browser. <strong>Warning</strong>: Notifications are not supported by your browser.
</div> </div>
<div
v-if="$store.state.desktopNotificationState === 'nohttps'"
id="warnBlockedDesktopNotifications"
class="error"
>
<strong>Warning</strong>: Notifications are only supported over HTTPS
connections.
</div>
<div <div
v-if="$store.state.desktopNotificationState === 'blocked'" v-if="$store.state.desktopNotificationState === 'blocked'"
id="warnBlockedDesktopNotifications" id="warnBlockedDesktopNotifications"
@ -358,7 +374,7 @@
Custom highlights Custom highlights
<span <span
class="tooltipped tooltipped-n tooltipped-no-delay" class="tooltipped tooltipped-n tooltipped-no-delay"
aria-label="If a message contains any of these comma-separated aria-label="If a message contains any of these comma-separated
expressions, it will trigger a highlight." expressions, it will trigger a highlight."
> >
<button class="extra-help" /> <button class="extra-help" />
@ -370,6 +386,7 @@ expressions, it will trigger a highlight."
type="text" type="text"
name="highlights" name="highlights"
class="input" class="input"
autocomplete="off"
placeholder="Comma-separated, e.g.: word, some more words, anotherword" placeholder="Comma-separated, e.g.: word, some more words, anotherword"
/> />
</label> </label>
@ -381,8 +398,8 @@ expressions, it will trigger a highlight."
Highlight exceptions Highlight exceptions
<span <span
class="tooltipped tooltipped-n tooltipped-no-delay" class="tooltipped tooltipped-n tooltipped-no-delay"
aria-label="If a message contains any of these comma-separated aria-label="If a message contains any of these comma-separated
expressions, it will not trigger a highlight even if it contains expressions, it will not trigger a highlight even if it contains
your nickname or expressions defined in custom highlights." your nickname or expressions defined in custom highlights."
> >
<button class="extra-help" /> <button class="extra-help" />
@ -394,6 +411,7 @@ your nickname or expressions defined in custom highlights."
type="text" type="text"
name="highlightExceptions" name="highlightExceptions"
class="input" class="input"
autocomplete="off"
placeholder="Comma-separated, e.g.: word, some more words, anotherword" placeholder="Comma-separated, e.g.: word, some more words, anotherword"
/> />
</label> </label>

View File

@ -107,6 +107,10 @@ body {
overflow: hidden; /* iOS Safari requires overflow rather than overflow-y */ overflow: hidden; /* iOS Safari requires overflow rather than overflow-y */
} }
body.force-no-select * {
user-select: none !important;
}
a, a,
a:hover, a:hover,
a:focus { a:focus {
@ -333,6 +337,7 @@ p {
.channel-list-item .not-connected-icon::before, .channel-list-item .not-connected-icon::before,
.channel-list-item .parted-channel-icon::before, .channel-list-item .parted-channel-icon::before,
.jump-to-input::before, .jump-to-input::before,
.password-container .reveal-password span,
#sidebar .collapse-network-icon::before { #sidebar .collapse-network-icon::before {
font: normal normal normal 14px/1 FontAwesome; font: normal normal normal 14px/1 FontAwesome;
font-size: inherit; /* Can't have font-size inherit on line above, so need to override */ font-size: inherit; /* Can't have font-size inherit on line above, so need to override */
@ -707,14 +712,19 @@ background on hover (unless active) */
/* Remove background on hovered/active channel when sorting/drag-and-dropping */ /* Remove background on hovered/active channel when sorting/drag-and-dropping */
.ui-sortable-ghost, .ui-sortable-ghost,
.channel-list-item.ui-sortable-dragged, .ui-sortable-dragging .channel-list-item,
.ui-sortable-dragged .channel-list-item, .ui-sortable-dragging,
.ui-sortable-active .channel-list-item:hover, .ui-sortable-dragging:hover,
.ui-sortable-active .channel-list-item.active { .ui-sortable-dragging.active,
.ui-sortable-dragging-touch-cue .channel-list-item,
.ui-sortable-dragging-touch-cue,
.ui-sortable-dragging-touch-cue:hover,
.ui-sortable-dragging-touch-cue.active {
background: transparent; background: transparent;
} }
.ui-sortable-ghost::after { .ui-sortable-ghost::after,
.ui-sortable-dragging-touch-cue:not(.ui-sortable-dragging)::after {
background: var(--body-bg-color); background: var(--body-bg-color);
border: 1px dashed #99a2b4; border: 1px dashed #99a2b4;
border-radius: 6px; border-radius: 6px;
@ -727,6 +737,10 @@ background on hover (unless active) */
right: 10px; right: 10px;
} }
.ui-sortable-dragging-touch-cue:not(.ui-sortable-ghost)::after {
background: transparent;
}
#sidebar .network { #sidebar .network {
position: relative; position: relative;
margin-bottom: 20px; margin-bottom: 20px;
@ -1038,7 +1052,10 @@ textarea.input {
.header .title { .header .title {
font-size: 15px; font-size: 15px;
padding-left: 6px; padding-left: 6px;
flex-shrink: 0; flex-shrink: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.topic-container { .topic-container {
@ -1054,6 +1071,12 @@ textarea.input {
flex-grow: 1; flex-grow: 1;
overflow: hidden; overflow: hidden;
font-size: 14px; font-size: 14px;
flex-shrink: 99999999;
min-width: 25px;
}
.header .topic.empty {
min-width: 0;
} }
.header .topic-input { .header .topic-input {
@ -1067,6 +1090,7 @@ textarea.input {
height: 35px; height: 35px;
overflow: hidden; overflow: hidden;
font-size: 14px; font-size: 14px;
line-height: normal;
outline: none; outline: none;
} }
@ -1689,6 +1713,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
#chat .userlist .search { #chat .userlist .search {
color: var(--body-color); color: var(--body-color);
appearance: none;
border: 0; border: 0;
background: none; background: none;
font: inherit; font: inherit;
@ -1979,7 +2004,6 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
} }
.password-container .reveal-password span { .password-container .reveal-password span {
font: normal normal normal 14px/1 FontAwesome;
font-size: 16px; font-size: 16px;
color: #607992; color: #607992;
width: 35px; width: 35px;
@ -2024,6 +2048,10 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
padding-right: 15px; padding-right: 15px;
} }
#help .help-item .subject.gesture {
font-weight: bold;
}
#help .help-item .description p { #help .help-item .description p {
margin-bottom: 0; margin-bottom: 0;
} }
@ -2234,6 +2262,14 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
background: transparent; background: transparent;
} }
#context-menu-container.passthrough {
pointer-events: none;
}
#context-menu-container.passthrough > * {
pointer-events: auto;
}
.mentions-popup, .mentions-popup,
#context-menu, #context-menu,
.textcomplete-menu { .textcomplete-menu {
@ -2635,6 +2671,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
right: 0; right: 0;
transform: translateX(180px); transform: translateX(180px);
transition: transform 0.2s; transition: transform 0.2s;
z-index: 1;
} }
#viewport.userlist-open #chat .userlist { #viewport.userlist-open #chat .userlist {

View File

@ -252,22 +252,30 @@ export function generateUserContextMenu($root, channel, network, user) {
return items; return items;
} }
// Names of the modes we are able to change // Names of the standard modes we are able to change
const modes = { const modeCharToName = {
"~": ["owner", "q"], "~": "owner",
"&": ["admin", "a"], "&": "admin",
"@": ["operator", "o"], "@": "operator",
"%": ["half-op", "h"], "%": "half-op",
"+": ["voice", "v"], "+": "voice",
}; };
// Labels for the mode changes. For example .rev(['admin', 'a']) => 'Revoke admin (-a)' // Labels for the mode changes. For example .rev({mode: "a", symbol: "&"}) => 'Revoke admin (-a)'
const modeTextTemplate = { const modeTextTemplate = {
revoke: (m) => `Revoke ${m[0]} (-${m[1]})`, revoke(m) {
give: (m) => `Give ${m[0]} (+${m[1]})`, const name = modeCharToName[m.symbol];
const res = name ? `Revoke ${name} (-${m.mode})` : `Mode -${m.mode}`;
return res;
},
give(m) {
const name = modeCharToName[m.symbol];
const res = name ? `Give ${name} (+${m.mode})` : `Mode +${m.mode}`;
return res;
},
}; };
const networkModes = network.serverOptions.PREFIX; const networkModeSymbols = network.serverOptions.PREFIX.symbols;
/** /**
* Determine whether the prefix of mode p1 has access to perform actions on p2. * Determine whether the prefix of mode p1 has access to perform actions on p2.
@ -284,38 +292,38 @@ export function generateUserContextMenu($root, channel, network, user) {
function compare(p1, p2) { function compare(p1, p2) {
// The modes ~ and @ can perform actions on their own mode. The others on modes below. // The modes ~ and @ can perform actions on their own mode. The others on modes below.
return "~@".indexOf(p1) > -1 return "~@".indexOf(p1) > -1
? networkModes.indexOf(p1) <= networkModes.indexOf(p2) ? networkModeSymbols.indexOf(p1) <= networkModeSymbols.indexOf(p2)
: networkModes.indexOf(p1) < networkModes.indexOf(p2); : networkModeSymbols.indexOf(p1) < networkModeSymbols.indexOf(p2);
} }
networkModes.forEach((prefix) => { network.serverOptions.PREFIX.prefix.forEach((mode) => {
if (!compare(currentChannelUser.modes[0], prefix)) { if (!compare(currentChannelUser.modes[0], mode.symbol)) {
// Our highest mode is below the current mode. Bail. // Our highest mode is below the current mode. Bail.
return; return;
} }
if (!user.modes.includes(prefix)) { if (!user.modes.includes(mode.symbol)) {
// The target doesn't already have this mode, therefore we can set it. // The target doesn't already have this mode, therefore we can set it.
items.push({ items.push({
label: modeTextTemplate.give(modes[prefix]), label: modeTextTemplate.give(mode),
type: "item", type: "item",
class: "action-set-mode", class: "action-set-mode",
action() { action() {
socket.emit("input", { socket.emit("input", {
target: channel.id, target: channel.id,
text: "/mode +" + modes[prefix][1] + " " + user.nick, text: "/mode +" + mode.mode + " " + user.nick,
}); });
}, },
}); });
} else { } else {
items.push({ items.push({
label: modeTextTemplate.revoke(modes[prefix]), label: modeTextTemplate.revoke(mode),
type: "item", type: "item",
class: "action-revoke-mode", class: "action-revoke-mode",
action() { action() {
socket.emit("input", { socket.emit("input", {
target: channel.id, target: channel.id,
text: "/mode -" + modes[prefix][1] + " " + user.nick, text: "/mode -" + mode.mode + " " + user.nick,
}); });
}, },
}); });
@ -323,9 +331,9 @@ export function generateUserContextMenu($root, channel, network, user) {
}); });
// Determine if we are half-op or op depending on the network modes so we can kick. // Determine if we are half-op or op depending on the network modes so we can kick.
if (!compare(networkModes.indexOf("%") > -1 ? "%" : "@", currentChannelUser.modes[0])) { if (!compare(networkModeSymbols.indexOf("%") > -1 ? "%" : "@", currentChannelUser.modes[0])) {
// Check if the target user has no mode or a mode lower than ours.
if (user.modes.length === 0 || compare(currentChannelUser.modes[0], user.modes[0])) { if (user.modes.length === 0 || compare(currentChannelUser.modes[0], user.modes[0])) {
// Check if the target user has no mode or a mode lower than ours.
items.push({ items.push({
label: "Kick", label: "Kick",
type: "item", type: "item",

View File

@ -0,0 +1,5 @@
function distance([x1, y1], [x2, y2]) {
return Math.hypot(x1 - x2, y1 - y2);
}
export default distance;

View File

@ -0,0 +1,106 @@
"use strict";
import distance from "./distance";
// onTwoFingerSwipe will be called with a cardinal direction ("n", "e", "s" or
// "w") as its only argument.
function listenForTwoFingerSwipes(onTwoFingerSwipe) {
let history = [];
document.body.addEventListener(
"touchmove",
function (event) {
if (event.touches.length !== 2) {
return;
}
const a = event.touches.item(0);
const b = event.touches.item(1);
const timestamp = window.performance.now();
const center = [(a.screenX + b.screenX) / 2, (a.screenY + b.screenY) / 2];
if (history.length > 0) {
const last = history[history.length - 1];
const centersAreEqual =
last.center[0] === center[0] && last.center[1] === center[1];
if (last.timestamp === timestamp || centersAreEqual) {
// Touches with the same timestamps or center don't help us
// see the speed of movement. Ignore them.
return;
}
}
history.push({timestamp, center});
},
{passive: true}
);
document.body.addEventListener(
"touchend",
function () {
if (event.touches.length >= 2) {
return;
}
try {
const direction = getSwipe(history);
if (direction) {
onTwoFingerSwipe(direction);
}
} finally {
history = [];
}
},
{passive: true}
);
document.body.addEventListener(
"touchcancel",
function () {
history = [];
},
{passive: true}
);
}
// Returns the cardinal direction of the swipe or null if there is no swipe.
function getSwipe(hist) {
// Speed is in pixels/millisecond. Must be maintained throughout swipe.
const MIN_SWIPE_SPEED = 0.2;
if (hist.length < 2) {
return null;
}
for (let i = 1; i < hist.length; ++i) {
const previous = hist[i - 1];
const current = hist[i];
const speed =
distance(previous.center, current.center) /
Math.abs(previous.timestamp - current.timestamp);
if (speed < MIN_SWIPE_SPEED) {
return null;
}
}
return getCardinalDirection(hist[0].center, hist[hist.length - 1].center);
}
function getCardinalDirection([x1, y1], [x2, y2]) {
// If θ is the angle of the vector then this is tan(θ)
const tangent = (y2 - y1) / (x2 - x1);
// All values of |tan(-45° to 45°)| are less than 1, same for 145° to 225°
if (Math.abs(tangent) < 1) {
return x1 < x2 ? "e" : "w";
}
return y1 < y2 ? "s" : "n";
}
export default listenForTwoFingerSwipes;

View File

@ -79,7 +79,7 @@ function parse(createElement, text, message = undefined, network = undefined) {
// arrays of objects containing start and end markers, as well as metadata // arrays of objects containing start and end markers, as well as metadata
// depending on what was found (channel or link). // depending on what was found (channel or link).
const channelPrefixes = network ? network.serverOptions.CHANTYPES : ["#", "&"]; const channelPrefixes = network ? network.serverOptions.CHANTYPES : ["#", "&"];
const userModes = network ? network.serverOptions.PREFIX : ["!", "@", "%", "+"]; const userModes = network?.serverOptions?.PREFIX.symbols || ["!", "@", "%", "+"];
const channelParts = findChannels(cleanText, channelPrefixes, userModes); const channelParts = findChannels(cleanText, channelPrefixes, userModes);
const linkParts = findLinks(cleanText); const linkParts = findLinks(cleanText);
const emojiParts = findEmoji(cleanText); const emojiParts = findEmoji(cleanText);

View File

@ -1238,8 +1238,8 @@
"credit_card": "💳", "credit_card": "💳",
"receipt": "🧾", "receipt": "🧾",
"chart": "💹", "chart": "💹",
"email": "✉️",
"envelope": "✉️", "envelope": "✉️",
"email": "📧",
"e_mail": "📧", "e_mail": "📧",
"incoming_envelope": "📨", "incoming_envelope": "📨",
"envelope_with_arrow": "📩", "envelope_with_arrow": "📩",

View File

@ -3,9 +3,10 @@
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
import store from "./store"; import store from "./store";
import {switchToChannel} from "./router"; import {switchToChannel, router, navigate} from "./router";
import isChannelCollapsed from "./helpers/isChannelCollapsed"; import isChannelCollapsed from "./helpers/isChannelCollapsed";
import isIgnoredKeybind from "./helpers/isIgnoredKeybind"; import isIgnoredKeybind from "./helpers/isIgnoredKeybind";
import listenForTwoFingerSwipes from "./helpers/listenForTwoFingerSwipes";
// Switch to the next/previous window in the channel list. // Switch to the next/previous window in the channel list.
Mousetrap.bind(["alt+up", "alt+down"], function (e, keys) { Mousetrap.bind(["alt+up", "alt+down"], function (e, keys) {
@ -13,11 +14,22 @@ Mousetrap.bind(["alt+up", "alt+down"], function (e, keys) {
return true; return true;
} }
navigateWindow(keys.split("+").pop() === "up" ? -1 : 1);
return false;
});
listenForTwoFingerSwipes(function (cardinalDirection) {
if (cardinalDirection === "e" || cardinalDirection === "w") {
navigateWindow(cardinalDirection === "e" ? -1 : 1);
}
});
function navigateWindow(direction) {
if (store.state.networks.length === 0) { if (store.state.networks.length === 0) {
return false; return;
} }
const direction = keys.split("+").pop() === "up" ? -1 : 1;
const flatChannels = []; const flatChannels = [];
let index = -1; let index = -1;
@ -44,9 +56,7 @@ Mousetrap.bind(["alt+up", "alt+down"], function (e, keys) {
index = (((index + direction) % length) + length) % length; index = (((index + direction) % length) + length) % length;
jumpToChannel(flatChannels[index]); jumpToChannel(flatChannels[index]);
}
return false;
});
// Switch to the next/previous lobby in the channel list // Switch to the next/previous lobby in the channel list
Mousetrap.bind(["alt+shift+up", "alt+shift+down"], function (e, keys) { Mousetrap.bind(["alt+shift+up", "alt+shift+down"], function (e, keys) {
@ -107,6 +117,17 @@ Mousetrap.bind(["alt+a"], function (e) {
return false; return false;
}); });
// Show the help menu.
Mousetrap.bind(["alt+/"], function (e) {
if (isIgnoredKeybind(e)) {
return true;
}
navigate("Help");
return false;
});
function jumpToChannel(targetChannel) { function jumpToChannel(targetChannel) {
switchToChannel(targetChannel); switchToChannel(targetChannel);
@ -156,6 +177,12 @@ const ignoredKeys = {
}; };
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
// Allow navigating back to the previous page when on the help screen.
if (e.key === "Escape" && router.currentRoute.name === "Help") {
router.go(-1);
return;
}
// Ignore any key that uses alt modifier // Ignore any key that uses alt modifier
// Ignore keys defined above // Ignore keys defined above
if (e.altKey || ignoredKeys[e.which]) { if (e.altKey || ignoredKeys[e.which]) {

View File

@ -98,7 +98,7 @@ export const config = normalizeConfig({
media: { media: {
default: true, default: true,
}, },
removeImageMetadata: { uploadCanvas: {
default: true, default: true,
}, },
userStyles: { userStyles: {

View File

@ -86,15 +86,6 @@ function loadFromLocalStorage() {
storedSettings.highlights = storedSettings.highlights.join(", "); storedSettings.highlights = storedSettings.highlights.join(", ");
} }
// Convert deprecated uploadCanvas to removeImageMetadata
if (
storedSettings.uploadCanvas !== undefined &&
storedSettings.removeImageMetadata === undefined
) {
storedSettings.removeImageMetadata = storedSettings.uploadCanvas;
delete storedSettings.uploadCanvas;
}
return storedSettings; return storedSettings;
} }

View File

@ -12,6 +12,8 @@ function detectDesktopNotificationState() {
return "unsupported"; return "unsupported";
} else if (Notification.permission === "granted") { } else if (Notification.permission === "granted") {
return "granted"; return "granted";
} else if (!window.isSecureContext) {
return "nohttps";
} }
return "blocked"; return "blocked";

View File

@ -142,7 +142,46 @@ class Uploader {
// This issue only happens if The Lounge is proxied through other software // This issue only happens if The Lounge is proxied through other software
// as it may buffer the upload before the upload request will be processed by The Lounge. // as it may buffer the upload before the upload request will be processed by The Lounge.
this.tokenKeepAlive = setInterval(() => socket.emit("upload:ping", token), 40 * 1000); this.tokenKeepAlive = setInterval(() => socket.emit("upload:ping", token), 40 * 1000);
this.performUpload(token, file);
if (
store.state.settings.uploadCanvas &&
file.type.startsWith("image/") &&
!file.type.includes("svg") &&
file.type !== "image/gif"
) {
this.renderImage(file, (newFile) => this.performUpload(token, newFile));
} else {
this.performUpload(token, file);
}
}
renderImage(file, callback) {
const fileReader = new FileReader();
fileReader.onabort = () => callback(file);
fileReader.onerror = () => fileReader.abort();
fileReader.onload = () => {
const img = new Image();
img.onerror = () => callback(file);
img.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0);
canvas.toBlob((blob) => {
callback(new File([blob], file.name));
}, file.type);
};
img.src = fileReader.result;
};
fileReader.readAsDataURL(file);
} }
performUpload(token, file) { performUpload(token, file) {
@ -185,7 +224,6 @@ class Uploader {
}; };
const formData = new FormData(); const formData = new FormData();
formData.append("removeMetadata", store.state.settings.removeImageMetadata);
formData.append("file", file); formData.append("file", file);
this.xhr.open("POST", `uploads/new/${token}`); this.xhr.open("POST", `uploads/new/${token}`);
this.xhr.send(formData); this.xhr.send(formData);

View File

@ -152,8 +152,9 @@ module.exports = {
// ### prefetchMaxSearchSize // ### prefetchMaxSearchSize
// //
// This value sets the maximum request size made to find the Open Graph tags // This value sets the maximum response size allowed when finding the Open
// for link previews. For some sites like YouTube this can easily exceed 300 // Graph tags for link previews. The entire response is temporarily stored
// in memory and for some sites like YouTube this can easily exceed 300
// kilobytes. // kilobytes.
// //
// This value is set to `50` kilobytes by default. // This value is set to `50` kilobytes by default.

View File

@ -1,7 +1,7 @@
{ {
"name": "thelounge", "name": "thelounge",
"description": "The self-hosted Web IRC client", "description": "The self-hosted Web IRC client",
"version": "4.3.0-pre.4", "version": "4.3.0-pre.6",
"preferGlobal": true, "preferGlobal": true,
"bin": { "bin": {
"thelounge": "index.js" "thelounge": "index.js"
@ -37,45 +37,44 @@
], ],
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10.15.0" "node": ">=12.0.0"
}, },
"dependencies": { "dependencies": {
"bcryptjs": "2.4.3", "bcryptjs": "2.4.3",
"busboy": "0.3.1", "busboy": "0.3.1",
"chalk": "4.1.1", "chalk": "4.1.2",
"cheerio": "1.0.0-rc.10", "cheerio": "1.0.0-rc.10",
"commander": "7.2.0", "commander": "7.2.0",
"content-disposition": "0.5.3", "content-disposition": "0.5.3",
"express": "4.17.1", "express": "4.17.1",
"file-type": "16.2.0", "file-type": "16.2.0",
"filenamify": "4.2.0", "filenamify": "4.2.0",
"got": "11.8.1", "got": "11.8.2",
"irc-framework": "4.11.0", "irc-framework": "4.11.0",
"is-utf8": "0.2.1", "is-utf8": "0.2.1",
"ldapjs": "2.2.3", "ldapjs": "2.3.1",
"linkify-it": "3.0.2", "linkify-it": "3.0.3",
"lodash": "4.17.21", "lodash": "4.17.21",
"mime-types": "2.1.28", "mime-types": "2.1.33",
"node-forge": "0.10.0", "node-forge": "0.10.0",
"package-json": "6.5.0", "package-json": "6.5.0",
"read": "1.0.7", "read": "1.0.7",
"read-chunk": "3.2.0", "read-chunk": "3.2.0",
"semver": "7.3.4", "semver": "7.3.5",
"sharp": "0.28.0",
"socket.io": "3.1.2", "socket.io": "3.1.2",
"tlds": "1.216.0", "tlds": "1.216.0",
"ua-parser-js": "0.7.24", "ua-parser-js": "0.7.30",
"uuid": "8.3.2", "uuid": "8.3.2",
"web-push": "3.4.4", "web-push": "3.4.5",
"yarn": "1.22.10" "yarn": "1.22.10"
}, },
"optionalDependencies": { "optionalDependencies": {
"sqlite3": "5.0.2" "sqlite3": "5.0.2"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.14.6", "@babel/core": "7.15.5",
"@babel/preset-env": "7.14.7", "@babel/preset-env": "7.15.6",
"@fortawesome/fontawesome-free": "5.15.3", "@fortawesome/fontawesome-free": "5.15.4",
"@vue/server-test-utils": "1.1.3", "@vue/server-test-utils": "1.1.3",
"@vue/test-utils": "1.1.3", "@vue/test-utils": "1.1.3",
"babel-loader": "8.2.2", "babel-loader": "8.2.2",
@ -84,13 +83,13 @@
"copy-webpack-plugin": "7.0.0", "copy-webpack-plugin": "7.0.0",
"css-loader": "5.1.1", "css-loader": "5.1.1",
"cssnano": "4.1.11", "cssnano": "4.1.11",
"dayjs": "1.10.5", "dayjs": "1.10.7",
"emoji-regex": "9.2.1", "emoji-regex": "9.2.2",
"eslint": "7.23.0", "eslint": "7.23.0",
"eslint-config-prettier": "6.15.0", "eslint-config-prettier": "6.15.0",
"eslint-plugin-vue": "7.5.0", "eslint-plugin-vue": "7.5.0",
"fuzzy": "0.1.3", "fuzzy": "0.1.3",
"husky": "4.3.5", "husky": "4.3.8",
"mini-css-extract-plugin": "1.3.6", "mini-css-extract-plugin": "1.3.6",
"mocha": "8.2.1", "mocha": "8.2.1",
"mousetrap": "1.6.5", "mousetrap": "1.6.5",
@ -98,15 +97,15 @@
"npm-run-all": "4.1.5", "npm-run-all": "4.1.5",
"nyc": "15.1.0", "nyc": "15.1.0",
"postcss": "8.2.10", "postcss": "8.2.10",
"postcss-import": "14.0.0", "postcss-import": "14.0.2",
"postcss-loader": "5.0.0", "postcss-loader": "5.0.0",
"postcss-preset-env": "6.7.0", "postcss-preset-env": "6.7.0",
"prettier": "2.2.1", "prettier": "2.2.1",
"pretty-quick": "3.1.0", "pretty-quick": "3.1.1",
"primer-tooltips": "2.0.0", "primer-tooltips": "2.0.0",
"sinon": "9.2.4", "sinon": "9.2.4",
"socket.io-client": "3.1.1", "socket.io-client": "3.1.3",
"stylelint": "13.9.0", "stylelint": "13.13.1",
"stylelint-config-standard": "20.0.0", "stylelint-config-standard": "20.0.0",
"textcomplete": "0.18.2", "textcomplete": "0.18.2",
"undate": "0.3.0", "undate": "0.3.0",
@ -120,11 +119,14 @@
"webpack": "5.21.2", "webpack": "5.21.2",
"webpack-cli": "4.5.0", "webpack-cli": "4.5.0",
"webpack-dev-middleware": "4.1.0", "webpack-dev-middleware": "4.1.0",
"webpack-hot-middleware": "2.25.0" "webpack-hot-middleware": "2.25.1"
}, },
"husky": { "husky": {
"hooks": { "hooks": {
"pre-commit": "pretty-quick --staged" "pre-commit": "pretty-quick --staged"
} }
},
"resolutions": {
"sortablejs": "git+https://github.com/itsjohncs/Sortable.git"
} }
} }

0
scripts/generate-emoji.js Normal file → Executable file
View File

View File

@ -104,11 +104,6 @@ function Client(manager, name, config = {}) {
delete client.config.awayMessage; delete client.config.awayMessage;
} }
if (client.config.uploadCanvas) {
client.config.clientSettings.removeImageMetadata = client.config.uploadCanvas;
delete client.config.uploadCanvas;
}
if (client.config.clientSettings.awayMessage) { if (client.config.clientSettings.awayMessage) {
client.awayMessage = client.config.clientSettings.awayMessage; client.awayMessage = client.config.clientSettings.awayMessage;
} }

View File

@ -43,6 +43,7 @@ const Helper = {
getDefaultNick, getDefaultNick,
parseHostmask, parseHostmask,
compareHostmask, compareHostmask,
compareWithWildcard,
password: { password: {
hash: passwordHash, hash: passwordHash,
@ -314,8 +315,27 @@ function parseHostmask(hostmask) {
function compareHostmask(a, b) { function compareHostmask(a, b) {
return ( return (
(a.nick.toLowerCase() === b.nick.toLowerCase() || a.nick === "*") && compareWithWildcard(a.nick, b.nick) &&
(a.ident.toLowerCase() === b.ident.toLowerCase() || a.ident === "*") && compareWithWildcard(a.ident, b.ident) &&
(a.hostname.toLowerCase() === b.hostname.toLowerCase() || a.hostname === "*") compareWithWildcard(a.hostname, b.hostname)
); );
} }
function compareWithWildcard(a, b) {
// we allow '*' and '?' wildcards in our comparison.
// this is mostly aligned with https://modern.ircdocs.horse/#wildcard-expressions
// but we do not support the escaping. The ABNF does not seem to be clear as to
// how to escape the escape char '\', which is valid in a nick,
// whereas the wildcards tend not to be (as per RFC1459).
// The "*" wildcard is ".*" in regex, "?" is "."
// so we tokenize and join with the proper char back together,
// escaping any other regex modifier
const wildmany_split = a.split("*").map((sub) => {
const wildone_split = sub.split("?").map((p) => _.escapeRegExp(p));
return wildone_split.join(".");
});
const user_regex = wildmany_split.join(".*");
const re = new RegExp(`^${user_regex}$`, "i"); // case insensitive
return re.test(b);
}

View File

@ -5,6 +5,7 @@ const {v4: uuidv4} = require("uuid");
const IrcFramework = require("irc-framework"); const IrcFramework = require("irc-framework");
const Chan = require("./chan"); const Chan = require("./chan");
const Msg = require("./msg"); const Msg = require("./msg");
const Prefix = require("./prefix");
const Helper = require("../helper"); const Helper = require("../helper");
const STSPolicies = require("../plugins/sts"); const STSPolicies = require("../plugins/sts");
const ClientCertificate = require("../plugins/clientCertificate"); const ClientCertificate = require("../plugins/clientCertificate");
@ -43,7 +44,12 @@ function Network(attr) {
irc: null, irc: null,
serverOptions: { serverOptions: {
CHANTYPES: ["#", "&"], CHANTYPES: ["#", "&"],
PREFIX: ["!", "@", "%", "+"], PREFIX: new Prefix([
{symbol: "!", mode: "Y"},
{symbol: "@", mode: "o"},
{symbol: "%", mode: "h"},
{symbol: "+", mode: "v"},
]),
NETWORK: "", NETWORK: "",
}, },
@ -532,6 +538,7 @@ Network.prototype.export = function () {
"proxyPort", "proxyPort",
"proxyUsername", "proxyUsername",
"proxyEnabled", "proxyEnabled",
"proxyPassword",
]); ]);
network.channels = this.channels network.channels = this.channels

33
src/models/prefix.js Normal file
View File

@ -0,0 +1,33 @@
"use strict";
class Prefix {
constructor(prefix) {
this.prefix = prefix || []; // [{symbol: "@", mode: "o"}, ... ]
this.modeToSymbol = {};
this.symbols = [];
this._update_internals();
}
_update_internals() {
// clean out the old cruft
this.modeToSymbol = {};
this.symbols = [];
const that = this;
this.prefix.forEach(function (p) {
that.modeToSymbol[p.mode] = p.symbol;
that.symbols.push(p.symbol);
});
}
update(prefix) {
this.prefix = prefix || [];
this._update_internals();
}
forEach(f) {
return this.prefix.forEach(f);
}
}
module.exports = Prefix;

View File

@ -4,7 +4,7 @@ const _ = require("lodash");
module.exports = User; module.exports = User;
function User(attr, prefixLookup) { function User(attr, prefix) {
_.defaults(this, attr, { _.defaults(this, attr, {
modes: [], modes: [],
away: "", away: "",
@ -18,12 +18,12 @@ function User(attr, prefixLookup) {
}, },
}); });
this.setModes(this.modes, prefixLookup); this.setModes(this.modes, prefix);
} }
User.prototype.setModes = function (modes, prefixLookup) { User.prototype.setModes = function (modes, prefix) {
// irc-framework sets character mode, but The Lounge works with symbols // irc-framework sets character mode, but The Lounge works with symbols
this.modes = modes.map((mode) => prefixLookup[mode]); this.modes = modes.map((mode) => prefix.modeToSymbol[mode]);
}; };
User.prototype.toJSON = function () { User.prototype.toJSON = function () {

View File

@ -23,7 +23,9 @@ exports.input = function ({irc, nick}, chan, cmd, args) {
return; return;
} }
if (args.length === 0) { const target = args.filter((arg) => arg !== "");
if (target.length === 0) {
chan.pushMessage( chan.pushMessage(
this, this,
new Msg({ new Msg({
@ -44,9 +46,13 @@ exports.input = function ({irc, nick}, chan, cmd, args) {
devoice: "-v", devoice: "-v",
}[cmd]; }[cmd];
args.forEach(function (target) { const limit = parseInt(irc.network.supports("MODES")) || target.length;
irc.raw("MODE", chan.name, mode, target);
}); for (let i = 0; i < target.length; i += limit) {
const targets = target.slice(i, i + limit);
const amode = `${mode[0]}${mode[1].repeat(targets.length)}`;
irc.raw("MODE", chan.name, amode, ...targets);
}
return; return;
} }

View File

@ -63,10 +63,9 @@ module.exports = function (irc, network) {
}); });
irc.on("socket connected", function () { irc.on("socket connected", function () {
network.prefixLookup = {}; if (irc.network.options.PREFIX) {
irc.network.options.PREFIX.forEach(function (mode) { network.serverOptions.PREFIX.update(irc.network.options.PREFIX);
network.prefixLookup[mode.mode] = mode.symbol; }
});
network.channels[0].pushMessage( network.channels[0].pushMessage(
client, client,
@ -197,20 +196,12 @@ module.exports = function (irc, network) {
}); });
irc.on("server options", function (data) { irc.on("server options", function (data) {
network.prefixLookup = {}; network.serverOptions.PREFIX.update(data.options.PREFIX);
data.options.PREFIX.forEach((mode) => {
network.prefixLookup[mode.mode] = mode.symbol;
});
if (data.options.CHANTYPES) { if (data.options.CHANTYPES) {
network.serverOptions.CHANTYPES = data.options.CHANTYPES; network.serverOptions.CHANTYPES = data.options.CHANTYPES;
} }
if (network.serverOptions.PREFIX) {
network.serverOptions.PREFIX = data.options.PREFIX.map((p) => p.symbol);
}
network.serverOptions.NETWORK = data.options.NETWORK; network.serverOptions.NETWORK = data.options.NETWORK;
client.emit("network:options", { client.emit("network:options", {

View File

@ -11,12 +11,6 @@ module.exports = function (irc, network) {
const client = this; const client = this;
irc.on("notice", function (data) { irc.on("notice", function (data) {
// Some servers send notices without any nickname
if (!data.nick) {
data.from_server = true;
data.nick = data.hostname || network.host;
}
data.type = Msg.Type.NOTICE; data.type = Msg.Type.NOTICE;
handleMessage(data); handleMessage(data);
}); });
@ -44,6 +38,12 @@ module.exports = function (irc, network) {
let showInActive = false; let showInActive = false;
const self = data.nick === irc.user.nick; const self = data.nick === irc.user.nick;
// Some servers send messages without any nickname
if (!data.nick) {
data.from_server = true;
data.nick = data.hostname || network.host;
}
// Check if the sender is in our ignore list // Check if the sender is in our ignore list
const shouldIgnore = const shouldIgnore =
!self && !self &&

View File

@ -107,7 +107,7 @@ module.exports = function (irc, network) {
return; return;
} }
const changedMode = network.prefixLookup[char]; const changedMode = network.serverOptions.PREFIX.modeToSymbol[char];
if (!add) { if (!add) {
_.pull(user.modes, changedMode); _.pull(user.modes, changedMode);

View File

@ -14,7 +14,7 @@ module.exports = function (irc, network) {
data.users.forEach((user) => { data.users.forEach((user) => {
const newUser = chan.getUser(user.nick); const newUser = chan.getUser(user.nick);
newUser.setModes(user.modes, network.prefixLookup); newUser.setModes(user.modes, network.serverOptions.PREFIX);
newUsers.set(user.nick.toLowerCase(), newUser); newUsers.set(user.nick.toLowerCase(), newUser);
}); });

View File

@ -236,7 +236,7 @@ class MessageStorage {
target: query.channelName, target: query.channelName,
networkUuid: query.networkUuid, networkUuid: query.networkUuid,
offset: query.offset, offset: query.offset,
results: parseSearchRowsToMessages(query.offset, rows), results: parseSearchRowsToMessages(query.offset, rows).reverse(),
}; };
resolve(response); resolve(response);
} }

View File

@ -46,6 +46,13 @@ const packageApis = function (packageInfo) {
}, },
Config: { Config: {
getConfig: () => Helper.config, getConfig: () => Helper.config,
getPersistentStorageDir: getPersistentStorageDir.bind(this, packageInfo.packageName),
},
Logger: {
error: (...args) => log.error(`[${packageInfo.packageName}]`, ...args),
warn: (...args) => log.warn(`[${packageInfo.packageName}]`, ...args),
info: (...args) => log.info(`[${packageInfo.packageName}]`, ...args),
debug: (...args) => log.debug(`[${packageInfo.packageName}]`, ...args),
}, },
}; };
}; };
@ -81,6 +88,12 @@ function getEnabledPackages(packageJson) {
return []; return [];
} }
function getPersistentStorageDir(packageName) {
const dir = path.join(Helper.getPackagesPath(), packageName);
fs.mkdirSync(dir, {recursive: true}); // we don't care if it already exists or not
return dir;
}
function loadPackage(packageName) { function loadPackage(packageName) {
let packageInfo; let packageInfo;
let packageFile; let packageFile;

View File

@ -11,7 +11,6 @@ const crypto = require("crypto");
const isUtf8 = require("is-utf8"); const isUtf8 = require("is-utf8");
const log = require("../log"); const log = require("../log");
const contentDisposition = require("content-disposition"); const contentDisposition = require("content-disposition");
const sharp = require("sharp");
// Map of allowed mime types to their respecive default filenames // Map of allowed mime types to their respecive default filenames
// that will be rendered in browser without forcing them to be downloaded // that will be rendered in browser without forcing them to be downloaded
@ -134,7 +133,6 @@ class Uploader {
let destDir; let destDir;
let destPath; let destPath;
let streamWriter; let streamWriter;
let removeMetadata;
const doneCallback = () => { const doneCallback = () => {
// detach the stream and drain any remaining data // detach the stream and drain any remaining data
@ -153,19 +151,6 @@ class Uploader {
} }
}; };
const successfullCompletion = () => {
doneCallback();
if (!uploadUrl) {
return res.status(400).json({error: "Missing file"});
}
// upload was done, send the generated file url to the client
res.status(200).json({
url: uploadUrl,
});
};
const abortWithError = (err) => { const abortWithError = (err) => {
doneCallback(); doneCallback();
@ -212,11 +197,6 @@ class Uploader {
busboyInstance.on("partsLimit", () => abortWithError(Error("Parts limit reached"))); busboyInstance.on("partsLimit", () => abortWithError(Error("Parts limit reached")));
busboyInstance.on("filesLimit", () => abortWithError(Error("Files limit reached"))); busboyInstance.on("filesLimit", () => abortWithError(Error("Files limit reached")));
busboyInstance.on("fieldsLimit", () => abortWithError(Error("Fields limit reached"))); busboyInstance.on("fieldsLimit", () => abortWithError(Error("Fields limit reached")));
busboyInstance.on("field", (fieldname, val) => {
if (fieldname === "removeMetadata") {
removeMetadata = val === "true";
}
});
// generate a random output filename for the file // generate a random output filename for the file
// we use do/while loop to prevent the rare case of generating a file name // we use do/while loop to prevent the rare case of generating a file name
@ -237,7 +217,11 @@ class Uploader {
return abortWithError(err); return abortWithError(err);
} }
busboyInstance.on("file", (fieldname, fileStream, filename, encoding, contentType) => { // Open a file stream for writing
streamWriter = fs.createWriteStream(destPath);
streamWriter.on("error", abortWithError);
busboyInstance.on("file", (fieldname, fileStream, filename) => {
uploadUrl = `${randomName}/${encodeURIComponent(filename)}`; uploadUrl = `${randomName}/${encodeURIComponent(filename)}`;
if (Helper.config.fileUpload.baseUrl) { if (Helper.config.fileUpload.baseUrl) {
@ -246,55 +230,31 @@ class Uploader {
uploadUrl = `uploads/${uploadUrl}`; uploadUrl = `uploads/${uploadUrl}`;
} }
// Sharps prebuilt libvips does not include gif support, but that is not a problem,
// as GIFs don't support EXIF metadata or anything alike
const isImage = contentType.startsWith("image/") && !contentType.endsWith("gif");
// if the busboy data stream errors out or goes over the file size limit // if the busboy data stream errors out or goes over the file size limit
// abort the processing with an error // abort the processing with an error
fileStream.on("error", abortWithError); fileStream.on("error", abortWithError);
fileStream.on("limit", () => { fileStream.on("limit", () => {
if (!isImage) { fileStream.unpipe(streamWriter);
fileStream.unpipe(streamWriter);
}
fileStream.on("readable", fileStream.read.bind(fileStream)); fileStream.on("readable", fileStream.read.bind(fileStream));
abortWithError(Error("File size limit reached")); abortWithError(Error("File size limit reached"));
}); });
if (isImage) { // Attempt to write the stream to file
let sharpInstance = sharp({ fileStream.pipe(streamWriter);
animated: true, });
pages: -1,
sequentialRead: true,
});
if (!removeMetadata) { busboyInstance.on("finish", () => {
sharpInstance = sharpInstance.withMetadata(); doneCallback();
}
sharpInstance if (!uploadUrl) {
.rotate() // auto-orient based on the EXIF Orientation tag return res.status(400).json({error: "Missing file"});
.toFile(destPath, (err) => {
// Removes metadata by default https://sharp.pixelplumbing.com/api-output#tofile if no `withMetadata` is present
if (err) {
abortWithError(err);
} else {
successfullCompletion();
}
});
fileStream.pipe(sharpInstance);
} else {
// Open a file stream for writing
streamWriter = fs.createWriteStream(destPath);
streamWriter.on("error", abortWithError);
streamWriter.on("finish", successfullCompletion);
// Attempt to write the stream to file
fileStream.pipe(streamWriter);
} }
// upload was done, send the generated file url to the client
res.status(200).json({
url: uploadUrl,
});
}); });
// pipe request body to busboy for processing // pipe request body to busboy for processing

View File

@ -50,6 +50,7 @@ module.exports = function (options = {}) {
app.set("env", "production") app.set("env", "production")
.disable("x-powered-by") .disable("x-powered-by")
.use(allRequests) .use(allRequests)
.use(addSecurityHeaders)
.get("/", indexRequest) .get("/", indexRequest)
.get("/service-worker.js", forceNoCacheRequest) .get("/service-worker.js", forceNoCacheRequest)
.get("/js/bundle.js.map", forceNoCacheRequest) .get("/js/bundle.js.map", forceNoCacheRequest)
@ -286,14 +287,7 @@ function allRequests(req, res, next) {
return next(); return next();
} }
function forceNoCacheRequest(req, res, next) { function addSecurityHeaders(req, res, next) {
// Intermittent proxies must not cache the following requests,
// browsers must fetch the latest version of these files (service worker, source maps)
res.setHeader("Cache-Control", "no-cache, no-transform");
return next();
}
function indexRequest(req, res) {
const policies = [ const policies = [
"default-src 'none'", // default to nothing "default-src 'none'", // default to nothing
"base-uri 'none'", // disallow <base>, has no fallback to default-src "base-uri 'none'", // disallow <base>, has no fallback to default-src
@ -317,10 +311,22 @@ function indexRequest(req, res) {
policies.push("img-src http: https: data:"); policies.push("img-src http: https: data:");
} }
res.setHeader("Content-Type", "text/html");
res.setHeader("Content-Security-Policy", policies.join("; ")); res.setHeader("Content-Security-Policy", policies.join("; "));
res.setHeader("Referrer-Policy", "no-referrer"); res.setHeader("Referrer-Policy", "no-referrer");
return next();
}
function forceNoCacheRequest(req, res, next) {
// Intermittent proxies must not cache the following requests,
// browsers must fetch the latest version of these files (service worker, source maps)
res.setHeader("Cache-Control", "no-cache, no-transform");
return next();
}
function indexRequest(req, res) {
res.setHeader("Content-Type", "text/html");
return fs.readFile( return fs.readFile(
path.join(__dirname, "..", "client", "index.html.tpl"), path.join(__dirname, "..", "client", "index.html.tpl"),
"utf-8", "utf-8",
@ -538,7 +544,7 @@ function initializeClient(socket, client, token, lastMessage, openChannel) {
socket.emit("mentions:list", client.mentions); socket.emit("mentions:list", client.mentions);
}); });
socket.on("mentions:hide", (msgId) => { socket.on("mentions:dismiss", (msgId) => {
if (typeof msgId !== "number") { if (typeof msgId !== "number") {
return; return;
} }
@ -549,7 +555,7 @@ function initializeClient(socket, client, token, lastMessage, openChannel) {
); );
}); });
socket.on("mentions:hide_all", () => { socket.on("mentions:dismiss_all", () => {
client.mentions = []; client.mentions = [];
}); });

View File

@ -17,15 +17,38 @@ describe("Commands", function () {
}); });
const testableNetwork = { const testableNetwork = {
firstCommand: null,
lastCommand: null, lastCommand: null,
nick: "xPaw", nick: "xPaw",
irc: { irc: {
network: {
supports(type) {
if (type.toUpperCase() === "MODES") {
return "4";
}
},
},
raw(...args) { raw(...args) {
testableNetwork.firstCommand = testableNetwork.lastCommand;
testableNetwork.lastCommand = args.join(" "); testableNetwork.lastCommand = args.join(" ");
}, },
}, },
}; };
const testableNetworkNoSupports = Object.assign({}, testableNetwork, {
irc: {
network: {
supports() {
return null;
},
},
raw(...args) {
testableNetworkNoSupports.firstCommand = testableNetworkNoSupports.lastCommand;
testableNetworkNoSupports.lastCommand = args.join(" ");
},
},
});
it("should not mess with the given target", function () { it("should not mess with the given target", function () {
const test = function (expected, args) { const test = function (expected, args) {
ModeCommand.input(testableNetwork, channel, "mode", Array.from(args)); ModeCommand.input(testableNetwork, channel, "mode", Array.from(args));
@ -81,10 +104,34 @@ describe("Commands", function () {
ModeCommand.input(testableNetwork, channel, "devoice", ["xPaw"]); ModeCommand.input(testableNetwork, channel, "devoice", ["xPaw"]);
expect(testableNetwork.lastCommand).to.equal("MODE #thelounge -v xPaw"); expect(testableNetwork.lastCommand).to.equal("MODE #thelounge -v xPaw");
});
// Multiple arguments are supported, sent as separate commands it("should use ISUPPORT MODES on shorthand commands", function () {
ModeCommand.input(testableNetwork, channel, "devoice", ["xPaw", "Max-P"]); ModeCommand.input(testableNetwork, channel, "voice", ["xPaw", "Max-P"]);
expect(testableNetwork.lastCommand).to.equal("MODE #thelounge -v Max-P"); expect(testableNetwork.lastCommand).to.equal("MODE #thelounge +vv xPaw Max-P");
// since the limit for modes on tests is 4, it should send two commands
ModeCommand.input(testableNetwork, channel, "devoice", [
"xPaw",
"Max-P",
"hey",
"idk",
"thelounge",
]);
expect(testableNetwork.firstCommand).to.equal(
"MODE #thelounge -vvvv xPaw Max-P hey idk"
);
expect(testableNetwork.lastCommand).to.equal("MODE #thelounge -v thelounge");
});
it("should fallback to all modes at once for shorthand commands", function () {
ModeCommand.input(testableNetworkNoSupports, channel, "voice", ["xPaw"]);
expect(testableNetworkNoSupports.lastCommand).to.equal("MODE #thelounge +v xPaw");
ModeCommand.input(testableNetworkNoSupports, channel, "devoice", ["xPaw", "Max-P"]);
expect(testableNetworkNoSupports.lastCommand).to.equal(
"MODE #thelounge -vv xPaw Max-P"
);
}); });
}); });
}); });

View File

@ -20,10 +20,10 @@ describe("Chan", function () {
}, },
}; };
const prefixLookup = {}; const prefixLookup = {modeToSymbol: {}};
network.network.options.PREFIX.forEach((mode) => { network.network.options.PREFIX.forEach((mode) => {
prefixLookup[mode.mode] = mode.symbol; prefixLookup.modeToSymbol[mode.mode] = mode.symbol;
}); });
describe("#findMessage(id)", function () { describe("#findMessage(id)", function () {

View File

@ -8,7 +8,7 @@ const User = require("../../src/models/user");
describe("Msg", function () { describe("Msg", function () {
["from", "target"].forEach((prop) => { ["from", "target"].forEach((prop) => {
it(`should keep a copy of the original user in the \`${prop}\` property`, function () { it(`should keep a copy of the original user in the \`${prop}\` property`, function () {
const prefixLookup = {a: "&", o: "@"}; const prefixLookup = {modeToSymbol: {a: "&", o: "@"}};
const user = new User( const user = new User(
{ {
modes: ["o"], modes: ["o"],

View File

@ -49,6 +49,7 @@ describe("Network", function () {
proxyEnabled: false, proxyEnabled: false,
proxyHost: "", proxyHost: "",
proxyPort: 1080, proxyPort: 1080,
proxyPassword: "",
proxyUsername: "", proxyUsername: "",
channels: [ channels: [
{name: "#thelounge", key: ""}, {name: "#thelounge", key: ""},

View File

@ -37,10 +37,9 @@ describe("SQLite Message Storage", function () {
fs.rmdir(path.join(Helper.getHomePath(), "logs"), done); fs.rmdir(path.join(Helper.getHomePath(), "logs"), done);
}); });
it("should resolve an empty array when disabled", function (done) { it("should resolve an empty array when disabled", function () {
store.getMessages(null, null).then((messages) => { return store.getMessages(null, null).then((messages) => {
expect(messages).to.be.empty; expect(messages).to.be.empty;
done();
}); });
}); });
@ -54,91 +53,134 @@ describe("SQLite Message Storage", function () {
}); });
it("should create tables", function (done) { it("should create tables", function (done) {
store.database.serialize(() => store.database.all(
store.database.all( "SELECT name, tbl_name, sql FROM sqlite_master WHERE type = 'table'",
"SELECT name, tbl_name, sql FROM sqlite_master WHERE type = 'table'", (err, row) => {
(err, row) => { expect(err).to.be.null;
expect(err).to.be.null; expect(row).to.deep.equal([
expect(row).to.deep.equal([ {
{ name: "options",
name: "options", tbl_name: "options",
tbl_name: "options", sql:
sql: "CREATE TABLE options (name TEXT, value TEXT, CONSTRAINT name_unique UNIQUE (name))",
"CREATE TABLE options (name TEXT, value TEXT, CONSTRAINT name_unique UNIQUE (name))", },
}, {
{ name: "messages",
name: "messages", tbl_name: "messages",
tbl_name: "messages", sql:
sql: "CREATE TABLE messages (network TEXT, channel TEXT, time INTEGER, type TEXT, msg TEXT)",
"CREATE TABLE messages (network TEXT, channel TEXT, time INTEGER, type TEXT, msg TEXT)", },
}, ]);
]);
done(); done();
} }
)
); );
}); });
it("should insert schema version to options table", function (done) { it("should insert schema version to options table", function (done) {
store.database.serialize(() => store.database.get(
store.database.get( "SELECT value FROM options WHERE name = 'schema_version'",
"SELECT value FROM options WHERE name = 'schema_version'", (err, row) => {
(err, row) => { expect(err).to.be.null;
expect(err).to.be.null;
// Should be sqlite.currentSchemaVersion, // Should be sqlite.currentSchemaVersion,
// compared as string because it's returned as such from the database // compared as string because it's returned as such from the database
expect(row.value).to.equal("1520239200"); expect(row.value).to.equal("1520239200");
done(); done();
} }
)
); );
}); });
it("should store a message", function (done) { it("should store a message", function () {
store.database.serialize(() => { store.index(
store.index( {
uuid: "this-is-a-network-guid",
},
{
name: "#thisISaCHANNEL",
},
new Msg({
time: 123456789,
text: "Hello from sqlite world!",
})
);
});
it("should retrieve previously stored message", function () {
return store
.getMessages(
{ {
uuid: "this-is-a-network-guid", uuid: "this-is-a-network-guid",
}, },
{ {
name: "#thisISaCHANNEL", name: "#thisisaCHANNEL",
}, }
new Msg({ )
time: 123456789, .then((messages) => {
text: "Hello from sqlite world!", expect(messages).to.have.lengthOf(1);
})
);
done(); const msg = messages[0];
});
expect(msg.text).to.equal("Hello from sqlite world!");
expect(msg.type).to.equal(Msg.Type.MESSAGE);
expect(msg.time.getTime()).to.equal(123456789);
});
}); });
it("should retrieve previously stored message", function (done) { it("should retrieve latest LIMIT messages in order", function () {
store.database.serialize(() => const originalMaxHistory = Helper.config.maxHistory;
store
.getMessages( try {
{ Helper.config.maxHistory = 2;
uuid: "this-is-a-network-guid",
}, for (let i = 0; i < 200; ++i) {
{ store.index(
name: "#thisisaCHANNEL", {uuid: "retrieval-order-test-network"},
} {name: "#channel"},
) new Msg({
time: 123456789 + i,
text: `msg ${i}`,
})
);
}
return store
.getMessages({uuid: "retrieval-order-test-network"}, {name: "#channel"})
.then((messages) => { .then((messages) => {
expect(messages).to.have.lengthOf(1); expect(messages).to.have.lengthOf(2);
expect(messages.map((i) => i.text)).to.deep.equal(["msg 198", "msg 199"]);
});
} finally {
Helper.config.maxHistory = originalMaxHistory;
}
});
const msg = messages[0]; it("should search messages", function () {
const originalMaxHistory = Helper.config.maxHistory;
expect(msg.text).to.equal("Hello from sqlite world!"); try {
expect(msg.type).to.equal(Msg.Type.MESSAGE); Helper.config.maxHistory = 2;
expect(msg.time.getTime()).to.equal(123456789);
done(); return store
.search({
searchTerm: "msg",
networkUuid: "retrieval-order-test-network",
}) })
); .then((messages) => {
expect(messages.results).to.have.lengthOf(100);
const expectedMessages = [];
for (let i = 100; i < 200; ++i) {
expectedMessages.push(`msg ${i}`);
}
expect(messages.results.map((i) => i.text)).to.deep.equal(expectedMessages);
});
} finally {
Helper.config.maxHistory = originalMaxHistory;
}
}); });
it("should close database", function (done) { it("should close database", function (done) {

View File

@ -48,7 +48,14 @@ describe("Hostmask", function () {
it(".compareHostmask (wildcard)", function () { it(".compareHostmask (wildcard)", function () {
const a = Helper.parseHostmask("nick!user@host"); const a = Helper.parseHostmask("nick!user@host");
const b = Helper.parseHostmask("nick!*@*"); const b = Helper.parseHostmask("n?ck!*@*");
expect(Helper.compareHostmask(b, a)).to.be.true;
expect(Helper.compareHostmask(a, b)).to.be.false;
});
it(".compareHostmask (wildcard - partial)", function () {
const a = Helper.parseHostmask("nicky!user@host");
const b = Helper.parseHostmask("nick*!*e?@?os*");
expect(Helper.compareHostmask(b, a)).to.be.true; expect(Helper.compareHostmask(b, a)).to.be.true;
expect(Helper.compareHostmask(a, b)).to.be.false; expect(Helper.compareHostmask(a, b)).to.be.false;
}); });
@ -60,3 +67,47 @@ describe("Hostmask", function () {
expect(Helper.compareHostmask(a, b)).to.be.true; expect(Helper.compareHostmask(a, b)).to.be.true;
}); });
}); });
describe("compareWithWildcard", function () {
const goodPairs = [
["asdf", "asdf"],
["AsDf", "asdf"],
["a?df*", "asdf"],
["*asdf*", "asdf"],
["*asdf", "asdf"],
["asd?", "asdf"],
["asd?*", "asdf"],
["a??f", "asdf"],
["a*", "asdf"],
["*f", "asdf"],
["*s*", "asdf"],
["*", ""],
["**", ""],
];
for (const t of goodPairs) {
it(`("${t[0]}", "${t[1]}")`, function () {
expect(Helper.compareWithWildcard(t[0], t[1])).to.be.true;
});
}
const badPairs = [
["asdf", "fdsa"],
["a?df*", "adfg"],
["?", ""],
["?asdf", "asdf"],
["?*", ""],
["*?*", ""],
["*?", ""],
["asd", "asdf"],
["sdf", "asdf"],
["sd", "asdf"],
["", "asdf"],
];
for (const t of badPairs) {
it(`("${t[0]}", "${t[1]}")`, function () {
expect(Helper.compareWithWildcard(t[0], t[1])).to.be.false;
});
}
});

View File

@ -85,7 +85,6 @@ const config = {
}, },
externals: { externals: {
json3: "JSON", // socket.io uses json3.js, but we do not target any browsers that need it json3: "JSON", // socket.io uses json3.js, but we do not target any browsers that need it
sharp: "commonjs sharp",
}, },
plugins: [ plugins: [
new VueLoaderPlugin(), new VueLoaderPlugin(),

880
yarn.lock

File diff suppressed because it is too large Load Diff