Merge branch 'master' into fix-search-query
This commit is contained in:
commit
3cec329e3b
16
.github/workflows/build.yml
vendored
16
.github/workflows/build.yml
vendored
@ -9,10 +9,6 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
# EOL: April 2021
|
||||
- os: ubuntu-latest
|
||||
node_version: 10.x
|
||||
|
||||
# EOL: April 2022
|
||||
- os: ubuntu-latest
|
||||
node_version: 12.x
|
||||
@ -20,15 +16,19 @@ jobs:
|
||||
# EOL: April 2023
|
||||
- os: ubuntu-latest
|
||||
node_version: 14.x
|
||||
- os: macOS-latest
|
||||
node_version: 14.x
|
||||
- os: windows-latest
|
||||
node_version: 14.x
|
||||
|
||||
# EOL: June 2021
|
||||
- os: ubuntu-latest
|
||||
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 }}
|
||||
|
||||
steps:
|
||||
|
30
CHANGELOG.md
30
CHANGELOG.md
@ -4,6 +4,36 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
<!-- 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]
|
||||
|
||||
[See the full changelog](https://github.com/thelounge/thelounge/compare/v4.3.0-pre.3...v4.3.0-pre.4)
|
||||
|
@ -35,7 +35,11 @@
|
||||
<span type="button" aria-label="Save topic"></span>
|
||||
</span>
|
||||
</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
|
||||
v-if="channel.topic"
|
||||
:network="network"
|
||||
|
@ -140,22 +140,53 @@ export default {
|
||||
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;
|
||||
|
||||
if (channel.inputHistoryPosition === 0) {
|
||||
channel.inputHistory[channel.inputHistoryPosition] = channel.pendingMessage;
|
||||
}
|
||||
|
||||
if (key === "up") {
|
||||
if (key === "up" && onRow === 0) {
|
||||
if (channel.inputHistoryPosition < channel.inputHistory.length - 1) {
|
||||
channel.inputHistoryPosition++;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
} else if (channel.inputHistoryPosition > 0) {
|
||||
} else if (key === "down" && channel.inputHistoryPosition > 0 && onRow === totalRows) {
|
||||
channel.inputHistoryPosition--;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
return false;
|
||||
|
@ -2,6 +2,7 @@
|
||||
<div
|
||||
v-if="isOpen"
|
||||
id="context-menu-container"
|
||||
:class="{passthrough}"
|
||||
@click="containerClick"
|
||||
@contextmenu.prevent="containerClick"
|
||||
@keydown.exact.up.prevent="navigateMenu(-1)"
|
||||
@ -49,6 +50,7 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
isOpen: false,
|
||||
passthrough: false,
|
||||
previousActiveElement: null,
|
||||
items: [],
|
||||
activeItem: -1,
|
||||
@ -60,18 +62,35 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
eventbus.on("escapekey", this.close);
|
||||
eventbus.on("contextmenu:cancel", this.close);
|
||||
eventbus.on("contextmenu:user", this.openUserContextMenu);
|
||||
eventbus.on("contextmenu:channel", this.openChannelContextMenu);
|
||||
},
|
||||
destroyed() {
|
||||
eventbus.off("escapekey", this.close);
|
||||
eventbus.off("contextmenu:cancel", this.close);
|
||||
eventbus.off("contextmenu:user", this.openUserContextMenu);
|
||||
eventbus.off("contextmenu:channel", this.openChannelContextMenu);
|
||||
|
||||
this.close();
|
||||
},
|
||||
methods: {
|
||||
enablePointerEvents() {
|
||||
this.passthrough = false;
|
||||
document.body.removeEventListener("pointerup", this.enablePointerEvents, {
|
||||
passive: true,
|
||||
});
|
||||
},
|
||||
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);
|
||||
this.open(data.event, items);
|
||||
},
|
||||
|
@ -10,10 +10,10 @@
|
||||
Recent mentions
|
||||
<button
|
||||
v-if="resolvedMessages.length"
|
||||
class="btn hide-all-mentions"
|
||||
@click="hideAllMentions()"
|
||||
class="btn dismiss-all-mentions"
|
||||
@click="dismissAllMentions()"
|
||||
>
|
||||
Hide all
|
||||
Dismiss all
|
||||
</button>
|
||||
</div>
|
||||
<template v-if="resolvedMessages.length === 0">
|
||||
@ -37,11 +37,14 @@
|
||||
</span>
|
||||
</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
|
||||
class="msg-hide"
|
||||
aria-label="Hide this mention"
|
||||
@click="hideMention(message)"
|
||||
class="msg-dismiss"
|
||||
aria-label="Dismiss this mention"
|
||||
@click="dismissMention(message)"
|
||||
></button>
|
||||
</span>
|
||||
</div>
|
||||
@ -102,7 +105,7 @@
|
||||
word-break: break-word; /* Webkit-specific */
|
||||
}
|
||||
|
||||
.mentions-popup .msg-hide::before {
|
||||
.mentions-popup .msg-dismiss::before {
|
||||
font-size: 20px;
|
||||
font-weight: normal;
|
||||
display: inline-block;
|
||||
@ -111,11 +114,11 @@
|
||||
content: "×";
|
||||
}
|
||||
|
||||
.mentions-popup .msg-hide:hover {
|
||||
.mentions-popup .msg-dismiss:hover {
|
||||
color: var(--link-color);
|
||||
}
|
||||
|
||||
.mentions-popup .hide-all-mentions {
|
||||
.mentions-popup .dismiss-all-mentions {
|
||||
margin: 0;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
@ -191,17 +194,17 @@ export default {
|
||||
messageTime(time) {
|
||||
return dayjs(time).fromNow();
|
||||
},
|
||||
hideMention(message) {
|
||||
dismissMention(message) {
|
||||
this.$store.state.mentions.splice(
|
||||
this.$store.state.mentions.findIndex((m) => m.msgId === message.msgId),
|
||||
1
|
||||
);
|
||||
|
||||
socket.emit("mentions:hide", message.msgId);
|
||||
socket.emit("mentions:dismiss", message.msgId);
|
||||
},
|
||||
hideAllMentions() {
|
||||
dismissAllMentions() {
|
||||
this.$store.state.mentions = [];
|
||||
socket.emit("mentions:hide_all");
|
||||
socket.emit("mentions:dismiss_all");
|
||||
},
|
||||
containerClick(event) {
|
||||
if (event.currentTarget === event.target) {
|
||||
|
@ -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: {
|
||||
|
@ -37,6 +37,7 @@ form.message-search input {
|
||||
border: 0;
|
||||
color: inherit;
|
||||
background-color: #fafafa;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
form.message-search input::placeholder {
|
||||
|
@ -166,7 +166,7 @@
|
||||
class="input"
|
||||
:type="slotProps.isVisible ? 'text' : 'password'"
|
||||
placeholder="Proxy password"
|
||||
name="password"
|
||||
name="proxyPassword"
|
||||
maxlength="300"
|
||||
/>
|
||||
</RevealPassword>
|
||||
|
@ -56,17 +56,18 @@
|
||||
<Draggable
|
||||
v-else
|
||||
:list="$store.state.networks"
|
||||
:filter="isCurrentlyInTouch"
|
||||
:prevent-on-filter="false"
|
||||
:delay="LONG_TOUCH_DURATION"
|
||||
:delay-on-touch-only="true"
|
||||
:touch-start-threshold="10"
|
||||
handle=".channel-list-item[data-type='lobby']"
|
||||
draggable=".network"
|
||||
ghost-class="ui-sortable-ghost"
|
||||
drag-class="ui-sortable-dragged"
|
||||
drag-class="ui-sortable-dragging"
|
||||
group="networks"
|
||||
class="networks"
|
||||
@change="onNetworkSort"
|
||||
@start="onDragStart"
|
||||
@end="onDragEnd"
|
||||
@choose="onDraggableChoose"
|
||||
@unchoose="onDraggableUnchoose"
|
||||
>
|
||||
<div
|
||||
v-for="network in $store.state.networks"
|
||||
@ -80,6 +81,10 @@
|
||||
class="network"
|
||||
role="region"
|
||||
aria-live="polite"
|
||||
@touchstart="onDraggableTouchStart"
|
||||
@touchmove="onDraggableTouchMove"
|
||||
@touchend="onDraggableTouchEnd"
|
||||
@touchcancel="onDraggableTouchEnd"
|
||||
>
|
||||
<NetworkLobby
|
||||
:network="network"
|
||||
@ -100,15 +105,16 @@
|
||||
<Draggable
|
||||
draggable=".channel-list-item"
|
||||
ghost-class="ui-sortable-ghost"
|
||||
drag-class="ui-sortable-dragged"
|
||||
drag-class="ui-sortable-dragging"
|
||||
:group="network.uuid"
|
||||
:filter="isCurrentlyInTouch"
|
||||
:prevent-on-filter="false"
|
||||
:list="network.channels"
|
||||
:delay="LONG_TOUCH_DURATION"
|
||||
:delay-on-touch-only="true"
|
||||
:touch-start-threshold="10"
|
||||
class="channels"
|
||||
@change="onChannelSort"
|
||||
@start="onDragStart"
|
||||
@end="onDragEnd"
|
||||
@choose="onDraggableChoose"
|
||||
@unchoose="onDraggableUnchoose"
|
||||
>
|
||||
<template v-for="(channel, index) in network.channels">
|
||||
<Channel
|
||||
@ -141,6 +147,7 @@
|
||||
color: #fff;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
padding-right: 35px;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.jump-to-input .input::placeholder {
|
||||
@ -199,6 +206,8 @@ import JoinChannel from "./JoinChannel.vue";
|
||||
import socket from "../js/socket";
|
||||
import collapseNetwork from "../js/helpers/collapseNetwork";
|
||||
import isIgnoredKeybind from "../js/helpers/isIgnoredKeybind";
|
||||
import distance from "../js/helpers/distance";
|
||||
import eventbus from "../js/eventbus";
|
||||
|
||||
export default {
|
||||
name: "NetworkList",
|
||||
@ -246,6 +255,10 @@ export default {
|
||||
this.setActiveSearchItem();
|
||||
},
|
||||
},
|
||||
created() {
|
||||
// Number of milliseconds a touch has to last to be considered long
|
||||
this.LONG_TOUCH_DURATION = 500;
|
||||
},
|
||||
mounted() {
|
||||
Mousetrap.bind("alt+shift+right", this.expandNetwork);
|
||||
Mousetrap.bind("alt+shift+left", this.collapseNetwork);
|
||||
@ -279,16 +292,6 @@ export default {
|
||||
|
||||
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) {
|
||||
if (!e.moved) {
|
||||
return;
|
||||
@ -316,6 +319,59 @@ export default {
|
||||
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) {
|
||||
if (isIgnoredKeybind(event)) {
|
||||
return true;
|
||||
|
@ -195,6 +195,9 @@ export default {
|
||||
|
||||
document.body.addEventListener("touchstart", this.onTouchStart, {passive: true});
|
||||
},
|
||||
destroyed() {
|
||||
document.body.removeEventListener("touchstart", this.onTouchStart, {passive: true});
|
||||
},
|
||||
methods: {
|
||||
isPublic: () => document.body.classList.contains("public"),
|
||||
},
|
||||
|
@ -87,6 +87,36 @@
|
||||
</p>
|
||||
</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>
|
||||
|
||||
<div class="help-item">
|
||||
@ -199,6 +229,16 @@
|
||||
</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="subject">
|
||||
<span><kbd>Esc</kbd></span>
|
||||
@ -764,6 +804,7 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
isApple: navigator.platform.match(/(Mac|iPhone|iPod|iPad)/i) || false,
|
||||
isTouch: navigator.maxTouchPoints > 0,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
@ -124,7 +124,7 @@ export default {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.search.results.slice().reverse();
|
||||
return this.search.results;
|
||||
},
|
||||
chan() {
|
||||
const chanId = parseInt(this.$route.params.id, 10);
|
||||
|
@ -248,11 +248,18 @@
|
||||
<div>
|
||||
<label class="opt">
|
||||
<input
|
||||
:checked="$store.state.settings.removeImageMetadata"
|
||||
:checked="$store.state.settings.uploadCanvas"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
@ -306,6 +313,7 @@
|
||||
<input
|
||||
id="desktopNotifications"
|
||||
:checked="$store.state.settings.desktopNotifications"
|
||||
:disabled="$store.state.desktopNotificationState === 'nohttps'"
|
||||
type="checkbox"
|
||||
name="desktopNotifications"
|
||||
/>
|
||||
@ -316,6 +324,14 @@
|
||||
>
|
||||
<strong>Warning</strong>: Notifications are not supported by your browser.
|
||||
</div>
|
||||
<div
|
||||
v-if="$store.state.desktopNotificationState === 'nohttps'"
|
||||
id="warnBlockedDesktopNotifications"
|
||||
class="error"
|
||||
>
|
||||
<strong>Warning</strong>: Notifications are only supported over HTTPS
|
||||
connections.
|
||||
</div>
|
||||
<div
|
||||
v-if="$store.state.desktopNotificationState === 'blocked'"
|
||||
id="warnBlockedDesktopNotifications"
|
||||
@ -358,7 +374,7 @@
|
||||
Custom highlights
|
||||
<span
|
||||
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."
|
||||
>
|
||||
<button class="extra-help" />
|
||||
@ -370,6 +386,7 @@ expressions, it will trigger a highlight."
|
||||
type="text"
|
||||
name="highlights"
|
||||
class="input"
|
||||
autocomplete="off"
|
||||
placeholder="Comma-separated, e.g.: word, some more words, anotherword"
|
||||
/>
|
||||
</label>
|
||||
@ -381,8 +398,8 @@ expressions, it will trigger a highlight."
|
||||
Highlight exceptions
|
||||
<span
|
||||
class="tooltipped tooltipped-n tooltipped-no-delay"
|
||||
aria-label="If a message contains any of these comma-separated
|
||||
expressions, it will not trigger a highlight even if it contains
|
||||
aria-label="If a message contains any of these comma-separated
|
||||
expressions, it will not trigger a highlight even if it contains
|
||||
your nickname or expressions defined in custom highlights."
|
||||
>
|
||||
<button class="extra-help" />
|
||||
@ -394,6 +411,7 @@ your nickname or expressions defined in custom highlights."
|
||||
type="text"
|
||||
name="highlightExceptions"
|
||||
class="input"
|
||||
autocomplete="off"
|
||||
placeholder="Comma-separated, e.g.: word, some more words, anotherword"
|
||||
/>
|
||||
</label>
|
||||
|
@ -107,6 +107,10 @@ body {
|
||||
overflow: hidden; /* iOS Safari requires overflow rather than overflow-y */
|
||||
}
|
||||
|
||||
body.force-no-select * {
|
||||
user-select: none !important;
|
||||
}
|
||||
|
||||
a,
|
||||
a:hover,
|
||||
a:focus {
|
||||
@ -333,6 +337,7 @@ p {
|
||||
.channel-list-item .not-connected-icon::before,
|
||||
.channel-list-item .parted-channel-icon::before,
|
||||
.jump-to-input::before,
|
||||
.password-container .reveal-password span,
|
||||
#sidebar .collapse-network-icon::before {
|
||||
font: normal normal normal 14px/1 FontAwesome;
|
||||
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 */
|
||||
.ui-sortable-ghost,
|
||||
.channel-list-item.ui-sortable-dragged,
|
||||
.ui-sortable-dragged .channel-list-item,
|
||||
.ui-sortable-active .channel-list-item:hover,
|
||||
.ui-sortable-active .channel-list-item.active {
|
||||
.ui-sortable-dragging .channel-list-item,
|
||||
.ui-sortable-dragging,
|
||||
.ui-sortable-dragging:hover,
|
||||
.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;
|
||||
}
|
||||
|
||||
.ui-sortable-ghost::after {
|
||||
.ui-sortable-ghost::after,
|
||||
.ui-sortable-dragging-touch-cue:not(.ui-sortable-dragging)::after {
|
||||
background: var(--body-bg-color);
|
||||
border: 1px dashed #99a2b4;
|
||||
border-radius: 6px;
|
||||
@ -727,6 +737,10 @@ background on hover (unless active) */
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
.ui-sortable-dragging-touch-cue:not(.ui-sortable-ghost)::after {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#sidebar .network {
|
||||
position: relative;
|
||||
margin-bottom: 20px;
|
||||
@ -1038,7 +1052,10 @@ textarea.input {
|
||||
.header .title {
|
||||
font-size: 15px;
|
||||
padding-left: 6px;
|
||||
flex-shrink: 0;
|
||||
flex-shrink: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.topic-container {
|
||||
@ -1054,6 +1071,12 @@ textarea.input {
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
flex-shrink: 99999999;
|
||||
min-width: 25px;
|
||||
}
|
||||
|
||||
.header .topic.empty {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header .topic-input {
|
||||
@ -1067,6 +1090,7 @@ textarea.input {
|
||||
height: 35px;
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
line-height: normal;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@ -1689,6 +1713,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
||||
|
||||
#chat .userlist .search {
|
||||
color: var(--body-color);
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: none;
|
||||
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 {
|
||||
font: normal normal normal 14px/1 FontAwesome;
|
||||
font-size: 16px;
|
||||
color: #607992;
|
||||
width: 35px;
|
||||
@ -2024,6 +2048,10 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
||||
#help .help-item .subject.gesture {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#help .help-item .description p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@ -2234,6 +2262,14 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#context-menu-container.passthrough {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#context-menu-container.passthrough > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.mentions-popup,
|
||||
#context-menu,
|
||||
.textcomplete-menu {
|
||||
@ -2635,6 +2671,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
||||
right: 0;
|
||||
transform: translateX(180px);
|
||||
transition: transform 0.2s;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#viewport.userlist-open #chat .userlist {
|
||||
|
@ -252,22 +252,30 @@ export function generateUserContextMenu($root, channel, network, user) {
|
||||
return items;
|
||||
}
|
||||
|
||||
// Names of the modes we are able to change
|
||||
const modes = {
|
||||
"~": ["owner", "q"],
|
||||
"&": ["admin", "a"],
|
||||
"@": ["operator", "o"],
|
||||
"%": ["half-op", "h"],
|
||||
"+": ["voice", "v"],
|
||||
// Names of the standard modes we are able to change
|
||||
const modeCharToName = {
|
||||
"~": "owner",
|
||||
"&": "admin",
|
||||
"@": "operator",
|
||||
"%": "half-op",
|
||||
"+": "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 = {
|
||||
revoke: (m) => `Revoke ${m[0]} (-${m[1]})`,
|
||||
give: (m) => `Give ${m[0]} (+${m[1]})`,
|
||||
revoke(m) {
|
||||
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.
|
||||
@ -284,38 +292,38 @@ export function generateUserContextMenu($root, channel, network, user) {
|
||||
function compare(p1, p2) {
|
||||
// The modes ~ and @ can perform actions on their own mode. The others on modes below.
|
||||
return "~@".indexOf(p1) > -1
|
||||
? networkModes.indexOf(p1) <= networkModes.indexOf(p2)
|
||||
: networkModes.indexOf(p1) < networkModes.indexOf(p2);
|
||||
? networkModeSymbols.indexOf(p1) <= networkModeSymbols.indexOf(p2)
|
||||
: networkModeSymbols.indexOf(p1) < networkModeSymbols.indexOf(p2);
|
||||
}
|
||||
|
||||
networkModes.forEach((prefix) => {
|
||||
if (!compare(currentChannelUser.modes[0], prefix)) {
|
||||
network.serverOptions.PREFIX.prefix.forEach((mode) => {
|
||||
if (!compare(currentChannelUser.modes[0], mode.symbol)) {
|
||||
// Our highest mode is below the current mode. Bail.
|
||||
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.
|
||||
items.push({
|
||||
label: modeTextTemplate.give(modes[prefix]),
|
||||
label: modeTextTemplate.give(mode),
|
||||
type: "item",
|
||||
class: "action-set-mode",
|
||||
action() {
|
||||
socket.emit("input", {
|
||||
target: channel.id,
|
||||
text: "/mode +" + modes[prefix][1] + " " + user.nick,
|
||||
text: "/mode +" + mode.mode + " " + user.nick,
|
||||
});
|
||||
},
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
label: modeTextTemplate.revoke(modes[prefix]),
|
||||
label: modeTextTemplate.revoke(mode),
|
||||
type: "item",
|
||||
class: "action-revoke-mode",
|
||||
action() {
|
||||
socket.emit("input", {
|
||||
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.
|
||||
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])) {
|
||||
// Check if the target user has no mode or a mode lower than ours.
|
||||
items.push({
|
||||
label: "Kick",
|
||||
type: "item",
|
||||
|
5
client/js/helpers/distance.js
Normal file
5
client/js/helpers/distance.js
Normal file
@ -0,0 +1,5 @@
|
||||
function distance([x1, y1], [x2, y2]) {
|
||||
return Math.hypot(x1 - x2, y1 - y2);
|
||||
}
|
||||
|
||||
export default distance;
|
106
client/js/helpers/listenForTwoFingerSwipes.js
Normal file
106
client/js/helpers/listenForTwoFingerSwipes.js
Normal 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;
|
@ -79,7 +79,7 @@ function parse(createElement, text, message = undefined, network = undefined) {
|
||||
// arrays of objects containing start and end markers, as well as metadata
|
||||
// depending on what was found (channel or link).
|
||||
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 linkParts = findLinks(cleanText);
|
||||
const emojiParts = findEmoji(cleanText);
|
||||
|
@ -1238,8 +1238,8 @@
|
||||
"credit_card": "💳",
|
||||
"receipt": "🧾",
|
||||
"chart": "💹",
|
||||
"email": "✉️",
|
||||
"envelope": "✉️",
|
||||
"email": "📧",
|
||||
"e_mail": "📧",
|
||||
"incoming_envelope": "📨",
|
||||
"envelope_with_arrow": "📩",
|
||||
|
@ -3,9 +3,10 @@
|
||||
import Mousetrap from "mousetrap";
|
||||
|
||||
import store from "./store";
|
||||
import {switchToChannel} from "./router";
|
||||
import {switchToChannel, router, navigate} from "./router";
|
||||
import isChannelCollapsed from "./helpers/isChannelCollapsed";
|
||||
import isIgnoredKeybind from "./helpers/isIgnoredKeybind";
|
||||
import listenForTwoFingerSwipes from "./helpers/listenForTwoFingerSwipes";
|
||||
|
||||
// Switch to the next/previous window in the channel list.
|
||||
Mousetrap.bind(["alt+up", "alt+down"], function (e, keys) {
|
||||
@ -13,11 +14,22 @@ Mousetrap.bind(["alt+up", "alt+down"], function (e, keys) {
|
||||
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) {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
const direction = keys.split("+").pop() === "up" ? -1 : 1;
|
||||
const flatChannels = [];
|
||||
let index = -1;
|
||||
|
||||
@ -44,9 +56,7 @@ Mousetrap.bind(["alt+up", "alt+down"], function (e, keys) {
|
||||
index = (((index + direction) % length) + length) % length;
|
||||
|
||||
jumpToChannel(flatChannels[index]);
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// Switch to the next/previous lobby in the channel list
|
||||
Mousetrap.bind(["alt+shift+up", "alt+shift+down"], function (e, keys) {
|
||||
@ -107,6 +117,17 @@ Mousetrap.bind(["alt+a"], function (e) {
|
||||
return false;
|
||||
});
|
||||
|
||||
// Show the help menu.
|
||||
Mousetrap.bind(["alt+/"], function (e) {
|
||||
if (isIgnoredKeybind(e)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
navigate("Help");
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
function jumpToChannel(targetChannel) {
|
||||
switchToChannel(targetChannel);
|
||||
|
||||
@ -156,6 +177,12 @@ const ignoredKeys = {
|
||||
};
|
||||
|
||||
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 keys defined above
|
||||
if (e.altKey || ignoredKeys[e.which]) {
|
||||
|
@ -98,7 +98,7 @@ export const config = normalizeConfig({
|
||||
media: {
|
||||
default: true,
|
||||
},
|
||||
removeImageMetadata: {
|
||||
uploadCanvas: {
|
||||
default: true,
|
||||
},
|
||||
userStyles: {
|
||||
|
@ -86,15 +86,6 @@ function loadFromLocalStorage() {
|
||||
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;
|
||||
}
|
||||
|
||||
|
@ -12,6 +12,8 @@ function detectDesktopNotificationState() {
|
||||
return "unsupported";
|
||||
} else if (Notification.permission === "granted") {
|
||||
return "granted";
|
||||
} else if (!window.isSecureContext) {
|
||||
return "nohttps";
|
||||
}
|
||||
|
||||
return "blocked";
|
||||
|
@ -142,7 +142,46 @@ class Uploader {
|
||||
// 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.
|
||||
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) {
|
||||
@ -185,7 +224,6 @@ class Uploader {
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("removeMetadata", store.state.settings.removeImageMetadata);
|
||||
formData.append("file", file);
|
||||
this.xhr.open("POST", `uploads/new/${token}`);
|
||||
this.xhr.send(formData);
|
||||
|
@ -152,8 +152,9 @@ module.exports = {
|
||||
|
||||
// ### prefetchMaxSearchSize
|
||||
//
|
||||
// This value sets the maximum request size made to find the Open Graph tags
|
||||
// for link previews. For some sites like YouTube this can easily exceed 300
|
||||
// This value sets the maximum response size allowed when finding the Open
|
||||
// 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.
|
||||
//
|
||||
// This value is set to `50` kilobytes by default.
|
||||
|
46
package.json
46
package.json
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "thelounge",
|
||||
"description": "The self-hosted Web IRC client",
|
||||
"version": "4.3.0-pre.4",
|
||||
"version": "4.3.0-pre.6",
|
||||
"preferGlobal": true,
|
||||
"bin": {
|
||||
"thelounge": "index.js"
|
||||
@ -37,45 +37,44 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.15.0"
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "2.4.3",
|
||||
"busboy": "0.3.1",
|
||||
"chalk": "4.1.1",
|
||||
"chalk": "4.1.2",
|
||||
"cheerio": "1.0.0-rc.10",
|
||||
"commander": "7.2.0",
|
||||
"content-disposition": "0.5.3",
|
||||
"express": "4.17.1",
|
||||
"file-type": "16.2.0",
|
||||
"filenamify": "4.2.0",
|
||||
"got": "11.8.1",
|
||||
"got": "11.8.2",
|
||||
"irc-framework": "4.11.0",
|
||||
"is-utf8": "0.2.1",
|
||||
"ldapjs": "2.2.3",
|
||||
"linkify-it": "3.0.2",
|
||||
"ldapjs": "2.3.1",
|
||||
"linkify-it": "3.0.3",
|
||||
"lodash": "4.17.21",
|
||||
"mime-types": "2.1.28",
|
||||
"mime-types": "2.1.33",
|
||||
"node-forge": "0.10.0",
|
||||
"package-json": "6.5.0",
|
||||
"read": "1.0.7",
|
||||
"read-chunk": "3.2.0",
|
||||
"semver": "7.3.4",
|
||||
"sharp": "0.28.0",
|
||||
"semver": "7.3.5",
|
||||
"socket.io": "3.1.2",
|
||||
"tlds": "1.216.0",
|
||||
"ua-parser-js": "0.7.24",
|
||||
"ua-parser-js": "0.7.30",
|
||||
"uuid": "8.3.2",
|
||||
"web-push": "3.4.4",
|
||||
"web-push": "3.4.5",
|
||||
"yarn": "1.22.10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"sqlite3": "5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.14.6",
|
||||
"@babel/preset-env": "7.14.7",
|
||||
"@fortawesome/fontawesome-free": "5.15.3",
|
||||
"@babel/core": "7.15.5",
|
||||
"@babel/preset-env": "7.15.6",
|
||||
"@fortawesome/fontawesome-free": "5.15.4",
|
||||
"@vue/server-test-utils": "1.1.3",
|
||||
"@vue/test-utils": "1.1.3",
|
||||
"babel-loader": "8.2.2",
|
||||
@ -84,13 +83,13 @@
|
||||
"copy-webpack-plugin": "7.0.0",
|
||||
"css-loader": "5.1.1",
|
||||
"cssnano": "4.1.11",
|
||||
"dayjs": "1.10.5",
|
||||
"emoji-regex": "9.2.1",
|
||||
"dayjs": "1.10.7",
|
||||
"emoji-regex": "9.2.2",
|
||||
"eslint": "7.23.0",
|
||||
"eslint-config-prettier": "6.15.0",
|
||||
"eslint-plugin-vue": "7.5.0",
|
||||
"fuzzy": "0.1.3",
|
||||
"husky": "4.3.5",
|
||||
"husky": "4.3.8",
|
||||
"mini-css-extract-plugin": "1.3.6",
|
||||
"mocha": "8.2.1",
|
||||
"mousetrap": "1.6.5",
|
||||
@ -98,15 +97,15 @@
|
||||
"npm-run-all": "4.1.5",
|
||||
"nyc": "15.1.0",
|
||||
"postcss": "8.2.10",
|
||||
"postcss-import": "14.0.0",
|
||||
"postcss-import": "14.0.2",
|
||||
"postcss-loader": "5.0.0",
|
||||
"postcss-preset-env": "6.7.0",
|
||||
"prettier": "2.2.1",
|
||||
"pretty-quick": "3.1.0",
|
||||
"pretty-quick": "3.1.1",
|
||||
"primer-tooltips": "2.0.0",
|
||||
"sinon": "9.2.4",
|
||||
"socket.io-client": "3.1.1",
|
||||
"stylelint": "13.9.0",
|
||||
"socket.io-client": "3.1.3",
|
||||
"stylelint": "13.13.1",
|
||||
"stylelint-config-standard": "20.0.0",
|
||||
"textcomplete": "0.18.2",
|
||||
"undate": "0.3.0",
|
||||
@ -120,11 +119,14 @@
|
||||
"webpack": "5.21.2",
|
||||
"webpack-cli": "4.5.0",
|
||||
"webpack-dev-middleware": "4.1.0",
|
||||
"webpack-hot-middleware": "2.25.0"
|
||||
"webpack-hot-middleware": "2.25.1"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "pretty-quick --staged"
|
||||
}
|
||||
},
|
||||
"resolutions": {
|
||||
"sortablejs": "git+https://github.com/itsjohncs/Sortable.git"
|
||||
}
|
||||
}
|
||||
|
0
scripts/generate-emoji.js
Normal file → Executable file
0
scripts/generate-emoji.js
Normal file → Executable file
@ -104,11 +104,6 @@ function Client(manager, name, config = {}) {
|
||||
delete client.config.awayMessage;
|
||||
}
|
||||
|
||||
if (client.config.uploadCanvas) {
|
||||
client.config.clientSettings.removeImageMetadata = client.config.uploadCanvas;
|
||||
delete client.config.uploadCanvas;
|
||||
}
|
||||
|
||||
if (client.config.clientSettings.awayMessage) {
|
||||
client.awayMessage = client.config.clientSettings.awayMessage;
|
||||
}
|
||||
|
@ -43,6 +43,7 @@ const Helper = {
|
||||
getDefaultNick,
|
||||
parseHostmask,
|
||||
compareHostmask,
|
||||
compareWithWildcard,
|
||||
|
||||
password: {
|
||||
hash: passwordHash,
|
||||
@ -314,8 +315,27 @@ function parseHostmask(hostmask) {
|
||||
|
||||
function compareHostmask(a, b) {
|
||||
return (
|
||||
(a.nick.toLowerCase() === b.nick.toLowerCase() || a.nick === "*") &&
|
||||
(a.ident.toLowerCase() === b.ident.toLowerCase() || a.ident === "*") &&
|
||||
(a.hostname.toLowerCase() === b.hostname.toLowerCase() || a.hostname === "*")
|
||||
compareWithWildcard(a.nick, b.nick) &&
|
||||
compareWithWildcard(a.ident, b.ident) &&
|
||||
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);
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ const {v4: uuidv4} = require("uuid");
|
||||
const IrcFramework = require("irc-framework");
|
||||
const Chan = require("./chan");
|
||||
const Msg = require("./msg");
|
||||
const Prefix = require("./prefix");
|
||||
const Helper = require("../helper");
|
||||
const STSPolicies = require("../plugins/sts");
|
||||
const ClientCertificate = require("../plugins/clientCertificate");
|
||||
@ -43,7 +44,12 @@ function Network(attr) {
|
||||
irc: null,
|
||||
serverOptions: {
|
||||
CHANTYPES: ["#", "&"],
|
||||
PREFIX: ["!", "@", "%", "+"],
|
||||
PREFIX: new Prefix([
|
||||
{symbol: "!", mode: "Y"},
|
||||
{symbol: "@", mode: "o"},
|
||||
{symbol: "%", mode: "h"},
|
||||
{symbol: "+", mode: "v"},
|
||||
]),
|
||||
NETWORK: "",
|
||||
},
|
||||
|
||||
@ -532,6 +538,7 @@ Network.prototype.export = function () {
|
||||
"proxyPort",
|
||||
"proxyUsername",
|
||||
"proxyEnabled",
|
||||
"proxyPassword",
|
||||
]);
|
||||
|
||||
network.channels = this.channels
|
||||
|
33
src/models/prefix.js
Normal file
33
src/models/prefix.js
Normal 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;
|
@ -4,7 +4,7 @@ const _ = require("lodash");
|
||||
|
||||
module.exports = User;
|
||||
|
||||
function User(attr, prefixLookup) {
|
||||
function User(attr, prefix) {
|
||||
_.defaults(this, attr, {
|
||||
modes: [],
|
||||
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
|
||||
this.modes = modes.map((mode) => prefixLookup[mode]);
|
||||
this.modes = modes.map((mode) => prefix.modeToSymbol[mode]);
|
||||
};
|
||||
|
||||
User.prototype.toJSON = function () {
|
||||
|
@ -23,7 +23,9 @@ exports.input = function ({irc, nick}, chan, cmd, args) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.length === 0) {
|
||||
const target = args.filter((arg) => arg !== "");
|
||||
|
||||
if (target.length === 0) {
|
||||
chan.pushMessage(
|
||||
this,
|
||||
new Msg({
|
||||
@ -44,9 +46,13 @@ exports.input = function ({irc, nick}, chan, cmd, args) {
|
||||
devoice: "-v",
|
||||
}[cmd];
|
||||
|
||||
args.forEach(function (target) {
|
||||
irc.raw("MODE", chan.name, mode, target);
|
||||
});
|
||||
const limit = parseInt(irc.network.supports("MODES")) || target.length;
|
||||
|
||||
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;
|
||||
}
|
||||
|
@ -63,10 +63,9 @@ module.exports = function (irc, network) {
|
||||
});
|
||||
|
||||
irc.on("socket connected", function () {
|
||||
network.prefixLookup = {};
|
||||
irc.network.options.PREFIX.forEach(function (mode) {
|
||||
network.prefixLookup[mode.mode] = mode.symbol;
|
||||
});
|
||||
if (irc.network.options.PREFIX) {
|
||||
network.serverOptions.PREFIX.update(irc.network.options.PREFIX);
|
||||
}
|
||||
|
||||
network.channels[0].pushMessage(
|
||||
client,
|
||||
@ -197,20 +196,12 @@ module.exports = function (irc, network) {
|
||||
});
|
||||
|
||||
irc.on("server options", function (data) {
|
||||
network.prefixLookup = {};
|
||||
|
||||
data.options.PREFIX.forEach((mode) => {
|
||||
network.prefixLookup[mode.mode] = mode.symbol;
|
||||
});
|
||||
network.serverOptions.PREFIX.update(data.options.PREFIX);
|
||||
|
||||
if (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;
|
||||
|
||||
client.emit("network:options", {
|
||||
|
@ -11,12 +11,6 @@ module.exports = function (irc, network) {
|
||||
const client = this;
|
||||
|
||||
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;
|
||||
handleMessage(data);
|
||||
});
|
||||
@ -44,6 +38,12 @@ module.exports = function (irc, network) {
|
||||
let showInActive = false;
|
||||
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
|
||||
const shouldIgnore =
|
||||
!self &&
|
||||
|
@ -107,7 +107,7 @@ module.exports = function (irc, network) {
|
||||
return;
|
||||
}
|
||||
|
||||
const changedMode = network.prefixLookup[char];
|
||||
const changedMode = network.serverOptions.PREFIX.modeToSymbol[char];
|
||||
|
||||
if (!add) {
|
||||
_.pull(user.modes, changedMode);
|
||||
|
@ -14,7 +14,7 @@ module.exports = function (irc, network) {
|
||||
|
||||
data.users.forEach((user) => {
|
||||
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);
|
||||
});
|
||||
|
@ -236,7 +236,7 @@ class MessageStorage {
|
||||
target: query.channelName,
|
||||
networkUuid: query.networkUuid,
|
||||
offset: query.offset,
|
||||
results: parseSearchRowsToMessages(query.offset, rows),
|
||||
results: parseSearchRowsToMessages(query.offset, rows).reverse(),
|
||||
};
|
||||
resolve(response);
|
||||
}
|
||||
|
@ -46,6 +46,13 @@ const packageApis = function (packageInfo) {
|
||||
},
|
||||
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 [];
|
||||
}
|
||||
|
||||
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) {
|
||||
let packageInfo;
|
||||
let packageFile;
|
||||
|
@ -11,7 +11,6 @@ const crypto = require("crypto");
|
||||
const isUtf8 = require("is-utf8");
|
||||
const log = require("../log");
|
||||
const contentDisposition = require("content-disposition");
|
||||
const sharp = require("sharp");
|
||||
|
||||
// Map of allowed mime types to their respecive default filenames
|
||||
// that will be rendered in browser without forcing them to be downloaded
|
||||
@ -134,7 +133,6 @@ class Uploader {
|
||||
let destDir;
|
||||
let destPath;
|
||||
let streamWriter;
|
||||
let removeMetadata;
|
||||
|
||||
const doneCallback = () => {
|
||||
// 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) => {
|
||||
doneCallback();
|
||||
|
||||
@ -212,11 +197,6 @@ class Uploader {
|
||||
busboyInstance.on("partsLimit", () => abortWithError(Error("Parts limit reached")));
|
||||
busboyInstance.on("filesLimit", () => abortWithError(Error("Files 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
|
||||
// we use do/while loop to prevent the rare case of generating a file name
|
||||
@ -237,7 +217,11 @@ class Uploader {
|
||||
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)}`;
|
||||
|
||||
if (Helper.config.fileUpload.baseUrl) {
|
||||
@ -246,55 +230,31 @@ class Uploader {
|
||||
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
|
||||
// abort the processing with an error
|
||||
fileStream.on("error", abortWithError);
|
||||
fileStream.on("limit", () => {
|
||||
if (!isImage) {
|
||||
fileStream.unpipe(streamWriter);
|
||||
}
|
||||
|
||||
fileStream.unpipe(streamWriter);
|
||||
fileStream.on("readable", fileStream.read.bind(fileStream));
|
||||
|
||||
abortWithError(Error("File size limit reached"));
|
||||
});
|
||||
|
||||
if (isImage) {
|
||||
let sharpInstance = sharp({
|
||||
animated: true,
|
||||
pages: -1,
|
||||
sequentialRead: true,
|
||||
});
|
||||
// Attempt to write the stream to file
|
||||
fileStream.pipe(streamWriter);
|
||||
});
|
||||
|
||||
if (!removeMetadata) {
|
||||
sharpInstance = sharpInstance.withMetadata();
|
||||
}
|
||||
busboyInstance.on("finish", () => {
|
||||
doneCallback();
|
||||
|
||||
sharpInstance
|
||||
.rotate() // auto-orient based on the EXIF Orientation tag
|
||||
.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);
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
// pipe request body to busboy for processing
|
||||
|
@ -50,6 +50,7 @@ module.exports = function (options = {}) {
|
||||
app.set("env", "production")
|
||||
.disable("x-powered-by")
|
||||
.use(allRequests)
|
||||
.use(addSecurityHeaders)
|
||||
.get("/", indexRequest)
|
||||
.get("/service-worker.js", forceNoCacheRequest)
|
||||
.get("/js/bundle.js.map", forceNoCacheRequest)
|
||||
@ -286,14 +287,7 @@ function allRequests(req, res, next) {
|
||||
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) {
|
||||
function addSecurityHeaders(req, res, next) {
|
||||
const policies = [
|
||||
"default-src 'none'", // default to nothing
|
||||
"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:");
|
||||
}
|
||||
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
res.setHeader("Content-Security-Policy", policies.join("; "));
|
||||
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(
|
||||
path.join(__dirname, "..", "client", "index.html.tpl"),
|
||||
"utf-8",
|
||||
@ -538,7 +544,7 @@ function initializeClient(socket, client, token, lastMessage, openChannel) {
|
||||
socket.emit("mentions:list", client.mentions);
|
||||
});
|
||||
|
||||
socket.on("mentions:hide", (msgId) => {
|
||||
socket.on("mentions:dismiss", (msgId) => {
|
||||
if (typeof msgId !== "number") {
|
||||
return;
|
||||
}
|
||||
@ -549,7 +555,7 @@ function initializeClient(socket, client, token, lastMessage, openChannel) {
|
||||
);
|
||||
});
|
||||
|
||||
socket.on("mentions:hide_all", () => {
|
||||
socket.on("mentions:dismiss_all", () => {
|
||||
client.mentions = [];
|
||||
});
|
||||
|
||||
|
@ -17,15 +17,38 @@ describe("Commands", function () {
|
||||
});
|
||||
|
||||
const testableNetwork = {
|
||||
firstCommand: null,
|
||||
lastCommand: null,
|
||||
nick: "xPaw",
|
||||
irc: {
|
||||
network: {
|
||||
supports(type) {
|
||||
if (type.toUpperCase() === "MODES") {
|
||||
return "4";
|
||||
}
|
||||
},
|
||||
},
|
||||
raw(...args) {
|
||||
testableNetwork.firstCommand = testableNetwork.lastCommand;
|
||||
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 () {
|
||||
const test = function (expected, args) {
|
||||
ModeCommand.input(testableNetwork, channel, "mode", Array.from(args));
|
||||
@ -81,10 +104,34 @@ describe("Commands", function () {
|
||||
|
||||
ModeCommand.input(testableNetwork, channel, "devoice", ["xPaw"]);
|
||||
expect(testableNetwork.lastCommand).to.equal("MODE #thelounge -v xPaw");
|
||||
});
|
||||
|
||||
// Multiple arguments are supported, sent as separate commands
|
||||
ModeCommand.input(testableNetwork, channel, "devoice", ["xPaw", "Max-P"]);
|
||||
expect(testableNetwork.lastCommand).to.equal("MODE #thelounge -v Max-P");
|
||||
it("should use ISUPPORT MODES on shorthand commands", function () {
|
||||
ModeCommand.input(testableNetwork, channel, "voice", ["xPaw", "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"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -20,10 +20,10 @@ describe("Chan", function () {
|
||||
},
|
||||
};
|
||||
|
||||
const prefixLookup = {};
|
||||
const prefixLookup = {modeToSymbol: {}};
|
||||
|
||||
network.network.options.PREFIX.forEach((mode) => {
|
||||
prefixLookup[mode.mode] = mode.symbol;
|
||||
prefixLookup.modeToSymbol[mode.mode] = mode.symbol;
|
||||
});
|
||||
|
||||
describe("#findMessage(id)", function () {
|
||||
|
@ -8,7 +8,7 @@ const User = require("../../src/models/user");
|
||||
describe("Msg", function () {
|
||||
["from", "target"].forEach((prop) => {
|
||||
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(
|
||||
{
|
||||
modes: ["o"],
|
||||
|
@ -49,6 +49,7 @@ describe("Network", function () {
|
||||
proxyEnabled: false,
|
||||
proxyHost: "",
|
||||
proxyPort: 1080,
|
||||
proxyPassword: "",
|
||||
proxyUsername: "",
|
||||
channels: [
|
||||
{name: "#thelounge", key: ""},
|
||||
|
@ -37,10 +37,9 @@ describe("SQLite Message Storage", function () {
|
||||
fs.rmdir(path.join(Helper.getHomePath(), "logs"), done);
|
||||
});
|
||||
|
||||
it("should resolve an empty array when disabled", function (done) {
|
||||
store.getMessages(null, null).then((messages) => {
|
||||
it("should resolve an empty array when disabled", function () {
|
||||
return store.getMessages(null, null).then((messages) => {
|
||||
expect(messages).to.be.empty;
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
@ -54,91 +53,134 @@ describe("SQLite Message Storage", function () {
|
||||
});
|
||||
|
||||
it("should create tables", function (done) {
|
||||
store.database.serialize(() =>
|
||||
store.database.all(
|
||||
"SELECT name, tbl_name, sql FROM sqlite_master WHERE type = 'table'",
|
||||
(err, row) => {
|
||||
expect(err).to.be.null;
|
||||
expect(row).to.deep.equal([
|
||||
{
|
||||
name: "options",
|
||||
tbl_name: "options",
|
||||
sql:
|
||||
"CREATE TABLE options (name TEXT, value TEXT, CONSTRAINT name_unique UNIQUE (name))",
|
||||
},
|
||||
{
|
||||
name: "messages",
|
||||
tbl_name: "messages",
|
||||
sql:
|
||||
"CREATE TABLE messages (network TEXT, channel TEXT, time INTEGER, type TEXT, msg TEXT)",
|
||||
},
|
||||
]);
|
||||
store.database.all(
|
||||
"SELECT name, tbl_name, sql FROM sqlite_master WHERE type = 'table'",
|
||||
(err, row) => {
|
||||
expect(err).to.be.null;
|
||||
expect(row).to.deep.equal([
|
||||
{
|
||||
name: "options",
|
||||
tbl_name: "options",
|
||||
sql:
|
||||
"CREATE TABLE options (name TEXT, value TEXT, CONSTRAINT name_unique UNIQUE (name))",
|
||||
},
|
||||
{
|
||||
name: "messages",
|
||||
tbl_name: "messages",
|
||||
sql:
|
||||
"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) {
|
||||
store.database.serialize(() =>
|
||||
store.database.get(
|
||||
"SELECT value FROM options WHERE name = 'schema_version'",
|
||||
(err, row) => {
|
||||
expect(err).to.be.null;
|
||||
store.database.get(
|
||||
"SELECT value FROM options WHERE name = 'schema_version'",
|
||||
(err, row) => {
|
||||
expect(err).to.be.null;
|
||||
|
||||
// Should be sqlite.currentSchemaVersion,
|
||||
// compared as string because it's returned as such from the database
|
||||
expect(row.value).to.equal("1520239200");
|
||||
// Should be sqlite.currentSchemaVersion,
|
||||
// compared as string because it's returned as such from the database
|
||||
expect(row.value).to.equal("1520239200");
|
||||
|
||||
done();
|
||||
}
|
||||
)
|
||||
done();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("should store a message", function (done) {
|
||||
store.database.serialize(() => {
|
||||
store.index(
|
||||
it("should store a message", function () {
|
||||
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",
|
||||
},
|
||||
{
|
||||
name: "#thisISaCHANNEL",
|
||||
},
|
||||
new Msg({
|
||||
time: 123456789,
|
||||
text: "Hello from sqlite world!",
|
||||
})
|
||||
);
|
||||
name: "#thisisaCHANNEL",
|
||||
}
|
||||
)
|
||||
.then((messages) => {
|
||||
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) {
|
||||
store.database.serialize(() =>
|
||||
store
|
||||
.getMessages(
|
||||
{
|
||||
uuid: "this-is-a-network-guid",
|
||||
},
|
||||
{
|
||||
name: "#thisisaCHANNEL",
|
||||
}
|
||||
)
|
||||
it("should retrieve latest LIMIT messages in order", function () {
|
||||
const originalMaxHistory = Helper.config.maxHistory;
|
||||
|
||||
try {
|
||||
Helper.config.maxHistory = 2;
|
||||
|
||||
for (let i = 0; i < 200; ++i) {
|
||||
store.index(
|
||||
{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) => {
|
||||
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!");
|
||||
expect(msg.type).to.equal(Msg.Type.MESSAGE);
|
||||
expect(msg.time.getTime()).to.equal(123456789);
|
||||
try {
|
||||
Helper.config.maxHistory = 2;
|
||||
|
||||
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) {
|
||||
|
@ -48,7 +48,14 @@ describe("Hostmask", function () {
|
||||
|
||||
it(".compareHostmask (wildcard)", function () {
|
||||
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(a, b)).to.be.false;
|
||||
});
|
||||
@ -60,3 +67,47 @@ describe("Hostmask", function () {
|
||||
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;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
@ -85,7 +85,6 @@ const config = {
|
||||
},
|
||||
externals: {
|
||||
json3: "JSON", // socket.io uses json3.js, but we do not target any browsers that need it
|
||||
sharp: "commonjs sharp",
|
||||
},
|
||||
plugins: [
|
||||
new VueLoaderPlugin(),
|
||||
|
Loading…
Reference in New Issue
Block a user