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:
|
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:
|
||||||
|
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 -->
|
<!-- 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)
|
||||||
|
@ -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"
|
||||||
|
@ -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;
|
||||||
|
@ -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);
|
||||||
},
|
},
|
||||||
|
@ -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) {
|
||||||
|
@ -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: {
|
||||||
|
@ -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 {
|
||||||
|
@ -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>
|
||||||
|
@ -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;
|
||||||
|
@ -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"),
|
||||||
},
|
},
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -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);
|
||||||
|
@ -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>
|
||||||
|
@ -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 {
|
||||||
|
@ -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",
|
||||||
|
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
|
// 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);
|
||||||
|
@ -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": "📩",
|
||||||
|
@ -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]) {
|
||||||
|
@ -98,7 +98,7 @@ export const config = normalizeConfig({
|
|||||||
media: {
|
media: {
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
removeImageMetadata: {
|
uploadCanvas: {
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
userStyles: {
|
userStyles: {
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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";
|
||||||
|
@ -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);
|
||||||
|
@ -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.
|
||||||
|
46
package.json
46
package.json
@ -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
0
scripts/generate-emoji.js
Normal file → Executable 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;
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
@ -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
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;
|
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 () {
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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", {
|
||||||
|
@ -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 &&
|
||||||
|
@ -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);
|
||||||
|
@ -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);
|
||||||
});
|
});
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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
|
||||||
|
@ -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 = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -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"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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 () {
|
||||||
|
@ -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"],
|
||||||
|
@ -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: ""},
|
||||||
|
@ -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) {
|
||||||
|
@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
@ -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(),
|
||||||
|
Loading…
Reference in New Issue
Block a user