Merge branch 'master' into fix-search-query

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

880
yarn.lock

File diff suppressed because it is too large Load diff