TypeScript and Vue 3 (#4559)

Co-authored-by: Eric Nemchik <eric@nemchik.com>
Co-authored-by: Pavel Djundik <xPaw@users.noreply.github.com>
This commit is contained in:
Max Leiter 2022-06-18 16:25:21 -08:00 committed by GitHub
parent 2e3d9a6265
commit dd05ee3a65
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
349 changed files with 13395 additions and 8810 deletions

View File

@ -1,2 +1,3 @@
public/
coverage/
dist/

View File

@ -1,14 +1,18 @@
module.exports = {
root: true,
// @ts-check
const {defineConfig} = require("eslint-define-config");
const projects = defineConfig({
parserOptions: {
ecmaVersion: 2022,
},
env: {
es6: true,
browser: true,
mocha: true,
node: true,
project: [
"./tsconfig.json",
"./client/tsconfig.json",
"./server/tsconfig.json",
"./test/tsconfig.json",
],
},
}).parserOptions.project;
const baseRules = defineConfig({
rules: {
"block-scoped-var": "error",
curly: ["error", "all"],
@ -23,7 +27,6 @@ module.exports = {
"no-else-return": "error",
"no-implicit-globals": "error",
"no-restricted-globals": ["error", "event", "fdescribe"],
"no-shadow": "error",
"no-template-curly-in-string": "error",
"no-unsafe-negation": "error",
"no-useless-computed-key": "error",
@ -62,18 +65,127 @@ module.exports = {
"spaced-comment": ["error", "always"],
strict: "off",
yoda: "error",
},
}).rules;
const vueRules = defineConfig({
rules: {
"import/no-default-export": 0,
"import/unambiguous": 0, // vue SFC can miss script tags
"@typescript-eslint/prefer-readonly": 0, // can be used in template
"vue/component-tags-order": [
"error",
{
order: ["template", "style", "script"],
},
],
"vue/multi-word-component-names": "off",
"vue/no-mutating-props": "off",
"vue/no-v-html": "off",
"vue/require-default-prop": "off",
"vue/v-slot-style": ["error", "longform"],
"vue/multi-word-component-names": "off",
},
plugins: ["vue"],
extends: ["eslint:recommended", "plugin:vue/recommended", "prettier"],
};
}).rules;
const tsRules = defineConfig({
rules: {
// note you must disable the base rule as it can report incorrect errors
"no-shadow": "off",
"@typescript-eslint/no-shadow": ["error"],
},
}).rules;
const tsRulesTemp = defineConfig({
rules: {
// TODO: eventually remove these
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-this-alias": "off",
"@typescript-eslint/no-unnecessary-type-assertion": "off",
"@typescript-eslint/no-unsafe-argument": "off",
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-call": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/no-unused-vars": "off",
},
}).rules;
const tsTestRulesTemp = defineConfig({
rules: {
// TODO: remove these
"@typescript-eslint/no-unsafe-return": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/restrict-plus-operands": "off",
},
}).rules;
module.exports = defineConfig({
root: true,
parserOptions: {
ecmaVersion: 2022,
},
overrides: [
{
files: ["**/*.ts", "**/*.vue"],
parser: "@typescript-eslint/parser",
parserOptions: {
tsconfigRootDir: __dirname,
project: projects,
extraFileExtensions: [".vue"],
},
plugins: ["@typescript-eslint"],
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking",
"prettier",
],
rules: {
...baseRules,
...tsRules,
...tsRulesTemp,
},
},
{
files: ["**/*.vue"],
parser: "vue-eslint-parser",
parserOptions: {
ecmaVersion: 2022,
ecmaFeatures: {
jsx: true,
},
parser: "@typescript-eslint/parser",
tsconfigRootDir: __dirname,
project: projects,
},
plugins: ["vue"],
extends: [
"eslint:recommended",
"plugin:vue/vue3-recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking",
"prettier",
],
rules: {...baseRules, ...tsRules, ...tsRulesTemp, ...vueRules},
},
{
files: ["./tests/**/*.ts"],
parser: "@typescript-eslint/parser",
rules: {
...baseRules,
...tsRules,
...tsRulesTemp,
...tsTestRulesTemp,
},
},
],
env: {
es6: true,
browser: true,
mocha: true,
node: true,
},
extends: ["eslint:recommended", "prettier"],
rules: baseRules,
});

1
.gitignore vendored
View File

@ -6,3 +6,4 @@ package-lock.json
coverage/
public/
dist/

View File

@ -9,9 +9,9 @@
# Ignore client folder as it's being built into public/ folder
# except for the specified files which are used by the server
client/**
!client/js/constants.js
!client/js/helpers/ircmessageparser/findLinks.js
!client/js/helpers/ircmessageparser/cleanIrcMessage.js
!client/js/constants.ts
!client/js/helpers/ircmessageparser/findLinks.ts
!client/js/helpers/ircmessageparser/cleanIrcMessage.ts
!client/index.html.tpl
public/js/bundle.vendor.js.map
@ -22,3 +22,4 @@ appveyor.yml
webpack.config*.js
postcss.config.js
renovate.json

View File

@ -1,9 +1,10 @@
coverage/
public/
dist/
test/fixtures/.thelounge/logs/
test/fixtures/.thelounge/certificates/
test/fixtures/.thelounge/storage/
test/fixtures/.thelounge/sts-policies.json
*.log
*.png
*.svg

View File

@ -3,7 +3,8 @@
"EditorConfig.EditorConfig",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"octref.vetur"
"Vue.volar",
"Vue.vscode-typescript-vue-plugin"
],
"unwantedRecommendations": []
}

View File

@ -1,10 +1,10 @@
{
"editor.formatOnSave": true,
"vetur.format.enable": false,
"prettier.useEditorConfig": true,
"prettier.requireConfig": true,
"prettier.disableLanguages": [],
"prettier.packageManager": "yarn",
"eslint.packageManager": "yarn",
"eslint.codeActionsOnSave.mode": "all"
"eslint.codeActionsOnSave.mode": "all",
"[typescript]": {"editor.defaultFormatter": "esbenp.prettier-vscode"},
"[vue]": {"editor.defaultFormatter": "esbenp.prettier-vscode"}
}

View File

@ -51,7 +51,7 @@ The Lounge is the official and community-managed fork of [Shout](https://github.
## Installation and usage
The Lounge requires latest [Node.js](https://nodejs.org/) LTS version or more recent.
[Yarn package manager](https://yarnpkg.com/) is also recommended.
The [Yarn package manager](https://yarnpkg.com/) is also recommended.
If you want to install with npm, `--unsafe-perm` is required for a correct install.
### Running stable releases
@ -85,5 +85,8 @@ Before submitting any change, make sure to:
- Read the [Contributing instructions](https://github.com/thelounge/thelounge/blob/master/.github/CONTRIBUTING.md#contributing)
- Run `yarn test` to execute linters and the test suite
- Run `yarn format:prettier` if linting fails
- Run `yarn build` if you change or add anything in `client/js` or `client/components`
- Run `yarn build:client` if you change or add anything in `client/js` or `client/components`
- The built files will be output to `public/` by webpack
- Run `yarn build:server` if you change anything in `server/`
- The built files will be output to `dist/` by tsc
- `yarn dev` can be used to start The Lounge with hot module reloading

View File

@ -1,3 +1,4 @@
module.exports = {
presets: [["@babel/env"]],
presets: [["@babel/preset-env", {bugfixes: true}], "babel-preset-typescript-vue3"],
plugins: ["@babel/plugin-transform-runtime"],
};

View File

@ -1,13 +1,13 @@
<template>
<div id="viewport" :class="viewportClasses" role="tablist">
<Sidebar v-if="$store.state.appLoaded" :overlay="$refs.overlay" />
<Sidebar v-if="store.state.appLoaded" :overlay="overlay" />
<div
id="sidebar-overlay"
ref="overlay"
aria-hidden="true"
@click="$store.commit('sidebarOpen', false)"
@click="store.commit('sidebarOpen', false)"
/>
<router-view ref="window"></router-view>
<router-view ref="loungeWindow"></router-view>
<Mentions />
<ImageViewer ref="imageViewer" />
<ContextMenu ref="contextMenu" />
@ -16,10 +16,10 @@
</div>
</template>
<script>
const constants = require("../js/constants");
<script lang="ts">
import constants from "../js/constants";
import eventbus from "../js/eventbus";
import Mousetrap from "mousetrap";
import Mousetrap, {ExtendedKeyboardEvent} from "mousetrap";
import throttle from "lodash/throttle";
import storage from "../js/localStorage";
import isIgnoredKeybind from "../js/helpers/isIgnoredKeybind";
@ -29,8 +29,29 @@ import ImageViewer from "./ImageViewer.vue";
import ContextMenu from "./ContextMenu.vue";
import ConfirmDialog from "./ConfirmDialog.vue";
import Mentions from "./Mentions.vue";
import {
computed,
provide,
defineComponent,
onBeforeUnmount,
onMounted,
ref,
Ref,
InjectionKey,
inject,
} from "vue";
import {useStore} from "../js/store";
import type {DebouncedFunc} from "lodash";
export default {
export const imageViewerKey = Symbol() as InjectionKey<Ref<typeof ImageViewer | null>>;
const contextMenuKey = Symbol() as InjectionKey<Ref<typeof ContextMenu | null>>;
const confirmDialogKey = Symbol() as InjectionKey<Ref<typeof ConfirmDialog | null>>;
export const useImageViewer = () => {
return inject(imageViewerKey) as Ref<typeof ImageViewer | null>;
};
export default defineComponent({
name: "App",
components: {
Sidebar,
@ -39,93 +60,78 @@ export default {
ConfirmDialog,
Mentions,
},
computed: {
viewportClasses() {
setup() {
const store = useStore();
const overlay = ref(null);
const loungeWindow = ref(null);
const imageViewer = ref(null);
const contextMenu = ref(null);
const confirmDialog = ref(null);
provide(imageViewerKey, imageViewer);
provide(contextMenuKey, contextMenu);
provide(confirmDialogKey, confirmDialog);
const viewportClasses = computed(() => {
return {
notified: this.$store.getters.highlightCount > 0,
"menu-open": this.$store.state.appLoaded && this.$store.state.sidebarOpen,
"menu-dragging": this.$store.state.sidebarDragging,
"userlist-open": this.$store.state.userlistOpen,
notified: store.getters.highlightCount > 0,
"menu-open": store.state.appLoaded && store.state.sidebarOpen,
"menu-dragging": store.state.sidebarDragging,
"userlist-open": store.state.userlistOpen,
};
},
},
created() {
this.prepareOpenStates();
},
mounted() {
Mousetrap.bind("esc", this.escapeKey);
Mousetrap.bind("alt+u", this.toggleUserList);
Mousetrap.bind("alt+s", this.toggleSidebar);
Mousetrap.bind("alt+m", this.toggleMentions);
});
// Make a single throttled resize listener available to all components
this.debouncedResize = throttle(() => {
eventbus.emit("resize");
}, 100);
const debouncedResize = ref<DebouncedFunc<() => void>>();
const dayChangeTimeout = ref<any>();
window.addEventListener("resize", this.debouncedResize, {passive: true});
// Emit a daychange event every time the day changes so date markers know when to update themselves
const emitDayChange = () => {
eventbus.emit("daychange");
// This should always be 24h later but re-computing exact value just in case
this.dayChangeTimeout = setTimeout(emitDayChange, this.msUntilNextDay());
const escapeKey = () => {
eventbus.emit("escapekey");
};
this.dayChangeTimeout = setTimeout(emitDayChange, this.msUntilNextDay());
},
beforeDestroy() {
Mousetrap.unbind("esc", this.escapeKey);
Mousetrap.unbind("alt+u", this.toggleUserList);
Mousetrap.unbind("alt+s", this.toggleSidebar);
Mousetrap.unbind("alt+m", this.toggleMentions);
window.removeEventListener("resize", this.debouncedResize);
clearTimeout(this.dayChangeTimeout);
},
methods: {
escapeKey() {
eventbus.emit("escapekey");
},
toggleSidebar(e) {
const toggleSidebar = (e: ExtendedKeyboardEvent) => {
if (isIgnoredKeybind(e)) {
return true;
}
this.$store.commit("toggleSidebar");
store.commit("toggleSidebar");
return false;
},
toggleUserList(e) {
};
const toggleUserList = (e: ExtendedKeyboardEvent) => {
if (isIgnoredKeybind(e)) {
return true;
}
this.$store.commit("toggleUserlist");
store.commit("toggleUserlist");
return false;
},
toggleMentions() {
if (this.$store.state.networks.length !== 0) {
};
const toggleMentions = () => {
if (store.state.networks.length !== 0) {
eventbus.emit("mentions:toggle");
}
},
msUntilNextDay() {
};
const msUntilNextDay = () => {
// Compute how many milliseconds are remaining until the next day starts
const today = new Date();
const tommorow = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1);
const tommorow = new Date(
today.getFullYear(),
today.getMonth(),
today.getDate() + 1
).getTime();
return tommorow - today;
},
prepareOpenStates() {
return tommorow - today.getTime();
};
const prepareOpenStates = () => {
const viewportWidth = window.innerWidth;
let isUserlistOpen = storage.get("thelounge.state.userlist");
if (viewportWidth > constants.mobileViewportPixels) {
this.$store.commit(
"sidebarOpen",
storage.get("thelounge.state.sidebar") !== "false"
);
store.commit("sidebarOpen", storage.get("thelounge.state.sidebar") !== "false");
}
// If The Lounge is opened on a small screen (less than 1024px), and we don't have stored
@ -134,8 +140,61 @@ export default {
isUserlistOpen = "true";
}
this.$store.commit("userlistOpen", isUserlistOpen === "true");
},
store.commit("userlistOpen", isUserlistOpen === "true");
};
prepareOpenStates();
onMounted(() => {
Mousetrap.bind("esc", escapeKey);
Mousetrap.bind("alt+u", toggleUserList);
Mousetrap.bind("alt+s", toggleSidebar);
Mousetrap.bind("alt+m", toggleMentions);
debouncedResize.value = throttle(() => {
eventbus.emit("resize");
}, 100);
window.addEventListener("resize", debouncedResize.value, {passive: true});
// Emit a daychange event every time the day changes so date markers know when to update themselves
const emitDayChange = () => {
eventbus.emit("daychange");
// This should always be 24h later but re-computing exact value just in case
dayChangeTimeout.value = setTimeout(emitDayChange, msUntilNextDay());
};
dayChangeTimeout.value = setTimeout(emitDayChange, msUntilNextDay());
});
onBeforeUnmount(() => {
Mousetrap.unbind("esc");
Mousetrap.unbind("alt+u");
Mousetrap.unbind("alt+s");
Mousetrap.unbind("alt+m");
if (debouncedResize.value) {
window.removeEventListener("resize", debouncedResize.value);
}
if (dayChangeTimeout.value) {
clearTimeout(dayChangeTimeout.value);
}
});
return {
viewportClasses,
escapeKey,
toggleSidebar,
toggleUserList,
toggleMentions,
store,
overlay,
loungeWindow,
imageViewer,
contextMenu,
confirmDialog,
};
},
};
});
</script>

View File

@ -1,4 +1,5 @@
<template>
<!-- TODO: investigate -->
<ChannelWrapper ref="wrapper" v-bind="$props">
<span class="name">{{ channel.name }}</span>
<span
@ -27,30 +28,38 @@
</ChannelWrapper>
</template>
<script>
<script lang="ts">
import {PropType, defineComponent, computed} from "vue";
import roundBadgeNumber from "../js/helpers/roundBadgeNumber";
import useCloseChannel from "../js/hooks/use-close-channel";
import {ClientChan, ClientNetwork} from "../js/types";
import ChannelWrapper from "./ChannelWrapper.vue";
export default {
export default defineComponent({
name: "Channel",
components: {
ChannelWrapper,
},
props: {
network: Object,
channel: Object,
network: {
type: Object as PropType<ClientNetwork>,
required: true,
},
channel: {
type: Object as PropType<ClientChan>,
required: true,
},
active: Boolean,
isFiltering: Boolean,
},
computed: {
unreadCount() {
return roundBadgeNumber(this.channel.unread);
},
setup(props) {
const unreadCount = computed(() => roundBadgeNumber(props.channel.unread));
const close = useCloseChannel(props.channel);
return {
unreadCount,
close,
};
},
methods: {
close() {
this.$root.closeChannel(this.channel);
},
},
};
});
</script>

View File

@ -23,72 +23,90 @@
:data-type="channel.type"
:aria-controls="'#chan-' + channel.id"
:aria-selected="active"
:style="channel.closed ? {transition: 'none', opacity: 0.4} : null"
:style="channel.closed ? {transition: 'none', opacity: 0.4} : undefined"
role="tab"
@click="click"
@contextmenu.prevent="openContextMenu"
>
<slot :network="network" :channel="channel" :activeChannel="activeChannel" />
<slot :network="network" :channel="channel" :active-channel="activeChannel" />
</div>
</template>
<script>
<script lang="ts">
import eventbus from "../js/eventbus";
import isChannelCollapsed from "../js/helpers/isChannelCollapsed";
import {ClientNetwork, ClientChan} from "../js/types";
import {computed, defineComponent, PropType} from "vue";
import {useStore} from "../js/store";
import {switchToChannel} from "../js/router";
export default {
export default defineComponent({
name: "ChannelWrapper",
props: {
network: Object,
channel: Object,
network: {
type: Object as PropType<ClientNetwork>,
required: true,
},
channel: {
type: Object as PropType<ClientChan>,
required: true,
},
active: Boolean,
isFiltering: Boolean,
},
computed: {
activeChannel() {
return this.$store.state.activeChannel;
},
isChannelVisible() {
return this.isFiltering || !isChannelCollapsed(this.network, this.channel);
},
},
methods: {
getAriaLabel() {
const extra = [];
const type = this.channel.type;
setup(props) {
const store = useStore();
const activeChannel = computed(() => store.state.activeChannel);
const isChannelVisible = computed(
() => props.isFiltering || !isChannelCollapsed(props.network, props.channel)
);
if (this.channel.unread > 0) {
if (this.channel.unread > 1) {
extra.push(`${this.channel.unread} unread messages`);
const getAriaLabel = () => {
const extra: string[] = [];
const type = props.channel.type;
if (props.channel.unread > 0) {
if (props.channel.unread > 1) {
extra.push(`${props.channel.unread} unread messages`);
} else {
extra.push(`${this.channel.unread} unread message`);
extra.push(`${props.channel.unread} unread message`);
}
}
if (this.channel.highlight > 0) {
if (this.channel.highlight > 1) {
extra.push(`${this.channel.highlight} mentions`);
if (props.channel.highlight > 0) {
if (props.channel.highlight > 1) {
extra.push(`${props.channel.highlight} mentions`);
} else {
extra.push(`${this.channel.highlight} mention`);
extra.push(`${props.channel.highlight} mention`);
}
}
return `${type}: ${this.channel.name} ${extra.length ? `(${extra.join(", ")})` : ""}`;
},
click() {
if (this.isFiltering) {
return `${type}: ${props.channel.name} ${extra.length ? `(${extra.join(", ")})` : ""}`;
};
const click = () => {
if (props.isFiltering) {
return;
}
this.$root.switchToChannel(this.channel);
},
openContextMenu(event) {
switchToChannel(props.channel);
};
const openContextMenu = (event: MouseEvent) => {
eventbus.emit("contextmenu:channel", {
event: event,
channel: this.channel,
network: this.network,
channel: props.channel,
network: props.network,
});
},
};
return {
activeChannel,
isChannelVisible,
getAriaLabel,
click,
openContextMenu,
};
},
};
});
</script>

View File

@ -3,10 +3,10 @@
<div
id="chat"
:class="{
'hide-motd': !$store.state.settings.motd,
'colored-nicks': $store.state.settings.coloredNicks,
'time-seconds': $store.state.settings.showSeconds,
'time-12h': $store.state.settings.use12hClock,
'hide-motd': store.state.settings.motd,
'colored-nicks': store.state.settings.coloredNicks,
'time-seconds': store.state.settings.showSeconds,
'time-12h': store.state.settings.use12hClock,
}"
>
<div
@ -47,7 +47,7 @@
/></span>
<MessageSearchForm
v-if="
$store.state.settings.searchEnabled &&
store.state.settings.searchEnabled &&
['channel', 'query'].includes(channel.type)
"
:network="network"
@ -71,7 +71,7 @@
<button
class="rt"
aria-label="Toggle user list"
@click="$store.commit('toggleUserlist')"
@click="store.commit('toggleUserlist')"
/>
</span>
</div>
@ -95,7 +95,7 @@
{'scroll-down-shown': !channel.scrolledToBottom},
]"
aria-label="Jump to recent messages"
@click="$refs.messageList.jumpToBottom()"
@click="messageList?.jumpToBottom()"
>
<div class="scroll-down-arrow" />
</div>
@ -110,17 +110,17 @@
</div>
</div>
<div
v-if="$store.state.currentUserVisibleError"
v-if="store.state.currentUserVisibleError"
id="user-visible-error"
@click="hideUserVisibleError"
>
{{ $store.state.currentUserVisibleError }}
{{ store.state.currentUserVisibleError }}
</div>
<ChatInput :network="network" :channel="channel" />
</div>
</template>
<script>
<script lang="ts">
import socket from "../js/socket";
import eventbus from "../js/eventbus";
import ParsedMessage from "./ParsedMessage.vue";
@ -133,8 +133,11 @@ import ListBans from "./Special/ListBans.vue";
import ListInvites from "./Special/ListInvites.vue";
import ListChannels from "./Special/ListChannels.vue";
import ListIgnored from "./Special/ListIgnored.vue";
import {defineComponent, PropType, ref, computed, watch, nextTick, onMounted, Component} from "vue";
import type {ClientNetwork, ClientChan} from "../js/types";
import {useStore} from "../js/store";
export default {
export default defineComponent({
name: "Chat",
components: {
ParsedMessage,
@ -145,93 +148,126 @@ export default {
MessageSearchForm,
},
props: {
network: Object,
channel: Object,
focused: String,
network: {type: Object as PropType<ClientNetwork>, required: true},
channel: {type: Object as PropType<ClientChan>, required: true},
focused: Number,
},
computed: {
specialComponent() {
switch (this.channel.special) {
emits: ["channel-changed"],
setup(props, {emit}) {
const store = useStore();
const messageList = ref<typeof MessageList>();
const topicInput = ref<HTMLInputElement | null>(null);
const specialComponent = computed(() => {
switch (props.channel.special) {
case "list_bans":
return ListBans;
return ListBans as Component;
case "list_invites":
return ListInvites;
return ListInvites as Component;
case "list_channels":
return ListChannels;
return ListChannels as Component;
case "list_ignored":
return ListIgnored;
return ListIgnored as Component;
}
return undefined;
},
},
watch: {
channel() {
this.channelChanged();
},
"channel.editTopic"(newValue) {
if (newValue) {
this.$nextTick(() => {
this.$refs.topicInput.focus();
});
}
},
},
mounted() {
this.channelChanged();
});
if (this.channel.editTopic) {
this.$nextTick(() => {
this.$refs.topicInput.focus();
});
}
},
methods: {
channelChanged() {
const channelChanged = () => {
// Triggered when active channel is set or changed
this.channel.highlight = 0;
this.channel.unread = 0;
emit("channel-changed", props.channel);
socket.emit("open", this.channel.id);
socket.emit("open", props.channel.id);
if (this.channel.usersOutdated) {
this.channel.usersOutdated = false;
if (props.channel.usersOutdated) {
props.channel.usersOutdated = false;
socket.emit("names", {
target: this.channel.id,
target: props.channel.id,
});
}
},
hideUserVisibleError() {
this.$store.commit("currentUserVisibleError", null);
},
editTopic() {
if (this.channel.type === "channel") {
this.channel.editTopic = true;
}
},
saveTopic() {
this.channel.editTopic = false;
const newTopic = this.$refs.topicInput.value;
};
if (this.channel.topic !== newTopic) {
const target = this.channel.id;
const text = `/raw TOPIC ${this.channel.name} :${newTopic}`;
const hideUserVisibleError = () => {
store.commit("currentUserVisibleError", null);
};
const editTopic = () => {
if (props.channel.type === "channel") {
props.channel.editTopic = true;
}
};
const saveTopic = () => {
props.channel.editTopic = false;
if (!topicInput.value) {
return;
}
const newTopic = topicInput.value.value;
if (props.channel.topic !== newTopic) {
const target = props.channel.id;
const text = `/raw TOPIC ${props.channel.name} :${newTopic}`;
socket.emit("input", {target, text});
}
},
openContextMenu(event) {
};
const openContextMenu = (event: any) => {
eventbus.emit("contextmenu:channel", {
event: event,
channel: this.channel,
network: this.network,
channel: props.channel,
network: props.network,
});
},
openMentions(event) {
};
const openMentions = (event: any) => {
eventbus.emit("mentions:toggle", {
event: event,
});
},
};
watch(
() => props.channel,
() => {
channelChanged();
}
);
watch(
() => props.channel.editTopic,
(newTopic) => {
if (newTopic) {
void nextTick(() => {
topicInput.value?.focus();
});
}
}
);
onMounted(() => {
channelChanged();
if (props.channel.editTopic) {
void nextTick(() => {
topicInput.value?.focus();
});
}
});
return {
store,
messageList,
topicInput,
specialComponent,
hideUserVisibleError,
editTopic,
saveTopic,
openContextMenu,
openMentions,
};
},
};
});
</script>

View File

@ -16,7 +16,7 @@
@blur="onBlur"
/>
<span
v-if="$store.state.serverConfiguration.fileUpload"
v-if="store.state.serverConfiguration?.fileUpload"
id="upload-tooltip"
class="tooltipped tooltipped-w tooltipped-no-touch"
aria-label="Upload file"
@ -34,7 +34,7 @@
id="upload"
type="button"
aria-label="Upload file"
:disabled="!$store.state.isConnected"
:disabled="!store.state.isConnected"
/>
</span>
<span
@ -46,13 +46,13 @@
id="submit"
type="submit"
aria-label="Send message"
:disabled="!$store.state.isConnected"
:disabled="!store.state.isConnected"
/>
</span>
</form>
</template>
<script>
<script lang="ts">
import Mousetrap from "mousetrap";
import {wrapCursor} from "undate";
import autocompletion from "../js/autocompletion";
@ -60,6 +60,9 @@ import commands from "../js/commands/index";
import socket from "../js/socket";
import upload from "../js/upload";
import eventbus from "../js/eventbus";
import {watch, defineComponent, nextTick, onMounted, PropType, ref, onUnmounted} from "vue";
import type {ClientNetwork, ClientChan} from "../js/types";
import {useStore} from "../js/store";
const formattingHotkeys = {
"mod+k": "\x03",
@ -86,178 +89,101 @@ const bracketWraps = {
_: "_",
};
let autocompletionRef = null;
export default {
export default defineComponent({
name: "ChatInput",
props: {
network: Object,
channel: Object,
network: {type: Object as PropType<ClientNetwork>, required: true},
channel: {type: Object as PropType<ClientChan>, required: true},
},
watch: {
"channel.id"() {
if (autocompletionRef) {
autocompletionRef.hide();
}
},
"channel.pendingMessage"() {
this.setInputSize();
},
},
mounted() {
eventbus.on("escapekey", this.blurInput);
setup(props) {
const store = useStore();
const input = ref<HTMLTextAreaElement>();
const uploadInput = ref<HTMLInputElement>();
const autocompletionRef = ref<ReturnType<typeof autocompletion>>();
if (this.$store.state.settings.autocomplete) {
autocompletionRef = autocompletion(this.$refs.input);
}
const inputTrap = Mousetrap(this.$refs.input);
inputTrap.bind(Object.keys(formattingHotkeys), function (e, key) {
const modifier = formattingHotkeys[key];
wrapCursor(
e.target,
modifier,
e.target.selectionStart === e.target.selectionEnd ? "" : modifier
);
return false;
});
inputTrap.bind(Object.keys(bracketWraps), function (e, key) {
if (e.target.selectionStart !== e.target.selectionEnd) {
wrapCursor(e.target, key, bracketWraps[key]);
return false;
}
});
inputTrap.bind(["up", "down"], (e, key) => {
if (
this.$store.state.isAutoCompleting ||
e.target.selectionStart !== e.target.selectionEnd
) {
return;
}
const onRow = (
this.$refs.input.value.slice(null, this.$refs.input.selectionStart).match(/\n/g) ||
[]
).length;
const totalRows = (this.$refs.input.value.match(/\n/g) || []).length;
const {channel} = this;
if (channel.inputHistoryPosition === 0) {
channel.inputHistory[channel.inputHistoryPosition] = channel.pendingMessage;
}
if (key === "up" && onRow === 0) {
if (channel.inputHistoryPosition < channel.inputHistory.length - 1) {
channel.inputHistoryPosition++;
} else {
return;
}
} 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;
this.setInputSize();
return false;
});
if (this.$store.state.serverConfiguration.fileUpload) {
upload.mounted();
}
},
destroyed() {
eventbus.off("escapekey", this.blurInput);
if (autocompletionRef) {
autocompletionRef.destroy();
autocompletionRef = null;
}
upload.abort();
},
methods: {
setPendingMessage(e) {
this.channel.pendingMessage = e.target.value;
this.channel.inputHistoryPosition = 0;
this.setInputSize();
},
setInputSize() {
this.$nextTick(() => {
if (!this.$refs.input) {
const setInputSize = () => {
void nextTick(() => {
if (!input.value) {
return;
}
const style = window.getComputedStyle(this.$refs.input);
const lineHeight = parseFloat(style.lineHeight, 10) || 1;
const style = window.getComputedStyle(input.value);
const lineHeight = parseFloat(style.lineHeight) || 1;
// Start by resetting height before computing as scrollHeight does not
// decrease when deleting characters
this.$refs.input.style.height = "";
input.value.style.height = "";
// Use scrollHeight to calculate how many lines there are in input, and ceil the value
// because some browsers tend to incorrently round the values when using high density
// displays or using page zoom feature
this.$refs.input.style.height =
Math.ceil(this.$refs.input.scrollHeight / lineHeight) * lineHeight + "px";
input.value.style.height = `${
Math.ceil(input.value.scrollHeight / lineHeight) * lineHeight
}px`;
});
},
getInputPlaceholder(channel) {
};
const setPendingMessage = (e: Event) => {
props.channel.pendingMessage = (e.target as HTMLInputElement).value;
props.channel.inputHistoryPosition = 0;
setInputSize();
};
const getInputPlaceholder = (channel: ClientChan) => {
if (channel.type === "channel" || channel.type === "query") {
return `Write to ${channel.name}`;
}
return "";
},
onSubmit() {
};
const onSubmit = () => {
if (!input.value) {
return;
}
// Triggering click event opens the virtual keyboard on mobile
// This can only be called from another interactive event (e.g. button click)
this.$refs.input.click();
this.$refs.input.focus();
input.value.click();
input.value.focus();
if (!this.$store.state.isConnected) {
if (!store.state.isConnected) {
return false;
}
const target = this.channel.id;
const text = this.channel.pendingMessage;
const target = props.channel.id;
const text = props.channel.pendingMessage;
if (text.length === 0) {
return false;
}
if (autocompletionRef) {
autocompletionRef.hide();
if (autocompletionRef.value) {
autocompletionRef.value.hide();
}
this.channel.inputHistoryPosition = 0;
this.channel.pendingMessage = "";
this.$refs.input.value = "";
this.setInputSize();
props.channel.inputHistoryPosition = 0;
props.channel.pendingMessage = "";
input.value.value = "";
setInputSize();
// Store new message in history if last message isn't already equal
if (this.channel.inputHistory[1] !== text) {
this.channel.inputHistory.splice(1, 0, text);
if (props.channel.inputHistory[1] !== text) {
props.channel.inputHistory.splice(1, 0, text);
}
// Limit input history to a 100 entries
if (this.channel.inputHistory.length > 100) {
this.channel.inputHistory.pop();
if (props.channel.inputHistory.length > 100) {
props.channel.inputHistory.pop();
}
if (text[0] === "/") {
const args = text.substr(1).split(" ");
const cmd = args.shift().toLowerCase();
const args = text.substring(1).split(" ");
const cmd = args.shift()?.toLowerCase();
if (!cmd) {
return false;
}
if (
Object.prototype.hasOwnProperty.call(commands, cmd) &&
@ -268,23 +194,166 @@ export default {
}
socket.emit("input", {target, text});
},
onUploadInputChange() {
const files = Array.from(this.$refs.uploadInput.files);
upload.triggerUpload(files);
this.$refs.uploadInput.value = ""; // Reset <input> element so you can upload the same file
},
openFileUpload() {
this.$refs.uploadInput.click();
},
blurInput() {
this.$refs.input.blur();
},
onBlur() {
if (autocompletionRef) {
autocompletionRef.hide();
};
const onUploadInputChange = () => {
if (!uploadInput.value || !uploadInput.value.files) {
return;
}
},
const files = Array.from(uploadInput.value.files);
upload.triggerUpload(files);
uploadInput.value.value = ""; // Reset <input> element so you can upload the same file
};
const openFileUpload = () => {
uploadInput.value?.click();
};
const blurInput = () => {
input.value?.blur();
};
const onBlur = () => {
if (autocompletionRef.value) {
autocompletionRef.value.hide();
}
};
watch(
() => props.channel.id,
() => {
if (autocompletionRef.value) {
autocompletionRef.value.hide();
}
}
);
watch(
() => props.channel.pendingMessage,
() => {
setInputSize();
}
);
onMounted(() => {
eventbus.on("escapekey", blurInput);
if (store.state.settings.autocomplete) {
if (!input.value) {
throw new Error("ChatInput autocomplete: input element is not available");
}
autocompletionRef.value = autocompletion(input.value);
}
const inputTrap = Mousetrap(input.value);
inputTrap.bind(Object.keys(formattingHotkeys), function (e, key) {
const modifier = formattingHotkeys[key];
if (!e.target) {
return;
}
wrapCursor(
e.target as HTMLTextAreaElement,
modifier,
(e.target as HTMLTextAreaElement).selectionStart ===
(e.target as HTMLTextAreaElement).selectionEnd
? ""
: modifier
);
return false;
});
inputTrap.bind(Object.keys(bracketWraps), function (e, key) {
if (
(e.target as HTMLTextAreaElement)?.selectionStart !==
(e.target as HTMLTextAreaElement).selectionEnd
) {
wrapCursor(e.target as HTMLTextAreaElement, key, bracketWraps[key]);
return false;
}
});
inputTrap.bind(["up", "down"], (e, key) => {
if (
store.state.isAutoCompleting ||
(e.target as HTMLTextAreaElement).selectionStart !==
(e.target as HTMLTextAreaElement).selectionEnd ||
!input.value
) {
return;
}
const onRow = (
input.value.value.slice(undefined, input.value.selectionStart).match(/\n/g) ||
[]
).length;
const totalRows = (input.value.value.match(/\n/g) || []).length;
const {channel} = props;
if (channel.inputHistoryPosition === 0) {
channel.inputHistory[channel.inputHistoryPosition] = channel.pendingMessage;
}
if (key === "up" && onRow === 0) {
if (channel.inputHistoryPosition < channel.inputHistory.length - 1) {
channel.inputHistoryPosition++;
} else {
return;
}
} else if (
key === "down" &&
channel.inputHistoryPosition > 0 &&
onRow === totalRows
) {
channel.inputHistoryPosition--;
} else {
return;
}
channel.pendingMessage = channel.inputHistory[channel.inputHistoryPosition];
input.value.value = channel.pendingMessage;
setInputSize();
return false;
});
if (store.state.serverConfiguration?.fileUpload) {
upload.mounted();
}
});
onUnmounted(() => {
eventbus.off("escapekey", blurInput);
if (autocompletionRef.value) {
autocompletionRef.value.destroy();
autocompletionRef.value = undefined;
}
upload.abort();
});
return {
store,
input,
uploadInput,
onUploadInputChange,
openFileUpload,
blurInput,
onBlur,
setInputSize,
upload,
getInputPlaceholder,
onSubmit,
setPendingMessage,
};
},
};
});
</script>

View File

@ -28,9 +28,10 @@
<div
v-for="(users, mode) in groupedUsers"
:key="mode"
:class="['user-mode', getModeClass(mode)]"
:class="['user-mode', getModeClass(String(mode))]"
>
<template v-if="userSearchInput.length > 0">
<!-- eslint-disable vue/no-v-text-v-html-on-component -->
<Username
v-for="user in users"
:key="user.original.nick + '-search'"
@ -39,6 +40,7 @@
:user="user.original"
v-html="user.string"
/>
<!-- eslint-enable -->
</template>
<template v-else>
<Username
@ -54,8 +56,11 @@
</aside>
</template>
<script>
<script lang="ts">
import {filter as fuzzyFilter} from "fuzzy";
import {computed, defineComponent, nextTick, PropType, ref} from "vue";
import type {UserInMessage} from "../../server/models/msg";
import type {ClientChan, ClientUser} from "../js/types";
import Username from "./Username.vue";
const modes = {
@ -68,39 +73,35 @@ const modes = {
"": "normal",
};
export default {
export default defineComponent({
name: "ChatUserList",
components: {
Username,
},
props: {
channel: Object,
channel: {type: Object as PropType<ClientChan>, required: true},
},
data() {
return {
userSearchInput: "",
activeUser: null,
};
},
computed: {
// filteredUsers is computed, to avoid unnecessary filtering
// as it is shared between filtering and keybindings.
filteredUsers() {
if (!this.userSearchInput) {
setup(props) {
const userSearchInput = ref("");
const activeUser = ref<UserInMessage | null>();
const userlist = ref<HTMLDivElement>();
const filteredUsers = computed(() => {
if (!userSearchInput.value) {
return;
}
return fuzzyFilter(this.userSearchInput, this.channel.users, {
return fuzzyFilter(userSearchInput.value, props.channel.users, {
pre: "<b>",
post: "</b>",
extract: (u) => u.nick,
});
},
groupedUsers() {
});
const groupedUsers = computed(() => {
const groups = {};
if (this.userSearchInput) {
const result = this.filteredUsers;
if (userSearchInput.value && filteredUsers.value) {
const result = filteredUsers.value;
for (const user of result) {
const mode = user.original.modes[0] || "";
@ -115,7 +116,7 @@ export default {
groups[mode].push(user);
}
} else {
for (const user of this.channel.users) {
for (const user of props.channel.users) {
const mode = user.modes[0] || "";
if (!groups[mode]) {
@ -126,24 +127,35 @@ export default {
}
}
return groups;
},
},
methods: {
setUserSearchInput(e) {
this.userSearchInput = e.target.value;
},
getModeClass(mode) {
return modes[mode];
},
selectUser() {
return groups as {
[mode: string]: (ClientUser & {
original: UserInMessage;
string: string;
})[];
};
});
const setUserSearchInput = (e: Event) => {
userSearchInput.value = (e.target as HTMLInputElement).value;
};
const getModeClass = (mode: string) => {
return modes[mode] as typeof modes;
};
const selectUser = () => {
// Simulate a click on the active user to open the context menu.
// Coordinates are provided to position the menu correctly.
if (!this.activeUser) {
if (!activeUser.value || !userlist.value) {
return;
}
const el = userlist.value.querySelector(".active");
if (!el) {
return;
}
const el = this.$refs.userlist.querySelector(".active");
const rect = el.getBoundingClientRect();
const ev = new MouseEvent("click", {
view: window,
@ -153,38 +165,58 @@ export default {
clientY: rect.top + rect.height,
});
el.dispatchEvent(ev);
},
hoverUser(user) {
this.activeUser = user;
},
removeHoverUser() {
this.activeUser = null;
},
navigateUserList(event, direction) {
};
const hoverUser = (user: UserInMessage) => {
activeUser.value = user;
};
const removeHoverUser = () => {
activeUser.value = null;
};
const scrollToActiveUser = () => {
// Scroll the list if needed after the active class is applied
void nextTick(() => {
const el = userlist.value?.querySelector(".active");
el?.scrollIntoView({block: "nearest", inline: "nearest"});
});
};
const navigateUserList = (event: Event, direction: number) => {
// Prevent propagation to stop global keybind handler from capturing pagedown/pageup
// and redirecting it to the message list container for scrolling
event.stopImmediatePropagation();
event.preventDefault();
let users = this.channel.users;
let users = props.channel.users;
// Only using filteredUsers when we have to avoids filtering when it's not needed
if (this.userSearchInput) {
users = this.filteredUsers.map((result) => result.original);
if (userSearchInput.value && filteredUsers.value) {
users = filteredUsers.value.map((result) => result.original);
}
// Bail out if there's no users to select
if (!users.length) {
this.activeUser = null;
activeUser.value = null;
return;
}
let currentIndex = users.indexOf(this.activeUser);
const abort = () => {
activeUser.value = direction ? users[0] : users[users.length - 1];
scrollToActiveUser();
};
// If there's no active user select the first or last one depending on direction
if (!this.activeUser || currentIndex === -1) {
this.activeUser = direction ? users[0] : users[users.length - 1];
this.scrollToActiveUser();
if (!activeUser.value) {
abort();
return;
}
let currentIndex = users.indexOf(activeUser.value as ClientUser);
if (currentIndex === -1) {
abort();
return;
}
@ -200,16 +232,24 @@ export default {
currentIndex -= users.length;
}
this.activeUser = users[currentIndex];
this.scrollToActiveUser();
},
scrollToActiveUser() {
// Scroll the list if needed after the active class is applied
this.$nextTick(() => {
const el = this.$refs.userlist.querySelector(".active");
el.scrollIntoView({block: "nearest", inline: "nearest"});
});
},
activeUser.value = users[currentIndex];
scrollToActiveUser();
};
return {
filteredUsers,
groupedUsers,
userSearchInput,
activeUser,
userlist,
setUserSearchInput,
getModeClass,
selectUser,
hoverUser,
removeHoverUser,
navigateUserList,
};
},
};
});
</script>

View File

@ -1,13 +1,13 @@
<template>
<div id="confirm-dialog-overlay" :class="{opened: data !== null}">
<div id="confirm-dialog-overlay" :class="{opened: !!data}">
<div v-if="data !== null" id="confirm-dialog">
<div class="confirm-text">
<div class="confirm-text-title">{{ data.title }}</div>
<p>{{ data.text }}</p>
<div class="confirm-text-title">{{ data?.title }}</div>
<p>{{ data?.text }}</p>
</div>
<div class="confirm-buttons">
<button class="btn btn-cancel" @click="close(false)">Cancel</button>
<button class="btn btn-danger" @click="close(true)">{{ data.button }}</button>
<button class="btn btn-danger" @click="close(true)">{{ data?.button }}</button>
</div>
</div>
</div>
@ -50,37 +50,53 @@
}
</style>
<script>
<script lang="ts">
import eventbus from "../js/eventbus";
import {defineComponent, onMounted, onUnmounted, ref} from "vue";
export default {
type ConfirmDialogData = {
title: string;
text: string;
button: string;
};
type ConfirmDialogCallback = {
(confirmed: boolean): void;
};
export default defineComponent({
name: "ConfirmDialog",
data() {
setup() {
const data = ref<ConfirmDialogData>();
const callback = ref<ConfirmDialogCallback>();
const open = (incoming: ConfirmDialogData, cb: ConfirmDialogCallback) => {
data.value = incoming;
callback.value = cb;
};
const close = (result: boolean) => {
data.value = undefined;
if (callback.value) {
callback.value(!!result);
}
};
onMounted(() => {
eventbus.on("escapekey", close);
eventbus.on("confirm-dialog", open);
});
onUnmounted(() => {
eventbus.off("escapekey", close);
eventbus.off("confirm-dialog", open);
});
return {
data: null,
callback: null,
data,
close,
};
},
mounted() {
eventbus.on("escapekey", this.close);
eventbus.on("confirm-dialog", this.open);
},
destroyed() {
eventbus.off("escapekey", this.close);
eventbus.off("confirm-dialog", this.open);
},
methods: {
open(data, callback) {
this.data = data;
this.callback = callback;
},
close(result) {
this.data = null;
if (this.callback) {
this.callback(!!result);
}
},
},
};
});
</script>

View File

@ -14,14 +14,17 @@
id="context-menu"
ref="contextMenu"
role="menu"
:style="style"
:style="{
top: style.top + 'px',
left: style.left + 'px',
}"
tabindex="-1"
@mouseleave="activeItem = -1"
@keydown.enter.prevent="clickActiveItem"
>
<template v-for="(item, id) of items">
<!-- TODO: type -->
<template v-for="(item, id) of (items as any)" :key="item.name">
<li
:key="item.name"
:class="[
'context-menu-' + item.type,
item.class ? 'context-menu-' + item.class : null,
@ -38,164 +41,77 @@
</div>
</template>
<script>
<script lang="ts">
import {
generateUserContextMenu,
generateChannelContextMenu,
generateInlineChannelContextMenu,
} from "../js/helpers/contextMenu.js";
ContextMenuItem,
} from "../js/helpers/contextMenu";
import eventbus from "../js/eventbus";
import {defineComponent, nextTick, onMounted, onUnmounted, PropType, ref} from "vue";
import {ClientChan, ClientMessage, ClientNetwork, ClientUser} from "../js/types";
import {useStore} from "../js/store";
import {useRouter} from "vue-router";
export default {
export default defineComponent({
name: "ContextMenu",
props: {
message: Object,
message: {
required: false,
type: Object as PropType<ClientMessage>,
},
},
data() {
return {
isOpen: false,
passthrough: false,
previousActiveElement: null,
items: [],
activeItem: -1,
style: {
left: 0,
top: 0,
},
};
},
mounted() {
eventbus.on("escapekey", this.close);
eventbus.on("contextmenu:cancel", this.close);
eventbus.on("contextmenu:user", this.openUserContextMenu);
eventbus.on("contextmenu:channel", this.openChannelContextMenu);
eventbus.on("contextmenu:inline-channel", this.openInlineChannelContextMenu);
},
destroyed() {
eventbus.off("escapekey", this.close);
eventbus.off("contextmenu:cancel", this.close);
eventbus.off("contextmenu:user", this.openUserContextMenu);
eventbus.off("contextmenu:channel", this.openChannelContextMenu);
eventbus.off("contextmenu:inline-channel", this.openInlineChannelContextMenu);
setup() {
const store = useStore();
const router = useRouter();
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 isOpen = ref(false);
const passthrough = ref(false);
const items = generateChannelContextMenu(this.$root, data.channel, data.network);
this.open(data.event, items);
},
openInlineChannelContextMenu(data) {
const {network} = this.$store.state.activeChannel;
const items = generateInlineChannelContextMenu(this.$root, data.channel, network);
this.open(data.event, items);
},
openUserContextMenu(data) {
const {network, channel} = this.$store.state.activeChannel;
const contextMenu = ref<HTMLUListElement | null>();
const previousActiveElement = ref<HTMLElement | null>();
const items = ref<ContextMenuItem[]>([]);
const activeItem = ref(-1);
const style = ref({
top: 0,
left: 0,
});
const items = generateUserContextMenu(
this.$root,
channel,
network,
channel.users.find((u) => u.nick === data.user.nick) || {
nick: data.user.nick,
modes: [],
}
);
this.open(data.event, items);
},
open(event, items) {
event.preventDefault();
this.previousActiveElement = document.activeElement;
this.items = items;
this.activeItem = 0;
this.isOpen = true;
// Position the menu and set the focus on the first item after it's size has updated
this.$nextTick(() => {
const pos = this.positionContextMenu(event);
this.style.left = pos.left + "px";
this.style.top = pos.top + "px";
this.$refs.contextMenu.focus();
});
},
close() {
if (!this.isOpen) {
const close = () => {
if (!isOpen.value) {
return;
}
this.isOpen = false;
this.items = [];
isOpen.value = false;
items.value = [];
if (this.previousActiveElement) {
this.previousActiveElement.focus();
this.previousActiveElement = null;
if (previousActiveElement.value) {
previousActiveElement.value.focus();
previousActiveElement.value = null;
}
},
hoverItem(id) {
this.activeItem = id;
},
clickItem(item) {
this.close();
};
if (item.action) {
item.action();
} else if (item.link) {
this.$router.push(item.link);
}
},
clickActiveItem() {
if (this.items[this.activeItem]) {
this.clickItem(this.items[this.activeItem]);
}
},
navigateMenu(direction) {
let currentIndex = this.activeItem;
const enablePointerEvents = () => {
passthrough.value = false;
document.body.removeEventListener("pointerup", enablePointerEvents);
};
currentIndex += direction;
const nextItem = this.items[currentIndex];
// If the next item we would select is a divider, skip over it
if (nextItem && nextItem.type === "divider") {
currentIndex += direction;
}
if (currentIndex < 0) {
currentIndex += this.items.length;
}
if (currentIndex > this.items.length - 1) {
currentIndex -= this.items.length;
}
this.activeItem = currentIndex;
},
containerClick(event) {
const containerClick = (event: MouseEvent) => {
if (event.currentTarget === event.target) {
this.close();
close();
}
},
positionContextMenu(event) {
const element = event.target;
const menuWidth = this.$refs.contextMenu.offsetWidth;
const menuHeight = this.$refs.contextMenu.offsetHeight;
};
const positionContextMenu = (event: MouseEvent) => {
const element = event.target as HTMLElement;
if (!contextMenu.value) {
return;
}
const menuWidth = contextMenu.value?.offsetWidth;
const menuHeight = contextMenu.value?.offsetHeight;
if (element && element.classList.contains("menu")) {
return {
@ -215,7 +131,154 @@ export default {
}
return offset;
},
};
const hoverItem = (id: number) => {
activeItem.value = id;
};
const clickItem = (item: ContextMenuItem) => {
close();
if ("action" in item && item.action) {
item.action();
} else if ("link" in item && item.link) {
router.push(item.link).catch(() => {
// eslint-disable-next-line no-console
console.error("Failed to navigate to", item.link);
});
}
};
const clickActiveItem = () => {
if (items.value[activeItem.value]) {
clickItem(items.value[activeItem.value]);
}
};
const open = (event: MouseEvent, newItems: ContextMenuItem[]) => {
event.preventDefault();
previousActiveElement.value = document.activeElement as HTMLElement;
items.value = newItems;
activeItem.value = 0;
isOpen.value = true;
// Position the menu and set the focus on the first item after it's size has updated
nextTick(() => {
const pos = positionContextMenu(event);
if (!pos) {
return;
}
style.value.left = pos.left;
style.value.top = pos.top;
contextMenu.value?.focus();
}).catch((e) => {
// eslint-disable-next-line no-console
console.error(e);
});
};
const openChannelContextMenu = (data: {
event: MouseEvent;
channel: ClientChan;
network: ClientNetwork;
}) => {
if (data.event.type === "contextmenu") {
// Pass through all pointer events to allow the network list's
// dragging events to continue triggering.
passthrough.value = true;
document.body.addEventListener("pointerup", enablePointerEvents, {
passive: true,
});
}
const newItems = generateChannelContextMenu(data.channel, data.network);
open(data.event, newItems);
};
const openInlineChannelContextMenu = (data: {channel: string; event: MouseEvent}) => {
const {network} = store.state.activeChannel;
const newItems = generateInlineChannelContextMenu(store, data.channel, network);
open(data.event, newItems);
};
const openUserContextMenu = (data: {
user: Pick<ClientUser, "nick" | "modes">;
event: MouseEvent;
}) => {
const {network, channel} = store.state.activeChannel;
const newItems = generateUserContextMenu(
store,
channel,
network,
channel.users.find((u) => u.nick === data.user.nick) || {
nick: data.user.nick,
modes: [],
}
);
open(data.event, newItems);
};
const navigateMenu = (direction: number) => {
let currentIndex = activeItem.value;
currentIndex += direction;
const nextItem = items.value[currentIndex];
// If the next item we would select is a divider, skip over it
if (nextItem && "type" in nextItem && nextItem.type === "divider") {
currentIndex += direction;
}
if (currentIndex < 0) {
currentIndex += items.value.length;
}
if (currentIndex > items.value.length - 1) {
currentIndex -= items.value.length;
}
activeItem.value = currentIndex;
};
onMounted(() => {
eventbus.on("escapekey", close);
eventbus.on("contextmenu:cancel", close);
eventbus.on("contextmenu:user", openUserContextMenu);
eventbus.on("contextmenu:channel", openChannelContextMenu);
eventbus.on("contextmenu:inline-channel", openInlineChannelContextMenu);
});
onUnmounted(() => {
eventbus.off("escapekey", close);
eventbus.off("contextmenu:cancel", close);
eventbus.off("contextmenu:user", openUserContextMenu);
eventbus.off("contextmenu:channel", openChannelContextMenu);
eventbus.off("contextmenu:inline-channel", openInlineChannelContextMenu);
close();
});
return {
isOpen,
items,
activeItem,
style,
contextMenu,
passthrough,
close,
containerClick,
navigateMenu,
hoverItem,
clickItem,
clickActiveItem,
};
},
};
});
</script>

View File

@ -6,52 +6,61 @@
</div>
</template>
<script>
<script lang="ts">
import dayjs from "dayjs";
import calendar from "dayjs/plugin/calendar";
import {computed, defineComponent, onBeforeUnmount, onMounted, PropType} from "vue";
import eventbus from "../js/eventbus";
import type {ClientMessage} from "../js/types";
dayjs.extend(calendar);
export default {
export default defineComponent({
name: "DateMarker",
props: {
message: Object,
message: {
type: Object as PropType<ClientMessage>,
required: true,
},
focused: Boolean,
},
computed: {
localeDate() {
return dayjs(this.message.time).format("D MMMM YYYY");
},
},
mounted() {
if (this.hoursPassed() < 48) {
eventbus.on("daychange", this.dayChange);
}
},
beforeDestroy() {
eventbus.off("daychange", this.dayChange);
},
methods: {
hoursPassed() {
return (Date.now() - Date.parse(this.message.time)) / 3600000;
},
dayChange() {
this.$forceUpdate();
setup(props) {
const localeDate = computed(() => dayjs(props.message.time).format("D MMMM YYYY"));
if (this.hoursPassed() >= 48) {
eventbus.off("daychange", this.dayChange);
const hoursPassed = () => {
return (Date.now() - Date.parse(props.message.time.toString())) / 3600000;
};
const dayChange = () => {
if (hoursPassed() >= 48) {
eventbus.off("daychange", dayChange);
}
},
friendlyDate() {
};
const friendlyDate = () => {
// See http://momentjs.com/docs/#/displaying/calendar-time/
return dayjs(this.message.time).calendar(null, {
return dayjs(props.message.time).calendar(null, {
sameDay: "[Today]",
lastDay: "[Yesterday]",
lastWeek: "D MMMM YYYY",
sameElse: "D MMMM YYYY",
});
},
};
onMounted(() => {
if (hoursPassed() < 48) {
eventbus.on("daychange", dayChange);
}
});
onBeforeUnmount(() => {
eventbus.off("daychange", dayChange);
});
return {
localeDate,
friendlyDate,
};
},
};
});
</script>

View File

@ -0,0 +1,120 @@
<template>
<div ref="containerRef" :class="$props.class">
<slot
v-for="(item, index) of list"
:key="item[itemKey]"
:element="item"
:index="index"
name="item"
></slot>
</div>
</template>
<script lang="ts">
import {defineComponent, ref, PropType, watch, onUnmounted, onBeforeUnmount} from "vue";
import Sortable from "sortablejs";
const Props = {
delay: {
type: Number,
default: 0,
required: false,
},
delayOnTouchOnly: {
type: Boolean,
default: false,
required: false,
},
touchStartThreshold: {
type: Number,
default: 10,
required: false,
},
handle: {
type: String,
default: "",
required: false,
},
draggable: {
type: String,
default: "",
required: false,
},
ghostClass: {
type: String,
default: "",
required: false,
},
dragClass: {
type: String,
default: "",
required: false,
},
group: {
type: String,
default: "",
required: false,
},
class: {
type: String,
default: "",
required: false,
},
itemKey: {
type: String,
default: "",
required: true,
},
list: {
type: Array as PropType<any[]>,
default: [],
required: true,
},
filter: {
type: String,
default: "",
required: false,
},
};
export default defineComponent({
name: "Draggable",
props: Props,
emits: ["change", "choose", "unchoose"],
setup(props, {emit}) {
const containerRef = ref<HTMLElement | null>(null);
const sortable = ref<Sortable | null>(null);
watch(containerRef, (newDraggable) => {
if (newDraggable) {
sortable.value = new Sortable(newDraggable, {
...props,
onChoose(event) {
emit("choose", event);
},
onUnchoose(event) {
emit("unchoose", event);
},
onEnd(event) {
emit("change", event);
},
});
}
});
onBeforeUnmount(() => {
if (sortable.value) {
sortable.value.destroy();
containerRef.value = null;
}
});
return {
containerRef,
};
},
});
</script>

View File

@ -38,121 +38,125 @@
</div>
</template>
<script>
<script lang="ts">
import Mousetrap from "mousetrap";
import {computed, defineComponent, ref, watch} from "vue";
import {onBeforeRouteLeave, onBeforeRouteUpdate} from "vue-router";
import eventbus from "../js/eventbus";
import {ClientChan, ClientMessage, ClientLinkPreview} from "../js/types";
export default {
export default defineComponent({
name: "ImageViewer",
data() {
return {
link: null,
previousImage: null,
nextImage: null,
channel: null,
setup() {
const viewer = ref<HTMLDivElement>();
const image = ref<HTMLImageElement>();
position: {
x: 0,
y: 0,
},
transform: {
x: 0,
y: 0,
scale: 0,
},
};
},
computed: {
computeImageStyles() {
const link = ref<ClientLinkPreview | null>(null);
const previousImage = ref<ClientLinkPreview | null>();
const nextImage = ref<ClientLinkPreview | null>();
const channel = ref<ClientChan | null>();
const position = ref<{
x: number;
y: number;
}>({
x: 0,
y: 0,
});
const transform = ref<{
scale: number;
x: number;
y: number;
}>({
scale: 1,
x: 0,
y: 0,
});
const computeImageStyles = computed(() => {
// Sub pixels may cause the image to blur in certain browsers
// round it down to prevent that
const transformX = Math.floor(this.transform.x);
const transformY = Math.floor(this.transform.y);
const transformX = Math.floor(transform.value.x);
const transformY = Math.floor(transform.value.y);
return {
left: `${this.position.x}px`,
top: `${this.position.y}px`,
transform: `translate3d(${transformX}px, ${transformY}px, 0) scale3d(${this.transform.scale}, ${this.transform.scale}, 1)`,
left: `${position.value.x}px`,
top: `${position.value.y}px`,
transform: `translate3d(${transformX}px, ${transformY}px, 0) scale3d(${transform.value.scale}, ${transform.value.scale}, 1)`,
};
},
},
watch: {
link(newLink, oldLink) {
// TODO: history.pushState
if (newLink === null) {
eventbus.off("escapekey", this.closeViewer);
eventbus.off("resize", this.correctPosition);
Mousetrap.unbind("left", this.previous);
Mousetrap.unbind("right", this.next);
});
const closeViewer = () => {
if (link.value === null) {
return;
}
this.setPrevNextImages();
channel.value = null;
previousImage.value = null;
nextImage.value = null;
link.value = null;
};
if (!oldLink) {
eventbus.on("escapekey", this.closeViewer);
eventbus.on("resize", this.correctPosition);
Mousetrap.bind("left", this.previous);
Mousetrap.bind("right", this.next);
}
},
},
methods: {
closeViewer() {
if (this.link === null) {
return;
}
this.channel = null;
this.previousImage = null;
this.nextImage = null;
this.link = null;
},
setPrevNextImages() {
if (!this.channel) {
const setPrevNextImages = () => {
if (!channel.value || !link.value) {
return null;
}
const links = this.channel.messages
const links = channel.value.messages
.map((msg) => msg.previews)
.flat()
.filter((preview) => preview.thumb);
const currentIndex = links.indexOf(this.link);
const currentIndex = links.indexOf(link.value);
this.previousImage = links[currentIndex - 1] || null;
this.nextImage = links[currentIndex + 1] || null;
},
previous() {
if (this.previousImage) {
this.link = this.previousImage;
}
},
next() {
if (this.nextImage) {
this.link = this.nextImage;
}
},
onImageLoad() {
this.prepareImage();
},
prepareImage() {
const viewer = this.$refs.viewer;
const image = this.$refs.image;
const width = viewer.offsetWidth;
const height = viewer.offsetHeight;
const scale = Math.min(1, width / image.width, height / image.height);
previousImage.value = links[currentIndex - 1] || null;
nextImage.value = links[currentIndex + 1] || null;
};
this.position.x = Math.floor(-image.naturalWidth / 2);
this.position.y = Math.floor(-image.naturalHeight / 2);
this.transform.scale = Math.max(scale, 0.1);
this.transform.x = width / 2;
this.transform.y = height / 2;
},
calculateZoomShift(newScale, x, y, oldScale) {
const imageWidth = this.$refs.image.width;
const centerX = this.$refs.viewer.offsetWidth / 2;
const centerY = this.$refs.viewer.offsetHeight / 2;
const previous = () => {
if (previousImage.value) {
link.value = previousImage.value;
}
};
const next = () => {
if (nextImage.value) {
link.value = nextImage.value;
}
};
const prepareImage = () => {
const viewerEl = viewer.value;
const imageEl = image.value;
if (!viewerEl || !imageEl) {
return;
}
const width = viewerEl.offsetWidth;
const height = viewerEl.offsetHeight;
const scale = Math.min(1, width / imageEl.width, height / imageEl.height);
position.value.x = Math.floor(-image.value!.naturalWidth / 2);
position.value.y = Math.floor(-image.value!.naturalHeight / 2);
transform.value.scale = Math.max(scale, 0.1);
transform.value.x = width / 2;
transform.value.y = height / 2;
};
const onImageLoad = () => {
prepareImage();
};
const calculateZoomShift = (newScale: number, x: number, y: number, oldScale: number) => {
if (!image.value || !viewer.value) {
return;
}
const imageWidth = image.value.width;
const centerX = viewer.value.offsetWidth / 2;
const centerY = viewer.value.offsetHeight / 2;
return {
x:
@ -164,32 +168,40 @@ export default {
((centerY - (oldScale - (imageWidth * x) / 2)) / x) * newScale +
(imageWidth * newScale) / 2,
};
},
correctPosition() {
const image = this.$refs.image;
const widthScaled = image.width * this.transform.scale;
const heightScaled = image.height * this.transform.scale;
const containerWidth = this.$refs.viewer.offsetWidth;
const containerHeight = this.$refs.viewer.offsetHeight;
};
const correctPosition = () => {
const imageEl = image.value;
const viewerEl = viewer.value;
if (!imageEl || !viewerEl) {
return;
}
const widthScaled = imageEl.width * transform.value.scale;
const heightScaled = imageEl.height * transform.value.scale;
const containerWidth = viewerEl.offsetWidth;
const containerHeight = viewerEl.offsetHeight;
if (widthScaled < containerWidth) {
this.transform.x = containerWidth / 2;
} else if (this.transform.x - widthScaled / 2 > 0) {
this.transform.x = widthScaled / 2;
} else if (this.transform.x + widthScaled / 2 < containerWidth) {
this.transform.x = containerWidth - widthScaled / 2;
transform.value.x = containerWidth / 2;
} else if (transform.value.x - widthScaled / 2 > 0) {
transform.value.x = widthScaled / 2;
} else if (transform.value.x + widthScaled / 2 < containerWidth) {
transform.value.x = containerWidth - widthScaled / 2;
}
if (heightScaled < containerHeight) {
this.transform.y = containerHeight / 2;
} else if (this.transform.y - heightScaled / 2 > 0) {
this.transform.y = heightScaled / 2;
} else if (this.transform.y + heightScaled / 2 < containerHeight) {
this.transform.y = containerHeight - heightScaled / 2;
transform.value.y = containerHeight / 2;
} else if (transform.value.y - heightScaled / 2 > 0) {
transform.value.y = heightScaled / 2;
} else if (transform.value.y + heightScaled / 2 < containerHeight) {
transform.value.y = containerHeight - heightScaled / 2;
}
},
};
// Reduce multiple touch points into a single x/y/scale
reduceTouches(touches) {
const reduceTouches = (touches: TouchList) => {
let totalX = 0;
let totalY = 0;
let totalScale = 0;
@ -219,17 +231,19 @@ export default {
y: totalY / touches.length,
scale: totalScale / touches.length,
};
},
onTouchStart(e) {
};
const onTouchStart = (e: TouchEvent) => {
// prevent sidebar touchstart event, we don't want to interact with sidebar while in image viewer
e.stopImmediatePropagation();
},
};
// Touch image manipulation:
// 1. Move around by dragging it with one finger
// 2. Change image scale by using two fingers
onImageTouchStart(e) {
const image = this.$refs.image;
let touch = this.reduceTouches(e.touches);
const onImageTouchStart = (e: TouchEvent) => {
const img = image.value;
let touch = reduceTouches(e.touches);
let currentTouches = e.touches;
let touchEndFingers = 0;
@ -240,21 +254,21 @@ export default {
};
const startTransform = {
x: this.transform.x,
y: this.transform.y,
scale: this.transform.scale,
x: transform.value.x,
y: transform.value.y,
scale: transform.value.scale,
};
const touchMove = (moveEvent) => {
touch = this.reduceTouches(moveEvent.touches);
touch = reduceTouches(moveEvent.touches);
if (currentTouches.length !== moveEvent.touches.length) {
currentTransform.x = touch.x;
currentTransform.y = touch.y;
currentTransform.scale = touch.scale;
startTransform.x = this.transform.x;
startTransform.y = this.transform.y;
startTransform.scale = this.transform.scale;
startTransform.x = transform.value.x;
startTransform.y = transform.value.y;
startTransform.scale = transform.value.scale;
}
const deltaX = touch.x - currentTransform.x;
@ -264,20 +278,25 @@ export default {
touchEndFingers = 0;
const newScale = Math.min(3, Math.max(0.1, startTransform.scale * deltaScale));
const fixedPosition = this.calculateZoomShift(
const fixedPosition = calculateZoomShift(
newScale,
startTransform.scale,
startTransform.x,
startTransform.y
);
this.transform.x = fixedPosition.x + deltaX;
this.transform.y = fixedPosition.y + deltaY;
this.transform.scale = newScale;
this.correctPosition();
if (!fixedPosition) {
return;
}
transform.value.x = fixedPosition.x + deltaX;
transform.value.y = fixedPosition.y + deltaY;
transform.value.scale = newScale;
correctPosition();
};
const touchEnd = (endEvent) => {
const touchEnd = (endEvent: TouchEvent) => {
const changedTouches = endEvent.changedTouches.length;
if (currentTouches.length > changedTouches + touchEndFingers) {
@ -287,27 +306,30 @@ export default {
// todo: this is swipe to close, but it's not working very well due to unfinished delta calculation
/* if (
this.transform.scale <= 1 &&
transform.value.scale <= 1 &&
endEvent.changedTouches[0].clientY - startTransform.y <= -70
) {
return this.closeViewer();
}*/
this.correctPosition();
correctPosition();
image.removeEventListener("touchmove", touchMove, {passive: true});
image.removeEventListener("touchend", touchEnd, {passive: true});
img?.removeEventListener("touchmove", touchMove);
img?.removeEventListener("touchend", touchEnd);
};
image.addEventListener("touchmove", touchMove, {passive: true});
image.addEventListener("touchend", touchEnd, {passive: true});
},
img?.addEventListener("touchmove", touchMove, {passive: true});
img?.addEventListener("touchend", touchEnd, {passive: true});
};
// Image mouse manipulation:
// 1. Mouse wheel scrolling will zoom in and out
// 2. If image is zoomed in, simply dragging it will move it around
onImageMouseDown(e) {
const onImageMouseDown = (e: MouseEvent) => {
// todo: ignore if in touch event currently?
// only left mouse
// TODO: e.buttons?
if (e.which !== 1) {
return;
}
@ -315,22 +337,26 @@ export default {
e.stopPropagation();
e.preventDefault();
const viewer = this.$refs.viewer;
const image = this.$refs.image;
const viewerEl = viewer.value;
const imageEl = image.value;
if (!viewerEl || !imageEl) {
return;
}
const startX = e.clientX;
const startY = e.clientY;
const startTransformX = this.transform.x;
const startTransformY = this.transform.y;
const widthScaled = image.width * this.transform.scale;
const heightScaled = image.height * this.transform.scale;
const containerWidth = viewer.offsetWidth;
const containerHeight = viewer.offsetHeight;
const centerX = this.transform.x - widthScaled / 2;
const centerY = this.transform.y - heightScaled / 2;
const startTransformX = transform.value.x;
const startTransformY = transform.value.y;
const widthScaled = imageEl.width * transform.value.scale;
const heightScaled = imageEl.height * transform.value.scale;
const containerWidth = viewerEl.offsetWidth;
const containerHeight = viewerEl.offsetHeight;
const centerX = transform.value.x - widthScaled / 2;
const centerY = transform.value.y - heightScaled / 2;
let movedDistance = 0;
const mouseMove = (moveEvent) => {
const mouseMove = (moveEvent: MouseEvent) => {
moveEvent.stopPropagation();
moveEvent.preventDefault();
@ -340,66 +366,112 @@ export default {
movedDistance = Math.max(movedDistance, Math.abs(newX), Math.abs(newY));
if (centerX < 0 || widthScaled + centerX > containerWidth) {
this.transform.x = startTransformX + newX;
transform.value.x = startTransformX + newX;
}
if (centerY < 0 || heightScaled + centerY > containerHeight) {
this.transform.y = startTransformY + newY;
transform.value.y = startTransformY + newY;
}
this.correctPosition();
correctPosition();
};
const mouseUp = (upEvent) => {
this.correctPosition();
const mouseUp = (upEvent: MouseEvent) => {
correctPosition();
if (movedDistance < 2 && upEvent.button === 0) {
this.closeViewer();
closeViewer();
}
image.removeEventListener("mousemove", mouseMove);
image.removeEventListener("mouseup", mouseUp);
image.value?.removeEventListener("mousemove", mouseMove);
image.value?.removeEventListener("mouseup", mouseUp);
};
image.addEventListener("mousemove", mouseMove);
image.addEventListener("mouseup", mouseUp);
},
image.value?.addEventListener("mousemove", mouseMove);
image.value?.addEventListener("mouseup", mouseUp);
};
// If image is zoomed in, holding ctrl while scrolling will move the image up and down
onMouseWheel(e) {
const onMouseWheel = (e: WheelEvent) => {
// if image viewer is closing (css animation), you can still trigger mousewheel
// TODO: Figure out a better fix for this
if (this.link === null) {
if (link.value === null) {
return;
}
e.preventDefault(); // TODO: Can this be passive?
if (e.ctrlKey) {
this.transform.y += e.deltaY;
transform.value.y += e.deltaY;
} else {
const delta = e.deltaY > 0 ? 0.1 : -0.1;
const newScale = Math.min(3, Math.max(0.1, this.transform.scale + delta));
const fixedPosition = this.calculateZoomShift(
const newScale = Math.min(3, Math.max(0.1, transform.value.scale + delta));
const fixedPosition = calculateZoomShift(
newScale,
this.transform.scale,
this.transform.x,
this.transform.y
transform.value.scale,
transform.value.x,
transform.value.y
);
this.transform.scale = newScale;
this.transform.x = fixedPosition.x;
this.transform.y = fixedPosition.y;
if (!fixedPosition) {
return;
}
transform.value.scale = newScale;
transform.value.x = fixedPosition.x;
transform.value.y = fixedPosition.y;
}
this.correctPosition();
},
onClick(e) {
correctPosition();
};
const onClick = (e: Event) => {
// If click triggers on the image, ignore it
if (e.target === this.$refs.image) {
if (e.target === image.value) {
return;
}
this.closeViewer();
},
closeViewer();
};
watch(link, (newLink, oldLink) => {
// TODO: history.pushState
if (newLink === null) {
eventbus.off("escapekey", closeViewer);
eventbus.off("resize", correctPosition);
Mousetrap.unbind("left");
Mousetrap.unbind("right");
return;
}
setPrevNextImages();
if (!oldLink) {
eventbus.on("escapekey", closeViewer);
eventbus.on("resize", correctPosition);
Mousetrap.bind("left", previous);
Mousetrap.bind("right", next);
}
});
return {
link,
image,
transform,
closeViewer,
next,
previous,
onImageLoad,
onImageMouseDown,
onMouseWheel,
onClick,
onTouchStart,
previousImage,
nextImage,
onImageTouchStart,
computeImageStyles,
viewer,
};
},
};
});
</script>

View File

@ -10,21 +10,26 @@
></span>
</template>
<script>
<script lang="ts">
import {defineComponent} from "vue";
import eventbus from "../js/eventbus";
export default {
export default defineComponent({
name: "InlineChannel",
props: {
channel: String,
},
methods: {
openContextMenu(event) {
setup(props) {
const openContextMenu = (event) => {
eventbus.emit("contextmenu:inline-channel", {
event: event,
channel: this.channel,
channel: props.channel,
});
},
};
return {
openContextMenu,
};
},
};
});
</script>

View File

@ -35,54 +35,59 @@
</form>
</template>
<script>
<script lang="ts">
import {defineComponent, PropType, ref} from "vue";
import {switchToChannel} from "../js/router";
import socket from "../js/socket";
import {useStore} from "../js/store";
import {ClientNetwork, ClientChan} from "../js/types";
export default {
export default defineComponent({
name: "JoinChannel",
directives: {
focus: {
inserted(el) {
el.focus();
},
mounted: (el: HTMLFormElement) => el.focus(),
},
},
props: {
network: Object,
channel: Object,
network: {type: Object as PropType<ClientNetwork>, required: true},
channel: {type: Object as PropType<ClientChan>, required: true},
},
data() {
return {
inputChannel: "",
inputPassword: "",
};
},
methods: {
onSubmit() {
const existingChannel = this.$store.getters.findChannelOnCurrentNetwork(
this.inputChannel
);
emits: ["toggle-join-channel"],
setup(props, {emit}) {
const store = useStore();
const inputChannel = ref("");
const inputPassword = ref("");
const onSubmit = () => {
const existingChannel = store.getters.findChannelOnCurrentNetwork(inputChannel.value);
if (existingChannel) {
this.$root.switchToChannel(existingChannel);
switchToChannel(existingChannel);
} else {
const chanTypes = this.network.serverOptions.CHANTYPES;
let channel = this.inputChannel;
const chanTypes = props.network.serverOptions.CHANTYPES;
let channel = inputChannel.value;
if (chanTypes && chanTypes.length > 0 && !chanTypes.includes(channel[0])) {
channel = chanTypes[0] + channel;
}
socket.emit("input", {
text: `/join ${channel} ${this.inputPassword}`,
target: this.channel.id,
text: `/join ${channel} ${inputPassword.value}`,
target: props.channel.id,
});
}
this.inputChannel = "";
this.inputPassword = "";
this.$emit("toggle-join-channel");
},
inputChannel.value = "";
inputPassword.value = "";
emit("toggle-join-channel");
};
return {
inputChannel,
inputPassword,
onSubmit,
};
},
};
});
</script>

View File

@ -129,137 +129,201 @@
</div>
</template>
<script>
<script lang="ts">
import {
computed,
defineComponent,
inject,
nextTick,
onBeforeUnmount,
onMounted,
onUnmounted,
PropType,
ref,
watch,
} from "vue";
import {onBeforeRouteUpdate} from "vue-router";
import eventbus from "../js/eventbus";
import friendlysize from "../js/helpers/friendlysize";
import {useStore} from "../js/store";
import type {ClientChan, ClientLinkPreview} from "../js/types";
import {imageViewerKey} from "./App.vue";
export default {
export default defineComponent({
name: "LinkPreview",
props: {
link: Object,
keepScrollPosition: Function,
channel: Object,
},
data() {
return {
showMoreButton: false,
isContentShown: false,
};
},
computed: {
moreButtonLabel() {
return this.isContentShown ? "Less" : "More";
link: {
type: Object as PropType<ClientLinkPreview>,
required: true,
},
imageMaxSize() {
if (!this.link.maxSize) {
keepScrollPosition: {
type: Function as PropType<() => void>,
required: true,
},
channel: {type: Object as PropType<ClientChan>, required: true},
},
setup(props) {
const store = useStore();
const showMoreButton = ref(false);
const isContentShown = ref(false);
const imageViewer = inject(imageViewerKey);
onBeforeRouteUpdate((to, from, next) => {
// cancel the navigation if the user is trying to close the image viewer
if (imageViewer?.value?.link) {
imageViewer.value.closeViewer();
return next(false);
}
next();
});
const content = ref<HTMLDivElement | null>(null);
const container = ref<HTMLDivElement | null>(null);
const moreButtonLabel = computed(() => {
return isContentShown.value ? "Less" : "More";
});
const imageMaxSize = computed(() => {
if (!props.link.maxSize) {
return;
}
return friendlysize(this.link.maxSize);
},
},
watch: {
"link.type"() {
this.updateShownState();
this.onPreviewUpdate();
},
},
created() {
this.updateShownState();
},
mounted() {
eventbus.on("resize", this.handleResize);
return friendlysize(props.link.maxSize);
});
this.onPreviewUpdate();
},
beforeDestroy() {
eventbus.off("resize", this.handleResize);
},
destroyed() {
// Let this preview go through load/canplay events again,
// Otherwise the browser can cause a resize on video elements
this.link.sourceLoaded = false;
},
methods: {
onPreviewUpdate() {
const handleResize = () => {
nextTick(() => {
if (!content.value || !container.value) {
return;
}
showMoreButton.value = content.value.offsetWidth >= container.value.offsetWidth;
}).catch((e) => {
// eslint-disable-next-line no-console
console.error("Error in LinkPreview.handleResize", e);
});
};
const onPreviewReady = () => {
props.link.sourceLoaded = true;
props.keepScrollPosition();
if (props.link.type === "link") {
handleResize();
}
};
const onPreviewUpdate = () => {
// Don't display previews while they are loading on the server
if (this.link.type === "loading") {
if (props.link.type === "loading") {
return;
}
// Error does not have any media to render
if (this.link.type === "error") {
this.onPreviewReady();
if (props.link.type === "error") {
onPreviewReady();
}
// If link doesn't have a thumbnail, render it
if (this.link.type === "link") {
this.handleResize();
this.keepScrollPosition();
if (props.link.type === "link") {
handleResize();
props.keepScrollPosition();
}
},
onPreviewReady() {
this.$set(this.link, "sourceLoaded", true);
};
this.keepScrollPosition();
if (this.link.type === "link") {
this.handleResize();
}
},
onThumbnailError() {
const onThumbnailError = () => {
// If thumbnail fails to load, hide it and show the preview without it
this.link.thumb = "";
this.onPreviewReady();
},
onThumbnailClick(e) {
props.link.thumb = "";
onPreviewReady();
};
const onThumbnailClick = (e: MouseEvent) => {
e.preventDefault();
const imageViewer = this.$root.$refs.app.$refs.imageViewer;
imageViewer.channel = this.channel;
imageViewer.link = this.link;
},
onMoreClick() {
this.isContentShown = !this.isContentShown;
this.keepScrollPosition();
},
handleResize() {
this.$nextTick(() => {
if (!this.$refs.content) {
return;
}
if (!imageViewer?.value) {
return;
}
this.showMoreButton =
this.$refs.content.offsetWidth >= this.$refs.container.offsetWidth;
});
},
updateShownState() {
imageViewer.value.channel = props.channel;
imageViewer.value.link = props.link;
};
const onMoreClick = () => {
isContentShown.value = !isContentShown.value;
props.keepScrollPosition();
};
const updateShownState = () => {
// User has manually toggled the preview, do not apply default
if (this.link.shown !== null) {
if (props.link.shown !== null) {
return;
}
let defaultState = false;
switch (this.link.type) {
switch (props.link.type) {
case "error":
// Collapse all errors by default unless its a message about image being too big
if (this.link.error === "image-too-big") {
defaultState = this.$store.state.settings.media;
if (props.link.error === "image-too-big") {
defaultState = store.state.settings.media;
}
break;
case "link":
defaultState = this.$store.state.settings.links;
defaultState = store.state.settings.links;
break;
default:
defaultState = this.$store.state.settings.media;
defaultState = store.state.settings.media;
}
this.link.shown = defaultState;
},
props.link.shown = defaultState;
};
updateShownState();
watch(
() => props.link.type,
() => {
updateShownState();
onPreviewUpdate();
}
);
onMounted(() => {
eventbus.on("resize", handleResize);
onPreviewUpdate();
});
onBeforeUnmount(() => {
eventbus.off("resize", handleResize);
});
onUnmounted(() => {
// Let this preview go through load/canplay events again,
// Otherwise the browser can cause a resize on video elements
props.link.sourceLoaded = false;
});
return {
moreButtonLabel,
imageMaxSize,
onThumbnailClick,
onThumbnailError,
onMoreClick,
onPreviewReady,
onPreviewUpdate,
showMoreButton,
isContentShown,
content,
container,
};
},
};
});
</script>

View File

@ -2,18 +2,21 @@
<span class="preview-size">({{ previewSize }})</span>
</template>
<script>
<script lang="ts">
import {defineComponent} from "vue";
import friendlysize from "../js/helpers/friendlysize";
export default {
export default defineComponent({
name: "LinkPreviewFileSize",
props: {
size: Number,
size: {type: Number, required: true},
},
computed: {
previewSize() {
return friendlysize(this.size);
},
setup(props) {
const previewSize = friendlysize(props.size);
return {
previewSize,
};
},
};
});
</script>

View File

@ -7,23 +7,31 @@
/>
</template>
<script>
export default {
<script lang="ts">
import {computed, defineComponent, PropType} from "vue";
import {ClientMessage, ClientLinkPreview} from "../js/types";
export default defineComponent({
name: "LinkPreviewToggle",
props: {
link: Object,
link: {type: Object as PropType<ClientLinkPreview>, required: true},
message: {type: Object as PropType<ClientMessage>, required: true},
},
computed: {
ariaLabel() {
return this.link.shown ? "Collapse preview" : "Expand preview";
},
},
methods: {
onClick() {
this.link.shown = !this.link.shown;
emits: ["toggle-link-preview"],
setup(props, {emit}) {
const ariaLabel = computed(() => {
return props.link.shown ? "Collapse preview" : "Expand preview";
});
this.$parent.$emit("toggle-link-preview", this.link, this.$parent.message);
},
const onClick = () => {
props.link.shown = !props.link.shown;
emit("toggle-link-preview", props.link, props.message);
};
return {
ariaLabel,
onClick,
};
},
};
});
</script>

View File

@ -20,20 +20,20 @@
<p v-if="isLoading">Loading</p>
<p v-else>You have no recent mentions.</p>
</template>
<template v-for="message in resolvedMessages" v-else>
<div :key="message.msgId" :class="['msg', message.type]">
<template v-for="message in resolvedMessages" v-else :key="message.msgId">
<div :class="['msg', message.type]">
<div class="mentions-info">
<div>
<span class="from">
<Username :user="message.from" />
<Username :user="(message.from as any)" />
<template v-if="message.channel">
in {{ message.channel.channel.name }} on
{{ message.channel.network.name }}
</template>
<template v-else> in unknown channel </template>
</span>
<template v-else> in unknown channel </template> </span
>{{ ` ` }}
<span :title="message.localetime" class="time">
{{ messageTime(message.time) }}
{{ messageTime(message.time.toString()) }}
</span>
</div>
<div>
@ -50,7 +50,7 @@
</div>
</div>
<div class="content" dir="auto">
<ParsedMessage :network="null" :message="message" />
<ParsedMessage :message="(message as any)" />
</div>
</div>
</template>
@ -144,7 +144,7 @@
}
</style>
<script>
<script lang="ts">
import Username from "./Username.vue";
import ParsedMessage from "./ParsedMessage.vue";
import socket from "../js/socket";
@ -152,78 +152,96 @@ import eventbus from "../js/eventbus";
import localetime from "../js/helpers/localetime";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import {computed, watch, defineComponent, ref, onMounted, onUnmounted} from "vue";
import {useStore} from "../js/store";
import {ClientMention} from "../js/types";
dayjs.extend(relativeTime);
export default {
export default defineComponent({
name: "Mentions",
components: {
Username,
ParsedMessage,
},
data() {
return {
isOpen: false,
isLoading: false,
};
},
computed: {
resolvedMessages() {
const messages = this.$store.state.mentions.slice().reverse();
setup() {
const store = useStore();
const isOpen = ref(false);
const isLoading = ref(false);
const resolvedMessages = computed(() => {
const messages = store.state.mentions.slice().reverse();
for (const message of messages) {
message.localetime = localetime(message.time);
message.channel = this.$store.getters.findChannel(message.chanId);
message.channel = store.getters.findChannel(message.chanId);
}
return messages.filter((message) => !message.channel.channel.muted);
},
},
watch: {
"$store.state.mentions"() {
this.isLoading = false;
},
},
mounted() {
eventbus.on("mentions:toggle", this.togglePopup);
eventbus.on("escapekey", this.closePopup);
},
destroyed() {
eventbus.off("mentions:toggle", this.togglePopup);
eventbus.off("escapekey", this.closePopup);
},
methods: {
messageTime(time) {
return messages.filter((message) => !message.channel?.channel.muted);
});
watch(
() => store.state.mentions,
() => {
isLoading.value = false;
}
);
const messageTime = (time: string) => {
return dayjs(time).fromNow();
},
dismissMention(message) {
this.$store.state.mentions.splice(
this.$store.state.mentions.findIndex((m) => m.msgId === message.msgId),
};
const dismissMention = (message: ClientMention) => {
store.state.mentions.splice(
store.state.mentions.findIndex((m) => m.msgId === message.msgId),
1
);
socket.emit("mentions:dismiss", message.msgId);
},
dismissAllMentions() {
this.$store.state.mentions = [];
socket.emit("mentions:dismiss_all");
},
containerClick(event) {
if (event.currentTarget === event.target) {
this.isOpen = false;
}
},
togglePopup() {
this.isOpen = !this.isOpen;
};
if (this.isOpen) {
this.isLoading = true;
const dismissAllMentions = () => {
store.state.mentions = [];
socket.emit("mentions:dismiss_all");
};
const containerClick = (event: Event) => {
if (event.currentTarget === event.target) {
isOpen.value = false;
}
};
const togglePopup = () => {
isOpen.value = !isOpen.value;
if (isOpen.value) {
isLoading.value = true;
socket.emit("mentions:get");
}
},
closePopup() {
this.isOpen = false;
},
};
const closePopup = () => {
isOpen.value = false;
};
onMounted(() => {
eventbus.on("mentions:toggle", togglePopup);
eventbus.on("escapekey", closePopup);
});
onUnmounted(() => {
eventbus.off("mentions:toggle", togglePopup);
eventbus.off("escapekey", closePopup);
});
return {
isOpen,
isLoading,
resolvedMessages,
messageTime,
dismissMention,
dismissAllMentions,
containerClick,
};
},
};
});
</script>

View File

@ -17,12 +17,14 @@
aria-hidden="true"
:aria-label="messageTimeLocale"
class="time tooltipped tooltipped-e"
>{{ messageTime }}
>{{ `${messageTime}&#32;` }}
</span>
<template v-if="message.type === 'unhandled'">
<span class="from">[{{ message.command }}]</span>
<span class="content">
<span v-for="(param, id) in message.params" :key="id">{{ param }} </span>
<span v-for="(param, id) in message.params" :key="id">{{
`&#32;${param}&#32;`
}}</span>
</span>
</template>
<template v-else-if="isAction()">
@ -95,56 +97,73 @@
</div>
</template>
<script>
const constants = require("../js/constants");
import localetime from "../js/helpers/localetime";
<script lang="ts">
import {computed, defineComponent, PropType} from "vue";
import dayjs from "dayjs";
import constants from "../js/constants";
import localetime from "../js/helpers/localetime";
import Username from "./Username.vue";
import LinkPreview from "./LinkPreview.vue";
import ParsedMessage from "./ParsedMessage.vue";
import MessageTypes from "./MessageTypes";
import type {ClientChan, ClientMessage, ClientNetwork} from "../js/types";
import {useStore} from "../js/store";
MessageTypes.ParsedMessage = ParsedMessage;
MessageTypes.LinkPreview = LinkPreview;
MessageTypes.Username = Username;
export default {
export default defineComponent({
name: "Message",
components: MessageTypes,
props: {
message: Object,
channel: Object,
network: Object,
keepScrollPosition: Function,
message: {type: Object as PropType<ClientMessage>, required: true},
channel: {type: Object as PropType<ClientChan>, required: false},
network: {type: Object as PropType<ClientNetwork>, required: true},
keepScrollPosition: Function as PropType<() => void>,
isPreviousSource: Boolean,
focused: Boolean,
},
computed: {
timeFormat() {
let format;
setup(props) {
const store = useStore();
if (this.$store.state.settings.use12hClock) {
format = this.$store.state.settings.showSeconds ? "msg12hWithSeconds" : "msg12h";
const timeFormat = computed(() => {
let format: keyof typeof constants.timeFormats;
if (store.state.settings.use12hClock) {
format = store.state.settings.showSeconds ? "msg12hWithSeconds" : "msg12h";
} else {
format = this.$store.state.settings.showSeconds ? "msgWithSeconds" : "msgDefault";
format = store.state.settings.showSeconds ? "msgWithSeconds" : "msgDefault";
}
return constants.timeFormats[format];
},
messageTime() {
return dayjs(this.message.time).format(this.timeFormat);
},
messageTimeLocale() {
return localetime(this.message.time);
},
messageComponent() {
return "message-" + this.message.type;
},
});
const messageTime = computed(() => {
return dayjs(props.message.time).format(timeFormat.value);
});
const messageTimeLocale = computed(() => {
return localetime(props.message.time);
});
const messageComponent = computed(() => {
return "message-" + props.message.type;
});
const isAction = () => {
return typeof MessageTypes["message-" + props.message.type] !== "undefined";
};
return {
timeFormat,
messageTime,
messageTimeLocale,
messageComponent,
isAction,
};
},
methods: {
isAction() {
return typeof MessageTypes["message-" + this.message.type] !== "undefined";
},
},
};
});
</script>

View File

@ -17,35 +17,45 @@
</div>
</template>
<script>
const constants = require("../js/constants");
<script lang="ts">
import {computed, defineComponent, PropType, ref} from "vue";
import constants from "../js/constants";
import {ClientMessage, ClientNetwork} from "../js/types";
import Message from "./Message.vue";
export default {
export default defineComponent({
name: "MessageCondensed",
components: {
Message,
},
props: {
network: Object,
messages: Array,
keepScrollPosition: Function,
network: {type: Object as PropType<ClientNetwork>, required: true},
messages: {
type: Array as PropType<ClientMessage[]>,
required: true,
},
keepScrollPosition: {
type: Function as PropType<() => void>,
required: true,
},
focused: Boolean,
},
data() {
return {
isCollapsed: true,
setup(props) {
const isCollapsed = ref(true);
const onCollapseClick = () => {
isCollapsed.value = !isCollapsed.value;
props.keepScrollPosition();
};
},
computed: {
condensedText() {
const obj = {};
const condensedText = computed(() => {
const obj: Record<string, number> = {};
constants.condensedTypes.forEach((type) => {
obj[type] = 0;
});
for (const message of this.messages) {
for (const message of props.messages) {
// special case since one MODE message can change multiple modes
if (message.type === "mode") {
// syntax: +vv-t maybe-some targets
@ -64,13 +74,13 @@ export default {
// Count quits as parts in condensed messages to reduce information density
obj.part += obj.quit;
const strings = [];
const strings: string[] = [];
constants.condensedTypes.forEach((type) => {
if (obj[type]) {
switch (type) {
case "chghost":
strings.push(
obj[type] +
String(obj[type]) +
(obj[type] > 1
? " users have changed hostname"
: " user has changed hostname")
@ -78,18 +88,19 @@ export default {
break;
case "join":
strings.push(
obj[type] +
String(obj[type]) +
(obj[type] > 1 ? " users have joined" : " user has joined")
);
break;
case "part":
strings.push(
obj[type] + (obj[type] > 1 ? " users have left" : " user has left")
String(obj[type]) +
(obj[type] > 1 ? " users have left" : " user has left")
);
break;
case "nick":
strings.push(
obj[type] +
String(obj[type]) +
(obj[type] > 1
? " users have changed nick"
: " user has changed nick")
@ -97,33 +108,38 @@ export default {
break;
case "kick":
strings.push(
obj[type] +
String(obj[type]) +
(obj[type] > 1 ? " users were kicked" : " user was kicked")
);
break;
case "mode":
strings.push(
obj[type] + (obj[type] > 1 ? " modes were set" : " mode was set")
String(obj[type]) +
(obj[type] > 1 ? " modes were set" : " mode was set")
);
break;
}
}
});
let text = strings.pop();
if (strings.length) {
text = strings.join(", ") + ", and " + text;
let text = strings.pop();
if (strings.length) {
text = strings.join(", ") + ", and " + text!;
}
return text;
}
return text;
},
return "";
});
return {
isCollapsed,
condensedText,
onCollapseClick,
};
},
methods: {
onCollapseClick() {
this.isCollapsed = !this.isCollapsed;
this.keepScrollPosition();
},
},
};
});
</script>

View File

@ -3,7 +3,7 @@
<div v-show="channel.moreHistoryAvailable" class="show-more">
<button
ref="loadMoreButton"
:disabled="channel.historyLoading || !$store.state.isConnected"
:disabled="channel.historyLoading || !store.state.isConnected"
class="btn"
@click="onShowMoreClick"
>
@ -22,11 +22,11 @@
<DateMarker
v-if="shouldDisplayDateMarker(message, id)"
:key="message.id + '-date'"
:message="message"
:focused="message.id == focused"
:message="message as any"
:focused="message.id === focused"
/>
<div
v-if="shouldDisplayUnreadMarker(message.id)"
v-if="shouldDisplayUnreadMarker(Number(message.id))"
:key="message.id + '-unread'"
class="unread-marker"
>
@ -39,7 +39,7 @@
:network="network"
:keep-scroll-position="keepScrollPosition"
:messages="message.messages"
:focused="message.id == focused"
:focused="message.id === focused"
/>
<Message
v-else
@ -49,7 +49,7 @@
:message="message"
:keep-scroll-position="keepScrollPosition"
:is-previous-source="isPreviousSource(message, id)"
:focused="message.id == focused"
:focused="message.id === focused"
@toggle-link-preview="onLinkPreviewToggle"
/>
</template>
@ -57,18 +57,41 @@
</div>
</template>
<script>
const constants = require("../js/constants");
<script lang="ts">
import constants from "../js/constants";
import eventbus from "../js/eventbus";
import clipboard from "../js/clipboard";
import socket from "../js/socket";
import Message from "./Message.vue";
import MessageCondensed from "./MessageCondensed.vue";
import DateMarker from "./DateMarker.vue";
import {
computed,
defineComponent,
nextTick,
onBeforeUnmount,
onBeforeUpdate,
onMounted,
onUnmounted,
PropType,
ref,
watch,
} from "vue";
import {useStore} from "../js/store";
import {ClientChan, ClientMessage, ClientNetwork, ClientLinkPreview} from "../js/types";
import Msg from "../../server/models/msg";
type CondensedMessageContainer = {
type: "condensed";
time: Date;
messages: ClientMessage[];
id?: number;
};
// TODO; move into component
let unreadMarkerShown = false;
export default {
export default defineComponent({
name: "MessageList",
components: {
Message,
@ -76,32 +99,105 @@ export default {
DateMarker,
},
props: {
network: Object,
channel: Object,
focused: String,
network: {type: Object as PropType<ClientNetwork>, required: true},
channel: {type: Object as PropType<ClientChan>, required: true},
focused: Number,
},
computed: {
condensedMessages() {
if (this.channel.type !== "channel") {
return this.channel.messages;
setup(props, {emit}) {
const store = useStore();
const chat = ref<HTMLDivElement | null>(null);
const loadMoreButton = ref<HTMLButtonElement | null>(null);
const historyObserver = ref<IntersectionObserver | null>(null);
const skipNextScrollEvent = ref(false);
const isWaitingForNextTick = ref(false);
const jumpToBottom = () => {
skipNextScrollEvent.value = true;
props.channel.scrolledToBottom = true;
const el = chat.value;
if (el) {
el.scrollTop = el.scrollHeight;
}
};
const onShowMoreClick = () => {
if (!store.state.isConnected) {
return;
}
let lastMessage = -1;
// Find the id of first message that isn't showInActive
// If showInActive is set, this message is actually in another channel
for (const message of props.channel.messages) {
if (!message.showInActive) {
lastMessage = message.id;
break;
}
}
props.channel.historyLoading = true;
socket.emit("more", {
target: props.channel.id,
lastId: lastMessage,
condensed: store.state.settings.statusMessages !== "shown",
});
};
const onLoadButtonObserved = (entries: IntersectionObserverEntry[]) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) {
return;
}
onShowMoreClick();
});
};
nextTick(() => {
if (!chat.value) {
return;
}
if (window.IntersectionObserver) {
historyObserver.value = new window.IntersectionObserver(onLoadButtonObserved, {
root: chat.value,
});
}
jumpToBottom();
}).catch((e) => {
// eslint-disable-next-line no-console
console.error("Error in new IntersectionObserver", e);
});
const condensedMessages = computed(() => {
if (props.channel.type !== "channel") {
return props.channel.messages;
}
// If actions are hidden, just return a message list with them excluded
if (this.$store.state.settings.statusMessages === "hidden") {
return this.channel.messages.filter(
if (store.state.settings.statusMessages === "hidden") {
return props.channel.messages.filter(
(message) => !constants.condensedTypes.has(message.type)
);
}
// If actions are not condensed, just return raw message list
if (this.$store.state.settings.statusMessages !== "condensed") {
return this.channel.messages;
if (store.state.settings.statusMessages !== "condensed") {
return props.channel.messages;
}
const condensed = [];
let lastCondensedContainer = null;
let lastCondensedContainer: CondensedMessageContainer | null = null;
for (const message of this.channel.messages) {
const condensed: (ClientMessage | CondensedMessageContainer)[] = [];
for (const message of props.channel.messages) {
// If this message is not condensable, or its an action affecting our user,
// then just append the message to container and be done with it
if (
@ -116,7 +212,7 @@ export default {
continue;
}
if (lastCondensedContainer === null) {
if (!lastCondensedContainer) {
lastCondensedContainer = {
time: message.time,
type: "condensed",
@ -126,14 +222,14 @@ export default {
condensed.push(lastCondensedContainer);
}
lastCondensedContainer.messages.push(message);
lastCondensedContainer!.messages.push(message);
// Set id of the condensed container to last message id,
// which is required for the unread marker to work correctly
lastCondensedContainer.id = message.id;
lastCondensedContainer!.id = message.id;
// If this message is the unread boundary, create a split condensed container
if (message.id === this.channel.firstUnread) {
if (message.id === props.channel.firstUnread) {
lastCondensedContainer = null;
}
}
@ -147,70 +243,13 @@ export default {
return message;
});
},
},
watch: {
"channel.id"() {
this.channel.scrolledToBottom = true;
// Re-add the intersection observer to trigger the check again on channel switch
// Otherwise if last channel had the button visible, switching to a new channel won't trigger the history
if (this.historyObserver) {
this.historyObserver.unobserve(this.$refs.loadMoreButton);
this.historyObserver.observe(this.$refs.loadMoreButton);
}
},
"channel.messages"() {
this.keepScrollPosition();
},
"channel.pendingMessage"() {
this.$nextTick(() => {
// Keep the scroll stuck when input gets resized while typing
this.keepScrollPosition();
});
},
},
created() {
this.$nextTick(() => {
if (!this.$refs.chat) {
return;
}
if (window.IntersectionObserver) {
this.historyObserver = new window.IntersectionObserver(this.onLoadButtonObserved, {
root: this.$refs.chat,
});
}
this.jumpToBottom();
});
},
mounted() {
this.$refs.chat.addEventListener("scroll", this.handleScroll, {passive: true});
eventbus.on("resize", this.handleResize);
this.$nextTick(() => {
if (this.historyObserver) {
this.historyObserver.observe(this.$refs.loadMoreButton);
}
});
},
beforeUpdate() {
unreadMarkerShown = false;
},
beforeDestroy() {
eventbus.off("resize", this.handleResize);
this.$refs.chat.removeEventListener("scroll", this.handleScroll);
},
destroyed() {
if (this.historyObserver) {
this.historyObserver.disconnect();
}
},
methods: {
shouldDisplayDateMarker(message, id) {
const previousMessage = this.condensedMessages[id - 1];
const shouldDisplayDateMarker = (
message: Msg | ClientMessage | CondensedMessageContainer,
id: number
) => {
const previousMessage = condensedMessages.value[id - 1];
if (!previousMessage) {
return true;
@ -224,135 +263,180 @@ export default {
oldDate.getMonth() !== newDate.getMonth() ||
oldDate.getFullYear() !== newDate.getFullYear()
);
},
shouldDisplayUnreadMarker(id) {
if (!unreadMarkerShown && id > this.channel.firstUnread) {
};
const shouldDisplayUnreadMarker = (id: number) => {
if (!unreadMarkerShown && id > props.channel.firstUnread) {
unreadMarkerShown = true;
return true;
}
return false;
},
isPreviousSource(currentMessage, id) {
const previousMessage = this.condensedMessages[id - 1];
return (
};
const isPreviousSource = (currentMessage: ClientMessage | Msg, id: number) => {
const previousMessage = condensedMessages[id - 1];
return !!(
previousMessage &&
currentMessage.type === "message" &&
previousMessage.type === "message" &&
previousMessage.from &&
currentMessage.from.nick === previousMessage.from.nick
);
},
onCopy() {
clipboard(this.$el);
},
onLinkPreviewToggle(preview, message) {
this.keepScrollPosition();
};
const onCopy = () => {
if (chat.value) {
clipboard(chat.value);
}
};
const keepScrollPosition = async () => {
// If we are already waiting for the next tick to force scroll position,
// we have no reason to perform more checks and set it again in the next tick
if (isWaitingForNextTick.value) {
return;
}
const el = chat.value;
if (!el) {
return;
}
if (!props.channel.scrolledToBottom) {
if (props.channel.historyLoading) {
const heightOld = el.scrollHeight - el.scrollTop;
isWaitingForNextTick.value = true;
await nextTick();
isWaitingForNextTick.value = false;
skipNextScrollEvent.value = true;
el.scrollTop = el.scrollHeight - heightOld;
}
return;
}
isWaitingForNextTick.value = true;
await nextTick();
isWaitingForNextTick.value = false;
jumpToBottom();
};
const onLinkPreviewToggle = async (preview: ClientLinkPreview, message: ClientMessage) => {
await keepScrollPosition();
// Tell the server we're toggling so it remembers at page reload
socket.emit("msg:preview:toggle", {
target: this.channel.id,
target: props.channel.id,
msgId: message.id,
link: preview.link,
shown: preview.shown,
});
},
onShowMoreClick() {
if (!this.$store.state.isConnected) {
return;
}
};
let lastMessage = -1;
// Find the id of first message that isn't showInActive
// If showInActive is set, this message is actually in another channel
for (const message of this.channel.messages) {
if (!message.showInActive) {
lastMessage = message.id;
break;
}
}
this.channel.historyLoading = true;
socket.emit("more", {
target: this.channel.id,
lastId: lastMessage,
condensed: this.$store.state.settings.statusMessages !== "shown",
});
},
onLoadButtonObserved(entries) {
entries.forEach((entry) => {
if (!entry.isIntersecting) {
return;
}
this.onShowMoreClick();
});
},
keepScrollPosition() {
// If we are already waiting for the next tick to force scroll position,
// we have no reason to perform more checks and set it again in the next tick
if (this.isWaitingForNextTick) {
return;
}
const el = this.$refs.chat;
if (!el) {
return;
}
if (!this.channel.scrolledToBottom) {
if (this.channel.historyLoading) {
const heightOld = el.scrollHeight - el.scrollTop;
this.isWaitingForNextTick = true;
this.$nextTick(() => {
this.isWaitingForNextTick = false;
this.skipNextScrollEvent = true;
el.scrollTop = el.scrollHeight - heightOld;
});
}
return;
}
this.isWaitingForNextTick = true;
this.$nextTick(() => {
this.isWaitingForNextTick = false;
this.jumpToBottom();
});
},
handleScroll() {
const handleScroll = () => {
// Setting scrollTop also triggers scroll event
// We don't want to perform calculations for that
if (this.skipNextScrollEvent) {
this.skipNextScrollEvent = false;
if (skipNextScrollEvent.value) {
skipNextScrollEvent.value = false;
return;
}
const el = this.$refs.chat;
const el = chat.value;
if (!el) {
return;
}
this.channel.scrolledToBottom = el.scrollHeight - el.scrollTop - el.offsetHeight <= 30;
},
handleResize() {
// Keep message list scrolled to bottom on resize
if (this.channel.scrolledToBottom) {
this.jumpToBottom();
}
},
jumpToBottom() {
this.skipNextScrollEvent = true;
this.channel.scrolledToBottom = true;
props.channel.scrolledToBottom = el.scrollHeight - el.scrollTop - el.offsetHeight <= 30;
};
const el = this.$refs.chat;
el.scrollTop = el.scrollHeight;
},
const handleResize = () => {
// Keep message list scrolled to bottom on resize
if (props.channel.scrolledToBottom) {
jumpToBottom();
}
};
onMounted(() => {
chat.value?.addEventListener("scroll", handleScroll, {passive: true});
eventbus.on("resize", handleResize);
void nextTick(() => {
if (historyObserver.value && loadMoreButton.value) {
historyObserver.value.observe(loadMoreButton.value);
}
});
});
watch(
() => props.channel.id,
() => {
props.channel.scrolledToBottom = true;
// Re-add the intersection observer to trigger the check again on channel switch
// Otherwise if last channel had the button visible, switching to a new channel won't trigger the history
if (historyObserver.value && loadMoreButton.value) {
historyObserver.value.unobserve(loadMoreButton.value);
historyObserver.value.observe(loadMoreButton.value);
}
}
);
watch(
() => props.channel.messages,
async () => {
await keepScrollPosition();
},
{
deep: true,
}
);
watch(
() => props.channel.pendingMessage,
async () => {
// Keep the scroll stuck when input gets resized while typing
await keepScrollPosition();
}
);
onBeforeUpdate(() => {
unreadMarkerShown = false;
});
onBeforeUnmount(() => {
eventbus.off("resize", handleResize);
chat.value?.removeEventListener("scroll", handleScroll);
});
onUnmounted(() => {
if (historyObserver.value) {
historyObserver.value.disconnect();
}
});
return {
chat,
store,
onShowMoreClick,
loadMoreButton,
onCopy,
condensedMessages,
shouldDisplayDateMarker,
shouldDisplayUnreadMarker,
keepScrollPosition,
isPreviousSource,
jumpToBottom,
onLinkPreviewToggle,
};
},
};
});
</script>

View File

@ -80,77 +80,96 @@ form.message-search.opened .input-wrapper {
}
</style>
<script>
export default {
<script lang="ts">
import {computed, defineComponent, onMounted, PropType, ref, watch} from "vue";
import {useRoute, useRouter} from "vue-router";
import eventbus from "../js/eventbus";
import {ClientNetwork, ClientChan} from "../js/types";
export default defineComponent({
name: "MessageSearchForm",
props: {
network: Object,
channel: Object,
network: {type: Object as PropType<ClientNetwork>, required: true},
channel: {type: Object as PropType<ClientChan>, required: true},
},
data() {
return {
searchOpened: false,
searchInput: "",
};
},
computed: {
onSearchPage() {
return this.$route.name === "SearchResults";
},
},
watch: {
"$route.query.q"() {
this.searchInput = this.$route.query.q;
},
},
mounted() {
this.searchInput = this.$route.query.q;
this.searchOpened = this.onSearchPage;
setup(props) {
const searchOpened = ref(false);
const searchInput = ref("");
const router = useRouter();
const route = useRoute();
if (!this.searchInput && this.searchOpened) {
this.$refs.searchInputField.focus();
}
},
methods: {
closeSearch() {
if (!this.onSearchPage) {
this.searchInput = "";
this.searchOpened = false;
const searchInputField = ref<HTMLInputElement | null>(null);
const onSearchPage = computed(() => {
return route.name === "SearchResults";
});
watch(route, (newValue) => {
if (newValue.query.q) {
searchInput.value = String(newValue.query.q);
}
},
toggleSearch() {
if (this.searchOpened) {
this.$refs.searchInputField.blur();
});
onMounted(() => {
searchInput.value = String(route.query.q || "");
searchOpened.value = onSearchPage.value;
if (searchInputField.value && !searchInput.value && searchOpened.value) {
searchInputField.value.focus();
}
});
const closeSearch = () => {
if (!onSearchPage.value) {
searchInput.value = "";
searchOpened.value = false;
}
};
const toggleSearch = () => {
if (searchOpened.value) {
searchInputField.value?.blur();
return;
}
this.searchOpened = true;
this.$refs.searchInputField.focus();
},
searchMessages(event) {
searchOpened.value = true;
searchInputField.value?.focus();
};
const searchMessages = (event: Event) => {
event.preventDefault();
if (!this.searchInput) {
if (!searchInput.value) {
return;
}
this.$router
router
.push({
name: "SearchResults",
params: {
id: this.channel.id,
id: props.channel.id,
},
query: {
q: this.searchInput,
q: searchInput.value,
},
})
.catch((err) => {
if (err.name === "NavigationDuplicated") {
// Search for the same query again
this.$root.$emit("re-search");
eventbus.emit("re-search");
}
});
},
};
return {
searchOpened,
searchInput,
searchInputField,
closeSearch,
toggleSearch,
searchMessages,
onSearchPage,
};
},
};
});
</script>

View File

@ -9,19 +9,27 @@
</span>
</template>
<script>
<script lang="ts">
import {defineComponent, PropType} from "vue";
import type {ClientNetwork, ClientMessage} from "../../js/types";
import ParsedMessage from "../ParsedMessage.vue";
import Username from "../Username.vue";
export default {
export default defineComponent({
name: "MessageTypeAway",
components: {
ParsedMessage,
Username,
},
props: {
network: Object,
message: Object,
network: {
type: Object as PropType<ClientNetwork>,
required: true,
},
message: {
type: Object as PropType<ClientMessage>,
required: true,
},
},
};
});
</script>

View File

@ -8,19 +8,27 @@
</span>
</template>
<script>
<script lang="ts">
import {defineComponent, PropType} from "vue";
import {ClientNetwork, ClientMessage} from "../../js/types";
import ParsedMessage from "../ParsedMessage.vue";
import Username from "../Username.vue";
export default {
export default defineComponent({
name: "MessageTypeBack",
components: {
ParsedMessage,
Username,
},
props: {
network: Object,
message: Object,
network: {
type: Object as PropType<ClientNetwork>,
required: true,
},
message: {
type: Object as PropType<ClientMessage>,
required: true,
},
},
};
});
</script>

View File

@ -12,19 +12,27 @@
</span>
</template>
<script>
<script lang="ts">
import {defineComponent, PropType} from "vue";
import {ClientNetwork, ClientMessage} from "../../js/types";
import ParsedMessage from "../ParsedMessage.vue";
import Username from "../Username.vue";
export default {
export default defineComponent({
name: "MessageTypeChangeHost",
components: {
ParsedMessage,
Username,
},
props: {
network: Object,
message: Object,
network: {
type: Object as PropType<ClientNetwork>,
required: true,
},
message: {
type: Object as PropType<ClientMessage>,
required: true,
},
},
};
});
</script>

View File

@ -1,23 +1,31 @@
<template>
<span class="content">
<Username :user="message.from" />&#32;
<span class="ctcp-message"><ParsedMessage :text="message.ctcpMessage" /></span>
<Username :user="message.from" />
{{ `&#32;` }}<span class="ctcp-message"><ParsedMessage :text="message.ctcpMessage" /></span>
</span>
</template>
<script>
<script lang="ts">
import {defineComponent, PropType} from "vue";
import {ClientNetwork, ClientMessage} from "../../js/types";
import ParsedMessage from "../ParsedMessage.vue";
import Username from "../Username.vue";
export default {
export default defineComponent({
name: "MessageTypeCTCP",
components: {
ParsedMessage,
Username,
},
props: {
network: Object,
message: Object,
network: {
type: Object as PropType<ClientNetwork>,
required: true,
},
message: {
type: Object as PropType<ClientMessage>,
required: true,
},
},
};
});
</script>

View File

@ -6,19 +6,27 @@
</span>
</template>
<script>
<script lang="ts">
import {defineComponent, PropType} from "vue";
import {ClientNetwork, ClientMessage} from "../../js/types";
import ParsedMessage from "../ParsedMessage.vue";
import Username from "../Username.vue";
export default {
export default defineComponent({
name: "MessageTypeRequestCTCP",
components: {
ParsedMessage,
Username,
},
props: {
network: Object,
message: Object,
network: {
type: Object as PropType<ClientNetwork>,
required: true,
},
message: {
type: Object as PropType<ClientMessage>,
required: true,
},
},
};
});
</script>

View File

@ -4,55 +4,67 @@
</span>
</template>
<script>
<script lang="ts">
import ParsedMessage from "../ParsedMessage.vue";
import {computed, defineComponent, PropType} from "vue";
import {ClientNetwork, ClientMessage} from "../../js/types";
export default {
export default defineComponent({
name: "MessageTypeError",
components: {
ParsedMessage,
},
props: {
network: Object,
message: Object,
network: {
type: Object as PropType<ClientNetwork>,
required: true,
},
message: {
type: Object as PropType<ClientMessage>,
required: true,
},
},
computed: {
errorMessage() {
switch (this.message.error) {
setup(props) {
const errorMessage = computed(() => {
switch (props.message.error) {
case "bad_channel_key":
return `Cannot join ${this.message.channel} - Bad channel key.`;
return `Cannot join ${props.message.channel} - Bad channel key.`;
case "banned_from_channel":
return `Cannot join ${this.message.channel} - You have been banned from the channel.`;
return `Cannot join ${props.message.channel} - You have been banned from the channel.`;
case "cannot_send_to_channel":
return `Cannot send to channel ${this.message.channel}`;
return `Cannot send to channel ${props.message.channel}`;
case "channel_is_full":
return `Cannot join ${this.message.channel} - Channel is full.`;
return `Cannot join ${props.message.channel} - Channel is full.`;
case "chanop_privs_needed":
return "Cannot perform action: You're not a channel operator.";
case "invite_only_channel":
return `Cannot join ${this.message.channel} - Channel is invite only.`;
return `Cannot join ${props.message.channel} - Channel is invite only.`;
case "no_such_nick":
return `User ${this.message.nick} hasn't logged in or does not exist.`;
return `User ${props.message.nick} hasn't logged in or does not exist.`;
case "not_on_channel":
return "Cannot perform action: You're not on the channel.";
case "password_mismatch":
return "Password mismatch.";
case "too_many_channels":
return `Cannot join ${this.message.channel} - You've already reached the maximum number of channels allowed.`;
return `Cannot join ${props.message.channel} - You've already reached the maximum number of channels allowed.`;
case "unknown_command":
return `Unknown command: ${this.message.command}`;
return `Unknown command: ${props.message.command}`;
case "user_not_in_channel":
return `User ${this.message.nick} is not on the channel.`;
return `User ${props.message.nick} is not on the channel.`;
case "user_on_channel":
return `User ${this.message.nick} is already on the channel.`;
return `User ${props.message.nick} is already on the channel.`;
default:
if (this.message.reason) {
return `${this.message.reason} (${this.message.error})`;
if (props.message.reason) {
return `${props.message.reason} (${props.message.error})`;
}
return this.message.error;
return props.message.error;
}
},
});
return {
errorMessage,
};
},
};
});
</script>

View File

@ -1,12 +1,10 @@
"use strict";
// This creates a version of `require()` in the context of the current
// directory, so we iterate over its content, which is a map statically built by
// Webpack.
// Second argument says it's recursive, third makes sure we only load templates.
const requireViews = require.context(".", false, /\.vue$/);
export default requireViews.keys().reduce((acc, path) => {
export default requireViews.keys().reduce((acc: Record<string, any>, path) => {
acc["message-" + path.substring(2, path.length - 4)] = requireViews(path).default;
return acc;

View File

@ -8,19 +8,27 @@
</span>
</template>
<script>
<script lang="ts">
import {defineComponent, PropType} from "vue";
import {ClientNetwork, ClientMessage} from "../../js/types";
import ParsedMessage from "../ParsedMessage.vue";
import Username from "../Username.vue";
export default {
export default defineComponent({
name: "MessageTypeInvite",
components: {
ParsedMessage,
Username,
},
props: {
network: Object,
message: Object,
network: {
type: Object as PropType<ClientNetwork>,
required: true,
},
message: {
type: Object as PropType<ClientMessage>,
required: true,
},
},
};
});
</script>

View File

@ -1,30 +1,38 @@
<template>
<span class="content">
<Username :user="message.from" />
<i class="hostmask"> (<ParsedMessage :network="network" :text="message.hostmask" />)</i>
<i class="hostmask">&#32;(<ParsedMessage :network="network" :text="message.hostmask" />)</i>
<template v-if="message.account">
<i class="account"> [{{ message.account }}]</i>
<i class="account">&#32;[{{ message.account }}]</i>
</template>
<template v-if="message.gecos">
<i class="realname"> {{ message.gecos }}</i>
<i class="realname">&#32;({{ message.gecos }})</i>
</template>
has joined the channel
</span>
</template>
<script>
<script lang="ts">
import {defineComponent, PropType} from "vue";
import {ClientNetwork, ClientMessage} from "../../js/types";
import ParsedMessage from "../ParsedMessage.vue";
import Username from "../Username.vue";
export default {
export default defineComponent({
name: "MessageTypeJoin",
components: {
ParsedMessage,
Username,
},
props: {
network: Object,
message: Object,
network: {
type: Object as PropType<ClientNetwork>,
required: true,
},
message: {
type: Object as PropType<ClientMessage>,
required: true,
},
},
};
});
</script>

View File

@ -9,19 +9,27 @@
</span>
</template>
<script>
<script lang="ts">
import {defineComponent, PropType} from "vue";
import {ClientNetwork, ClientMessage} from "../../js/types";
import ParsedMessage from "../ParsedMessage.vue";
import Username from "../Username.vue";
export default {
export default defineComponent({
name: "MessageTypeKick",
components: {
ParsedMessage,
Username,
},
props: {
network: Object,
message: Object,
network: {
type: Object as PropType<ClientNetwork>,
required: true,
},
message: {
type: Object as PropType<ClientMessage>,
required: true,
},
},
};
});
</script>

View File

@ -6,19 +6,27 @@
</span>
</template>
<script>
<script lang="ts">
import {defineComponent, PropType} from "vue";
import {ClientNetwork, ClientMessage} from "../../js/types";
import ParsedMessage from "../ParsedMessage.vue";
import Username from "../Username.vue";
export default {
export default defineComponent({
name: "MessageTypeMode",
components: {
ParsedMessage,
Username,
},
props: {
network: Object,
message: Object,
network: {
type: Object as PropType<ClientNetwork>,
required: true,
},
message: {
type: Object as PropType<ClientMessage>,
required: true,
},
},
};
});
</script>

View File

@ -4,12 +4,21 @@
</span>
</template>
<script>
export default {
<script lang="ts">
import {defineComponent, PropType} from "vue";
import {ClientNetwork, ClientMessage} from "../../js/types";
export default defineComponent({
name: "MessageChannelMode",
props: {
network: Object,
message: Object,
network: {
type: Object as PropType<ClientNetwork>,
required: true,
},
message: {
type: Object as PropType<ClientMessage>,
required: true,
},
},
};
});
</script>

View File

@ -4,12 +4,21 @@
</span>
</template>
<script>
export default {
<script lang="ts">
import {defineComponent, PropType} from "vue";
import {ClientNetwork, ClientMessage} from "../../js/types";
export default defineComponent({
name: "MessageChannelMode",
props: {
network: Object,
message: Object,
network: {
type: Object as PropType<ClientNetwork>,
required: true,
},
message: {
type: Object as PropType<ClientMessage>,
required: true,
},
},
};
});
</script>

View File

@ -4,26 +4,34 @@
</span>
</template>
<script>
<script lang="ts">
import {computed, defineComponent, PropType} from "vue";
import {ClientNetwork, ClientMessage} from "../../js/types";
import ParsedMessage from "../ParsedMessage.vue";
export default {
export default defineComponent({
name: "MessageTypeMonospaceBlock",
components: {
ParsedMessage,
},
props: {
network: Object,
message: Object,
network: {
type: Object as PropType<ClientNetwork>,
required: true,
},
message: {
type: Object as PropType<ClientMessage>,
required: true,
},
},
computed: {
cleanText() {
let lines = this.message.text.split("\n");
setup(props) {
const cleanText = computed(() => {
let lines = props.message.text.split("\n");
// If all non-empty lines of the MOTD start with a hyphen (which is common
// across MOTDs), remove all the leading hyphens.
if (lines.every((line) => line === "" || line[0] === "-")) {
lines = lines.map((line) => line.substr(2));
lines = lines.map((line) => line.substring(2));
}
// Remove empty lines around the MOTD (but not within it)
@ -31,7 +39,11 @@ export default {
.map((line) => line.replace(/\s*$/, ""))
.join("\n")
.replace(/^[\r\n]+|[\r\n]+$/g, "");
},
});
return {
cleanText,
};
},
};
});
</script>

View File

@ -6,17 +6,25 @@
</span>
</template>
<script>
<script lang="ts">
import {defineComponent, PropType} from "vue";
import {ClientNetwork, ClientMessage} from "../../js/types";
import Username from "../Username.vue";
export default {
export default defineComponent({
name: "MessageTypeNick",
components: {
Username,
},
props: {
network: Object,
message: Object,
network: {
type: Object as PropType<ClientNetwork>,
required: true,
},
message: {
type: Object as PropType<ClientMessage>,
required: true,
},
},
};
});
</script>

View File

@ -9,19 +9,27 @@
</span>
</template>
<script>
<script lang="ts">
import {defineComponent, PropType} from "vue";
import {ClientNetwork, ClientMessage} from "../../js/types";
import ParsedMessage from "../ParsedMessage.vue";
import Username from "../Username.vue";
export default {
export default defineComponent({
name: "MessageTypePart",
components: {
ParsedMessage,
Username,
},
props: {
network: Object,
message: Object,
network: {
type: Object as PropType<ClientNetwork>,
required: true,
},
message: {
type: Object as PropType<ClientMessage>,
required: true,
},
},
};
});
</script>

View File

@ -9,19 +9,27 @@
</span>
</template>
<script>
<script lang="ts">
import {defineComponent, PropType} from "vue";
import type {ClientMessage, ClientNetwork} from "../../js/types";
import ParsedMessage from "../ParsedMessage.vue";
import Username from "../Username.vue";
export default {
export default defineComponent({
name: "MessageTypeQuit",
components: {
ParsedMessage,
Username,
},
props: {
network: Object,
message: Object,
network: {
type: Object as PropType<ClientNetwork>,
required: true,
},
message: {
type: Object as PropType<ClientMessage>,
required: true,
},
},
};
});
</script>

View File

@ -2,12 +2,21 @@
<span class="content">{{ message.text }}</span>
</template>
<script>
export default {
<script lang="ts">
import {defineComponent, PropType} from "vue";
import {ClientNetwork, ClientMessage} from "../../js/types";
export default defineComponent({
name: "MessageTypeRaw",
props: {
network: Object,
message: Object,
network: {
type: Object as PropType<ClientNetwork>,
required: true,
},
message: {
type: Object as PropType<ClientMessage>,
required: true,
},
},
};
});
</script>

View File

@ -10,19 +10,27 @@
</span>
</template>
<script>
<script lang="ts">
import {defineComponent, PropType} from "vue";
import type {ClientMessage, ClientNetwork} from "../../js/types";
import ParsedMessage from "../ParsedMessage.vue";
import Username from "../Username.vue";
export default {
export default defineComponent({
name: "MessageTypeTopic",
components: {
ParsedMessage,
Username,
},
props: {
network: Object,
message: Object,
network: {
type: Object as PropType<ClientNetwork>,
required: true,
},
message: {
type: Object as PropType<ClientMessage>,
required: true,
},
},
};
});
</script>

View File

@ -6,23 +6,33 @@
</span>
</template>
<script>
<script lang="ts">
import localetime from "../../js/helpers/localetime";
import {computed, defineComponent, PropType} from "vue";
import {ClientNetwork, ClientMessage} from "../../js/types";
import Username from "../Username.vue";
export default {
export default defineComponent({
name: "MessageTypeTopicSetBy",
components: {
Username,
},
props: {
network: Object,
message: Object,
},
computed: {
messageTimeLocale() {
return localetime(this.message.when);
network: {
type: Object as PropType<ClientNetwork>,
required: true,
},
message: {
type: Object as PropType<ClientMessage>,
required: true,
},
},
};
setup(props) {
const messageTimeLocale = computed(() => localetime(props.message.when));
return {
messageTimeLocale,
};
},
});
</script>

View File

@ -55,9 +55,9 @@
</template>
<template v-if="message.whois.special">
<template v-for="special in message.whois.special">
<dt :key="special">Special:</dt>
<dd :key="special">{{ special }}</dd>
<template v-for="special in message.whois.special" :key="special">
<dt>Special:</dt>
<dd>{{ special }}</dd>
</template>
</template>
@ -111,25 +111,33 @@
</span>
</template>
<script>
<script lang="ts">
import {defineComponent, PropType} from "vue";
import localetime from "../../js/helpers/localetime";
import {ClientNetwork, ClientMessage} from "../../js/types";
import ParsedMessage from "../ParsedMessage.vue";
import Username from "../Username.vue";
export default {
export default defineComponent({
name: "MessageTypeWhois",
components: {
ParsedMessage,
Username,
},
props: {
network: Object,
message: Object,
},
methods: {
localetime(date) {
return localetime(date);
network: {
type: Object as PropType<ClientNetwork>,
required: true,
},
message: {
type: Object as PropType<ClientMessage>,
required: true,
},
},
};
setup() {
return {
localetime: (date: Date) => localetime(date),
};
},
});
</script>

View File

@ -11,12 +11,14 @@
</template>
<template v-else>
Connect
<template v-if="config.lockNetwork && $store.state.serverConfiguration.public">
<template
v-if="config?.lockNetwork && store?.state.serverConfiguration?.public"
>
to {{ defaults.name }}
</template>
</template>
</h1>
<template v-if="!config.lockNetwork">
<template v-if="!config?.lockNetwork">
<h2>Network settings</h2>
<div class="connect-row">
<label for="connect:name">Name</label>
@ -173,7 +175,7 @@
</div>
</template>
</template>
<template v-else-if="config.lockNetwork && !$store.state.serverConfiguration.public">
<template v-else-if="config.lockNetwork && !store.state.serverConfiguration?.public">
<h2>Network settings</h2>
<div class="connect-row">
<label for="connect:name">Name</label>
@ -218,7 +220,7 @@
@input="onNickChanged"
/>
</div>
<template v-if="!config.useHexIp">
<template v-if="!config?.useHexIp">
<div class="connect-row">
<label for="connect:username">Username</label>
<input
@ -252,7 +254,7 @@
placeholder="The Lounge - https://thelounge.chat"
/>
</div>
<template v-if="defaults.uuid && !$store.state.serverConfiguration.public">
<template v-if="defaults.uuid && !store.state.serverConfiguration?.public">
<div class="connect-row">
<label for="connect:commands">
Commands
@ -288,8 +290,8 @@ the server tab on new connection"
</div>
</template>
<template v-if="$store.state.serverConfiguration.public">
<template v-if="config.lockNetwork">
<template v-if="store.state.serverConfiguration?.public">
<template v-if="config?.lockNetwork">
<div class="connect-row">
<label></label>
<div class="input-wrap">
@ -343,7 +345,7 @@ the server tab on new connection"
Username + password (SASL PLAIN)
</label>
<label
v-if="!$store.state.serverConfiguration.public && defaults.tls"
v-if="!store.state.serverConfiguration?.public && defaults.tls"
class="opt"
>
<input
@ -435,89 +437,134 @@ the server tab on new connection"
}
</style>
<script>
<script lang="ts">
import RevealPassword from "./RevealPassword.vue";
import SidebarToggle from "./SidebarToggle.vue";
import {defineComponent, nextTick, PropType, ref, watch} from "vue";
import {useStore} from "../js/store";
import {ClientNetwork} from "../js/types";
export default {
export type NetworkFormDefaults = Partial<ClientNetwork> & {
join?: string;
};
export default defineComponent({
name: "NetworkForm",
components: {
RevealPassword,
SidebarToggle,
},
props: {
handleSubmit: Function,
defaults: Object,
handleSubmit: {
type: Function as PropType<(network: ClientNetwork) => void>,
required: true,
},
defaults: {
type: Object as PropType<NetworkFormDefaults>,
required: true,
},
disabled: Boolean,
},
data() {
return {
config: this.$store.state.serverConfiguration,
previousUsername: this.defaults.username,
displayPasswordField: false,
};
},
watch: {
displayPasswordField(value) {
if (value) {
this.$nextTick(() => this.$refs.publicPassword.focus());
}
},
"defaults.commands"() {
this.$nextTick(this.resizeCommandsInput);
},
"defaults.tls"(isSecureChecked) {
const ports = [6667, 6697];
const newPort = isSecureChecked ? 0 : 1;
setup(props) {
const store = useStore();
const config = ref(store.state.serverConfiguration);
const previousUsername = ref(props.defaults?.username);
const displayPasswordField = ref(false);
// If you disable TLS and current port is 6697,
// set it to 6667, and vice versa
if (this.defaults.port === ports[newPort]) {
this.defaults.port = ports[1 - newPort];
}
},
},
methods: {
setSaslAuth(type) {
this.defaults.sasl = type;
},
onNickChanged(event) {
// Username input is not available when useHexIp is set
if (!this.$refs.usernameInput) {
return;
}
const publicPassword = ref<HTMLInputElement | null>(null);
if (
!this.$refs.usernameInput.value ||
this.$refs.usernameInput.value === this.previousUsername
) {
this.$refs.usernameInput.value = event.target.value;
watch(displayPasswordField, (newValue) => {
if (newValue) {
void nextTick(() => {
publicPassword.value?.focus();
});
}
});
this.previousUsername = event.target.value;
},
onSubmit(event) {
const formData = new FormData(event.target);
const data = {};
const commandsInput = ref<HTMLInputElement | null>(null);
for (const item of formData.entries()) {
data[item[0]] = item[1];
}
this.handleSubmit(data);
},
resizeCommandsInput() {
if (!this.$refs.commandsInput) {
const resizeCommandsInput = () => {
if (!commandsInput.value) {
return;
}
// Reset height first so it can down size
this.$refs.commandsInput.style.height = "";
commandsInput.value.style.height = "";
// 2 pixels to account for the border
this.$refs.commandsInput.style.height =
Math.ceil(this.$refs.commandsInput.scrollHeight + 2) + "px";
},
commandsInput.value.style.height = `${Math.ceil(
commandsInput.value.scrollHeight + 2
)}px`;
};
watch(
() => props.defaults?.commands,
() => {
void nextTick(() => {
resizeCommandsInput();
});
}
);
watch(
() => props.defaults?.tls,
(isSecureChecked) => {
const ports = [6667, 6697];
const newPort = isSecureChecked ? 0 : 1;
// If you disable TLS and current port is 6697,
// set it to 6667, and vice versa
if (props.defaults?.port === ports[newPort]) {
props.defaults.port = ports[1 - newPort];
}
}
);
const setSaslAuth = (type: string) => {
if (props.defaults) {
props.defaults.sasl = type;
}
};
const usernameInput = ref<HTMLInputElement | null>(null);
const onNickChanged = (event: Event) => {
if (!usernameInput.value) {
return;
}
const usernameRef = usernameInput.value;
if (!usernameRef.value || usernameRef.value === previousUsername.value) {
usernameRef.value = (event.target as HTMLInputElement)?.value;
}
previousUsername.value = (event.target as HTMLInputElement)?.value;
};
const onSubmit = (event: Event) => {
const formData = new FormData(event.target as HTMLFormElement);
const data: Partial<ClientNetwork> = {};
formData.forEach((value, key) => {
data[key] = value;
});
props.handleSubmit(data as ClientNetwork);
};
return {
store,
config,
displayPasswordField,
publicPassword,
commandsInput,
resizeCommandsInput,
setSaslAuth,
usernameInput,
onNickChanged,
onSubmit,
};
},
};
});
</script>

View File

@ -1,6 +1,6 @@
<template>
<div
v-if="$store.state.networks.length === 0"
v-if="store.state.networks.length === 0"
class="empty"
role="navigation"
aria-label="Network and Channel list"
@ -55,7 +55,7 @@
</div>
<Draggable
v-else
:list="$store.state.networks"
:list="store.state.networks"
:delay="LONG_TOUCH_DURATION"
:delay-on-touch-only="true"
:touch-start-threshold="10"
@ -65,71 +65,79 @@
drag-class="ui-sortable-dragging"
group="networks"
class="networks"
item-key="uuid"
@change="onNetworkSort"
@choose="onDraggableChoose"
@unchoose="onDraggableUnchoose"
>
<div
v-for="network in $store.state.networks"
:id="'network-' + network.uuid"
:key="network.uuid"
:class="{
collapsed: network.isCollapsed,
'not-connected': !network.status.connected,
'not-secure': !network.status.secure,
}"
class="network"
role="region"
aria-live="polite"
@touchstart="onDraggableTouchStart"
@touchmove="onDraggableTouchMove"
@touchend="onDraggableTouchEnd"
@touchcancel="onDraggableTouchEnd"
>
<NetworkLobby
:network="network"
:is-join-channel-shown="network.isJoinChannelShown"
:active="
$store.state.activeChannel &&
network.channels[0] === $store.state.activeChannel.channel
"
@toggle-join-channel="network.isJoinChannelShown = !network.isJoinChannelShown"
/>
<JoinChannel
v-if="network.isJoinChannelShown"
:network="network"
:channel="network.channels[0]"
@toggle-join-channel="network.isJoinChannelShown = !network.isJoinChannelShown"
/>
<Draggable
draggable=".channel-list-item"
ghost-class="ui-sortable-ghost"
drag-class="ui-sortable-dragging"
:group="network.uuid"
:list="network.channels"
:delay="LONG_TOUCH_DURATION"
:delay-on-touch-only="true"
:touch-start-threshold="10"
class="channels"
@change="onChannelSort"
@choose="onDraggableChoose"
@unchoose="onDraggableUnchoose"
<template v-slot:item="{element: network}">
<div
:id="'network-' + network.uuid"
:key="network.uuid"
:class="{
collapsed: network.isCollapsed,
'not-connected': !network.status.connected,
'not-secure': !network.status.secure,
}"
class="network"
role="region"
aria-live="polite"
@touchstart="onDraggableTouchStart"
@touchmove="onDraggableTouchMove"
@touchend="onDraggableTouchEnd"
@touchcancel="onDraggableTouchEnd"
>
<template v-for="(channel, index) in network.channels">
<Channel
v-if="index > 0"
:key="channel.id"
:channel="channel"
:network="network"
:active="
$store.state.activeChannel &&
channel === $store.state.activeChannel.channel
"
/>
</template>
</Draggable>
</div>
<NetworkLobby
:network="network"
:is-join-channel-shown="network.isJoinChannelShown"
:active="
store.state.activeChannel &&
network.channels[0] === store.state.activeChannel.channel
"
@toggle-join-channel="
network.isJoinChannelShown = !network.isJoinChannelShown
"
/>
<JoinChannel
v-if="network.isJoinChannelShown"
:network="network"
:channel="network.channels[0]"
@toggle-join-channel="
network.isJoinChannelShown = !network.isJoinChannelShown
"
/>
<Draggable
draggable=".channel-list-item"
ghost-class="ui-sortable-ghost"
drag-class="ui-sortable-dragging"
:group="network.uuid"
:list="network.channels"
:delay="LONG_TOUCH_DURATION"
:delay-on-touch-only="true"
:touch-start-threshold="10"
class="channels"
item-key="name"
@change="onChannelSort"
@choose="onDraggableChoose"
@unchoose="onDraggableUnchoose"
>
<template v-slot:item="{element: channel, index}">
<Channel
v-if="index > 0"
:key="channel.id"
:data-item="channel.id"
:channel="channel"
:network="network"
:active="
store.state.activeChannel &&
channel === store.state.activeChannel.channel
"
/>
</template>
</Draggable>
</div>
</template>
</Draggable>
</div>
</template>
@ -195,21 +203,27 @@
}
</style>
<script>
<script lang="ts">
import {computed, watch, defineComponent, nextTick, onBeforeUnmount, onMounted, ref} from "vue";
import Mousetrap from "mousetrap";
import Draggable from "vuedraggable";
import Draggable from "./Draggable.vue";
import {filter as fuzzyFilter} from "fuzzy";
import NetworkLobby from "./NetworkLobby.vue";
import Channel from "./Channel.vue";
import JoinChannel from "./JoinChannel.vue";
import socket from "../js/socket";
import collapseNetwork from "../js/helpers/collapseNetwork";
import collapseNetworkHelper from "../js/helpers/collapseNetwork";
import isIgnoredKeybind from "../js/helpers/isIgnoredKeybind";
import distance from "../js/helpers/distance";
import eventbus from "../js/eventbus";
import {ClientChan, NetChan} from "../js/types";
import {useStore} from "../js/store";
import {switchToChannel} from "../js/router";
import Sortable from "sortablejs";
export default {
export default defineComponent({
name: "NetworkList",
components: {
JoinChannel,
@ -217,120 +231,137 @@ export default {
Channel,
Draggable,
},
data() {
return {
searchText: "",
activeSearchItem: null,
};
},
computed: {
items() {
const items = [];
setup() {
const store = useStore();
const searchText = ref("");
const activeSearchItem = ref<ClientChan | null>();
// Number of milliseconds a touch has to last to be considered long
const LONG_TOUCH_DURATION = 500;
for (const network of this.$store.state.networks) {
const startDrag = ref<[number, number] | null>();
const searchInput = ref<HTMLInputElement | null>(null);
const networklist = ref<HTMLDivElement | null>(null);
const sidebarWasClosed = ref(false);
const moveItemInArray = <T>(array: T[], from: number, to: number) => {
const item = array.splice(from, 1)[0];
array.splice(to, 0, item);
};
const items = computed(() => {
const newItems: NetChan[] = [];
for (const network of store.state.networks) {
for (const channel of network.channels) {
if (
this.$store.state.activeChannel &&
channel === this.$store.state.activeChannel.channel
store.state.activeChannel &&
channel === store.state.activeChannel.channel
) {
continue;
}
items.push({network, channel});
newItems.push({network, channel});
}
}
return items;
},
results() {
const results = fuzzyFilter(this.searchText, this.items, {
return newItems;
});
const results = computed(() => {
const newResults = fuzzyFilter(searchText.value, items.value, {
extract: (item) => item.channel.name,
}).map((item) => item.original);
return results;
},
},
watch: {
searchText() {
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);
Mousetrap.bind("alt+j", this.toggleSearch);
},
beforeDestroy() {
Mousetrap.unbind("alt+shift+right", this.expandNetwork);
Mousetrap.unbind("alt+shift+left", this.collapseNetwork);
Mousetrap.unbind("alt+j", this.toggleSearch);
},
methods: {
expandNetwork(event) {
return newResults;
});
const collapseNetwork = (event: Mousetrap.ExtendedKeyboardEvent) => {
if (isIgnoredKeybind(event)) {
return true;
}
if (this.$store.state.activeChannel) {
collapseNetwork(this.$store.state.activeChannel.network, false);
if (store.state.activeChannel) {
collapseNetworkHelper(store.state.activeChannel.network, true);
}
return false;
},
collapseNetwork(event) {
};
const expandNetwork = (event: Mousetrap.ExtendedKeyboardEvent) => {
if (isIgnoredKeybind(event)) {
return true;
}
if (this.$store.state.activeChannel) {
collapseNetwork(this.$store.state.activeChannel.network, true);
if (store.state.activeChannel) {
collapseNetworkHelper(store.state.activeChannel.network, false);
}
return false;
},
onNetworkSort(e) {
if (!e.moved) {
};
const onNetworkSort = (e: Sortable.SortableEvent) => {
const {oldIndex, newIndex} = e;
if (oldIndex === undefined || newIndex === undefined || oldIndex === newIndex) {
return;
}
moveItemInArray(store.state.networks, oldIndex, newIndex);
socket.emit("sort", {
type: "networks",
order: this.$store.state.networks.map((n) => n.uuid),
order: store.state.networks.map((n) => n.uuid),
});
},
onChannelSort(e) {
if (!e.moved) {
};
const onChannelSort = (e: Sortable.SortableEvent) => {
let {oldIndex, newIndex} = e;
if (oldIndex === undefined || newIndex === undefined || oldIndex === newIndex) {
return;
}
const channel = this.$store.getters.findChannel(e.moved.element.id);
// Indexes are offset by one due to the lobby
oldIndex += 1;
newIndex += 1;
if (!channel) {
const unparsedId = e.item.getAttribute("data-item");
if (!unparsedId) {
return;
}
const id = parseInt(unparsedId);
const netChan = store.getters.findChannel(id);
if (!netChan) {
return;
}
moveItemInArray(netChan.network.channels, oldIndex, newIndex);
socket.emit("sort", {
type: "channels",
target: channel.network.uuid,
order: channel.network.channels.map((c) => c.id),
target: netChan.network.uuid,
order: netChan.network.channels.map((c) => c.id),
});
},
isTouchEvent(event) {
};
const isTouchEvent = (event: any): boolean => {
// This is the same way Sortable.js detects a touch event. See
// SortableJS/Sortable@daaefeda:/src/Sortable.js#L465
return (
return !!(
(event.touches && event.touches[0]) ||
(event.pointerType && event.pointerType === "touch")
);
},
onDraggableChoose(event) {
};
const onDraggableChoose = (event: any) => {
const original = event.originalEvent;
if (this.isTouchEvent(original)) {
if (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
@ -338,120 +369,147 @@ export default {
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];
startDrag.value = [original.touches[0].clientX, original.touches[0].clientY];
} else if (original instanceof PointerEvent) {
this.startDrag = [original.clientX, original.clientY];
startDrag.value = [original.clientX, original.clientY];
}
}
},
onDraggableUnchoose(event) {
};
const onDraggableUnchoose = (event: any) => {
event.item.classList.remove("ui-sortable-dragging-touch-cue");
this.startDrag = null;
},
onDraggableTouchStart(event) {
startDrag.value = null;
};
const onDraggableTouchStart = (event: TouchEvent) => {
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 onDraggableTouchMove = (event: TouchEvent) => {
if (startDrag.value && event.touches.length > 0) {
const touch = event.touches[0];
const currentPosition = [touch.clientX, touch.clientY];
if (distance(this.startDrag, currentPosition) > 10) {
if (distance(startDrag.value, currentPosition as [number, number]) > 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) {
};
const onDraggableTouchEnd = (event: TouchEvent) => {
if (event.touches.length === 0) {
document.body.classList.remove("force-no-select");
}
},
toggleSearch(event) {
};
const activateSearch = () => {
if (searchInput.value === document.activeElement) {
return;
}
sidebarWasClosed.value = store.state.sidebarOpen ? false : true;
store.commit("sidebarOpen", true);
void nextTick(() => {
searchInput.value?.focus();
});
};
const deactivateSearch = () => {
activeSearchItem.value = null;
searchText.value = "";
searchInput.value?.blur();
if (sidebarWasClosed.value) {
store.commit("sidebarOpen", false);
}
};
const toggleSearch = (event: Mousetrap.ExtendedKeyboardEvent) => {
if (isIgnoredKeybind(event)) {
return true;
}
if (this.$refs.searchInput === document.activeElement) {
this.deactivateSearch();
if (searchInput.value === document.activeElement) {
deactivateSearch();
return false;
}
this.activateSearch();
activateSearch();
return false;
},
activateSearch() {
if (this.$refs.searchInput === document.activeElement) {
return;
}
};
this.sidebarWasClosed = this.$store.state.sidebarOpen ? false : true;
this.$store.commit("sidebarOpen", true);
this.$nextTick(() => {
this.$refs.searchInput.focus();
});
},
deactivateSearch() {
this.activeSearchItem = null;
this.searchText = "";
this.$refs.searchInput.blur();
const setSearchText = (e: Event) => {
searchText.value = (e.target as HTMLInputElement).value;
};
if (this.sidebarWasClosed) {
this.$store.commit("sidebarOpen", false);
}
},
setSearchText(e) {
this.searchText = e.target.value;
},
setActiveSearchItem(channel) {
if (!this.results.length) {
const setActiveSearchItem = (channel?: ClientChan) => {
if (!results.value.length) {
return;
}
if (!channel) {
channel = this.results[0].channel;
channel = results.value[0].channel;
}
this.activeSearchItem = channel;
},
selectResult() {
if (!this.searchText || !this.results.length) {
activeSearchItem.value = channel;
};
const scrollToActive = () => {
// Scroll the list if needed after the active class is applied
void nextTick(() => {
const el = networklist.value?.querySelector(".channel-list-item.active");
if (el) {
el.scrollIntoView({block: "nearest", inline: "nearest"});
}
});
};
const selectResult = () => {
if (!searchText.value || !results.value.length) {
return;
}
this.$root.switchToChannel(this.activeSearchItem);
this.deactivateSearch();
this.scrollToActive();
},
navigateResults(event, direction) {
if (activeSearchItem.value) {
switchToChannel(activeSearchItem.value);
deactivateSearch();
scrollToActive();
}
};
const navigateResults = (event: Event, direction: number) => {
// Prevent propagation to stop global keybind handler from capturing pagedown/pageup
// and redirecting it to the message list container for scrolling
event.stopImmediatePropagation();
event.preventDefault();
if (!this.searchText) {
if (!searchText.value) {
return;
}
const channels = this.results.map((r) => r.channel);
const channels = results.value.map((r) => r.channel);
// Bail out if there's no channels to select
if (!channels.length) {
this.activeSearchItem = null;
activeSearchItem.value = null;
return;
}
let currentIndex = channels.indexOf(this.activeSearchItem);
let currentIndex = activeSearchItem.value
? channels.indexOf(activeSearchItem.value)
: -1;
// If there's no active channel select the first or last one depending on direction
if (!this.activeSearchItem || currentIndex === -1) {
this.activeSearchItem = direction ? channels[0] : channels[channels.length - 1];
this.scrollToActive();
if (!activeSearchItem.value || currentIndex === -1) {
activeSearchItem.value = direction ? channels[0] : channels[channels.length - 1];
scrollToActive();
return;
}
@ -467,19 +525,54 @@ export default {
currentIndex -= channels.length;
}
this.activeSearchItem = channels[currentIndex];
this.scrollToActive();
},
scrollToActive() {
// Scroll the list if needed after the active class is applied
this.$nextTick(() => {
const el = this.$refs.networklist.querySelector(".channel-list-item.active");
activeSearchItem.value = channels[currentIndex];
scrollToActive();
};
if (el) {
el.scrollIntoView({block: "nearest", inline: "nearest"});
}
});
},
watch(searchText, () => {
setActiveSearchItem();
});
onMounted(() => {
Mousetrap.bind("alt+shift+right", expandNetwork);
Mousetrap.bind("alt+shift+left", collapseNetwork);
Mousetrap.bind("alt+j", toggleSearch);
});
onBeforeUnmount(() => {
Mousetrap.unbind("alt+shift+right");
Mousetrap.unbind("alt+shift+left");
Mousetrap.unbind("alt+j");
});
const networkContainerRef = ref<HTMLDivElement>();
const channelRefs = ref<{[key: string]: HTMLDivElement}>({});
return {
store,
networklist,
searchInput,
searchText,
results,
activeSearchItem,
LONG_TOUCH_DURATION,
activateSearch,
deactivateSearch,
toggleSearch,
setSearchText,
setActiveSearchItem,
scrollToActive,
selectResult,
navigateResults,
onChannelSort,
onNetworkSort,
onDraggableTouchStart,
onDraggableTouchMove,
onDraggableTouchEnd,
onDraggableChoose,
onDraggableUnchoose,
};
},
};
});
</script>

View File

@ -45,40 +45,57 @@
</ChannelWrapper>
</template>
<script>
<script lang="ts">
import {computed, defineComponent, PropType} from "vue";
import collapseNetwork from "../js/helpers/collapseNetwork";
import roundBadgeNumber from "../js/helpers/roundBadgeNumber";
import ChannelWrapper from "./ChannelWrapper.vue";
export default {
import type {ClientChan, ClientNetwork} from "../js/types";
export default defineComponent({
name: "Channel",
components: {
ChannelWrapper,
},
props: {
network: Object,
network: {
type: Object as PropType<ClientNetwork>,
required: true,
},
isJoinChannelShown: Boolean,
active: Boolean,
isFiltering: Boolean,
},
computed: {
channel() {
return this.network.channels[0];
},
joinChannelLabel() {
return this.isJoinChannelShown ? "Cancel" : "Join a channel…";
},
unreadCount() {
return roundBadgeNumber(this.channel.unread);
},
},
methods: {
onCollapseClick() {
collapseNetwork(this.network, !this.network.isCollapsed);
},
getExpandLabel(network) {
emits: ["toggle-join-channel"],
setup(props) {
const channel = computed(() => {
return props.network.channels[0];
});
const joinChannelLabel = computed(() => {
return props.isJoinChannelShown ? "Cancel" : "Join a channel…";
});
const unreadCount = computed(() => {
return roundBadgeNumber(channel.value.unread);
});
const onCollapseClick = () => {
collapseNetwork(props.network, !props.network.isCollapsed);
};
const getExpandLabel = (network: ClientNetwork) => {
return network.isCollapsed ? "Expand" : "Collapse";
},
};
return {
channel,
joinChannelLabel,
unreadCount,
onCollapseClick,
getExpandLabel,
};
},
};
});
</script>

View File

@ -1,23 +1,22 @@
<script>
<script lang="ts">
import {defineComponent, PropType, h} from "vue";
import parse from "../js/helpers/parse";
import type {ClientMessage, ClientNetwork} from "../js/types";
export default {
export default defineComponent({
name: "ParsedMessage",
functional: true,
props: {
text: String,
message: Object,
network: Object,
message: {type: Object as PropType<ClientMessage | string>, required: false},
network: {type: Object as PropType<ClientNetwork>, required: false},
},
render(createElement, context) {
render(context) {
return parse(
createElement,
typeof context.props.text !== "undefined"
? context.props.text
: context.props.message.text,
context.props.message,
context.props.network
typeof context.text !== "undefined" ? context.text : context.message.text,
context.message,
context.network
);
},
};
});
</script>

View File

@ -1,6 +1,6 @@
<template>
<div>
<slot :isVisible="isVisible" />
<slot :is-visible="isVisible" />
<span
ref="revealButton"
type="button"
@ -16,18 +16,22 @@
</div>
</template>
<script>
export default {
<script lang="ts">
import {defineComponent, ref} from "vue";
export default defineComponent({
name: "RevealPassword",
data() {
setup() {
const isVisible = ref(false);
const onClick = () => {
isVisible.value = !isVisible.value;
};
return {
isVisible: false,
isVisible,
onClick,
};
},
methods: {
onClick() {
this.isVisible = !this.isVisible;
},
},
};
});
</script>

View File

@ -3,38 +3,64 @@
v-if="activeChannel"
:network="activeChannel.network"
:channel="activeChannel.channel"
:focused="$route.query.focused"
:focused="parseInt(String(route.query.focused), 10)"
@channel-changed="channelChanged"
/>
</template>
<script>
<script lang="ts">
import {watch, computed, defineComponent, onMounted} from "vue";
import {useRoute} from "vue-router";
import {useStore} from "../js/store";
import {ClientChan} from "../js/types";
// Temporary component for routing channels and lobbies
import Chat from "./Chat.vue";
export default {
export default defineComponent({
name: "RoutedChat",
components: {
Chat,
},
computed: {
activeChannel() {
const chanId = parseInt(this.$route.params.id, 10);
const channel = this.$store.getters.findChannel(chanId);
setup() {
const route = useRoute();
const store = useStore();
const activeChannel = computed(() => {
const chanId = parseInt(String(route.params.id || ""), 10);
const channel = store.getters.findChannel(chanId);
return channel;
},
});
const setActiveChannel = () => {
if (activeChannel.value) {
store.commit("activeChannel", activeChannel.value);
}
};
watch(activeChannel, () => {
setActiveChannel();
});
onMounted(() => {
setActiveChannel();
});
const channelChanged = (channel: ClientChan) => {
const chanId = channel.id;
const chanInStore = store.getters.findChannel(chanId);
if (chanInStore?.channel) {
chanInStore.channel.unread = 0;
chanInStore.channel.highlight = 0;
}
};
return {
route,
activeChannel,
channelChanged,
};
},
watch: {
activeChannel() {
this.setActiveChannel();
},
},
mounted() {
this.setActiveChannel();
},
methods: {
setActiveChannel() {
this.$store.commit("activeChannel", this.activeChannel);
},
},
};
});
</script>

View File

@ -45,30 +45,39 @@
}
</style>
<script>
<script lang="ts">
import {computed, defineComponent, PropType} from "vue";
import localetime from "../js/helpers/localetime";
import Auth from "../js/auth";
import socket from "../js/socket";
import {ClientSession} from "../js/store";
export default {
export default defineComponent({
name: "Session",
props: {
session: Object,
},
computed: {
lastUse() {
return localetime(this.session.lastUse);
session: {
type: Object as PropType<ClientSession>,
required: true,
},
},
methods: {
signOut() {
if (!this.session.current) {
socket.emit("sign-out", this.session.token);
setup(props) {
const lastUse = computed(() => {
return localetime(props.session.lastUse);
});
const signOut = () => {
if (!props.session.current) {
socket.emit("sign-out", props.session.token);
} else {
socket.emit("sign-out");
Auth.signout();
}
},
};
return {
lastUse,
signOut,
};
},
};
});
</script>

View File

@ -2,8 +2,8 @@
<div>
<div
v-if="
!$store.state.serverConfiguration.public &&
!$store.state.serverConfiguration.ldapEnabled
!store.state.serverConfiguration?.public &&
!store.state.serverConfiguration?.ldapEnabled
"
id="change-password"
role="group"
@ -68,7 +68,7 @@
</div>
</div>
<div v-if="!$store.state.serverConfiguration.public" class="session-list" role="group">
<div v-if="!store.state.serverConfiguration?.public" class="session-list" role="group">
<h2>Sessions</h2>
<h3>Current session</h3>
@ -84,7 +84,7 @@
</template>
<h3>Other sessions</h3>
<p v-if="$store.state.sessions.length === 0">Loading</p>
<p v-if="store.state.sessions.length === 0">Loading</p>
<p v-else-if="otherSessions.length === 0">
<em>You are not currently logged in to any other device.</em>
</p>
@ -98,46 +98,59 @@
</div>
</template>
<script>
<script lang="ts">
import socket from "../../js/socket";
import RevealPassword from "../RevealPassword.vue";
import Session from "../Session.vue";
import {computed, defineComponent, onMounted, PropType, ref} from "vue";
import {useStore} from "../../js/store";
export default {
export default defineComponent({
name: "UserSettings",
components: {
RevealPassword,
Session,
},
data() {
return {
passwordChangeStatus: null,
passwordErrors: {
missing_fields: "Please enter a new password",
password_mismatch: "Both new password fields must match",
password_incorrect:
"The current password field does not match your account password",
update_failed: "Failed to update your password",
},
props: {
settingsForm: {
type: Object as PropType<HTMLFormElement>,
required: true,
},
},
setup(props) {
const store = useStore();
const passwordErrors = {
missing_fields: "Please enter a new password",
password_mismatch: "Both new password fields must match",
password_incorrect: "The current password field does not match your account password",
update_failed: "Failed to update your password",
};
},
computed: {
currentSession() {
return this.$store.state.sessions.find((item) => item.current);
},
activeSessions() {
return this.$store.state.sessions.filter((item) => !item.current && item.active > 0);
},
otherSessions() {
return this.$store.state.sessions.filter((item) => !item.current && !item.active);
},
},
mounted() {
socket.emit("sessions:get");
},
methods: {
changePassword() {
const allFields = new FormData(this.$refs.settingsForm);
const passwordChangeStatus = ref<{
success: boolean;
error: keyof typeof passwordErrors;
}>();
const currentSession = computed(() => {
return store.state.sessions.find((item) => item.current);
});
const activeSessions = computed(() => {
return store.state.sessions.filter((item) => !item.current && item.active > 0);
});
const otherSessions = computed(() => {
return store.state.sessions.filter((item) => !item.current && !item.active);
});
onMounted(() => {
socket.emit("sessions:get");
});
const changePassword = () => {
const allFields = new FormData(props.settingsForm);
const data = {
old_password: allFields.get("old_password"),
new_password: allFields.get("new_password"),
@ -145,7 +158,7 @@ export default {
};
if (!data.old_password || !data.new_password || !data.verify_password) {
this.passwordChangeStatus = {
passwordChangeStatus.value = {
success: false,
error: "missing_fields",
};
@ -153,7 +166,7 @@ export default {
}
if (data.new_password !== data.verify_password) {
this.passwordChangeStatus = {
passwordChangeStatus.value = {
success: false,
error: "password_mismatch",
};
@ -161,11 +174,22 @@ export default {
}
socket.once("change-password", (response) => {
this.passwordChangeStatus = response;
// TODO type
passwordChangeStatus.value = response as any;
});
socket.emit("change-password", data);
},
};
return {
store,
passwordChangeStatus,
passwordErrors,
currentSession,
activeSessions,
otherSessions,
changePassword,
};
},
};
});
</script>

View File

@ -3,14 +3,14 @@
<h2>Messages</h2>
<div>
<label class="opt">
<input :checked="$store.state.settings.motd" type="checkbox" name="motd" />
<input :checked="store.state.settings.motd" type="checkbox" name="motd" />
Show <abbr title="Message Of The Day">MOTD</abbr>
</label>
</div>
<div>
<label class="opt">
<input
:checked="$store.state.settings.showSeconds"
:checked="store.state.settings.showSeconds"
type="checkbox"
name="showSeconds"
/>
@ -20,24 +20,24 @@
<div>
<label class="opt">
<input
:checked="$store.state.settings.use12hClock"
:checked="store.state.settings.use12hClock"
type="checkbox"
name="use12hClock"
/>
Use 12-hour timestamps
</label>
</div>
<template v-if="$store.state.serverConfiguration.prefetch">
<template v-if="store.state.serverConfiguration?.prefetch">
<h2>Link previews</h2>
<div>
<label class="opt">
<input :checked="$store.state.settings.media" type="checkbox" name="media" />
<input :checked="store.state.settings.media" type="checkbox" name="media" />
Auto-expand media
</label>
</div>
<div>
<label class="opt">
<input :checked="$store.state.settings.links" type="checkbox" name="links" />
<input :checked="store.state.settings.links" type="checkbox" name="links" />
Auto-expand websites
</label>
</div>
@ -54,7 +54,7 @@
<div role="group" aria-labelledby="label-status-messages">
<label class="opt">
<input
:checked="$store.state.settings.statusMessages === 'shown'"
:checked="store.state.settings.statusMessages === 'shown'"
type="radio"
name="statusMessages"
value="shown"
@ -63,7 +63,7 @@
</label>
<label class="opt">
<input
:checked="$store.state.settings.statusMessages === 'condensed'"
:checked="store.state.settings.statusMessages === 'condensed'"
type="radio"
name="statusMessages"
value="condensed"
@ -72,7 +72,7 @@
</label>
<label class="opt">
<input
:checked="$store.state.settings.statusMessages === 'hidden'"
:checked="store.state.settings.statusMessages === 'hidden'"
type="radio"
name="statusMessages"
value="hidden"
@ -84,7 +84,7 @@
<div>
<label class="opt">
<input
:checked="$store.state.settings.coloredNicks"
:checked="store.state.settings.coloredNicks"
type="checkbox"
name="coloredNicks"
/>
@ -92,7 +92,7 @@
</label>
<label class="opt">
<input
:checked="$store.state.settings.autocomplete"
:checked="store.state.settings.autocomplete"
type="checkbox"
name="autocomplete"
/>
@ -112,7 +112,7 @@
</label>
<input
id="nickPostfix"
:value="$store.state.settings.nickPostfix"
:value="store.state.settings.nickPostfix"
type="text"
name="nickPostfix"
class="input"
@ -126,12 +126,12 @@
<label for="theme-select" class="sr-only">Theme</label>
<select
id="theme-select"
:value="$store.state.settings.theme"
:value="store.state.settings.theme"
name="theme"
class="input"
>
<option
v-for="theme in $store.state.serverConfiguration.themes"
v-for="theme in store.state.serverConfiguration?.themes"
:key="theme.name"
:value="theme.name"
>
@ -147,7 +147,7 @@
</label>
<textarea
id="user-specified-css-input"
:value="$store.state.settings.userStyles"
:value="store.state.settings.userStyles"
class="input"
name="userStyles"
placeholder="/* You can override any style with CSS here */"
@ -162,8 +162,18 @@ textarea#user-specified-css-input {
}
</style>
<script>
export default {
<script lang="ts">
import {defineComponent} from "vue";
import {useStore} from "../../js/store";
export default defineComponent({
name: "AppearanceSettings",
};
setup() {
const store = useStore();
return {
store,
};
},
});
</script>

View File

@ -19,12 +19,12 @@
Open irc:// URLs with The Lounge
</button>
</div>
<div v-if="$store.state.serverConfiguration.fileUpload">
<div v-if="store.state.serverConfiguration?.fileUpload">
<h2>File uploads</h2>
<div>
<label class="opt">
<input
:checked="$store.state.settings.uploadCanvas"
:checked="store.state.settings.uploadCanvas"
type="checkbox"
name="uploadCanvas"
/>
@ -39,18 +39,18 @@
</label>
</div>
</div>
<div v-if="!$store.state.serverConfiguration.public">
<div v-if="!store.state.serverConfiguration?.public">
<h2>Settings synchronisation</h2>
<label class="opt">
<input
:checked="$store.state.settings.syncSettings"
:checked="store.state.settings.syncSettings"
type="checkbox"
name="syncSettings"
/>
Synchronize settings with other clients
</label>
<template v-if="!$store.state.settings.syncSettings">
<div v-if="$store.state.serverHasSettings" class="settings-sync-panel">
<template v-if="!store.state.settings.syncSettings">
<div v-if="store.state.serverHasSettings" class="settings-sync-panel">
<p>
<strong>Warning:</strong> Checking this box will override the settings of
this client with those stored on the server.
@ -71,14 +71,14 @@
</div>
</template>
</div>
<div v-if="!$store.state.serverConfiguration.public">
<div v-if="!store.state.serverConfiguration?.public">
<h2>Automatic away message</h2>
<label class="opt">
<label for="awayMessage" class="sr-only">Automatic away message</label>
<input
id="awayMessage"
:value="$store.state.settings.awayMessage"
:value="store.state.settings.awayMessage"
type="text"
name="awayMessage"
class="input"
@ -91,53 +91,85 @@
<style></style>
<script>
let installPromptEvent = null;
<script lang="ts">
import {computed, defineComponent, onMounted, ref} from "vue";
import {useStore} from "../../js/store";
import {BeforeInstallPromptEvent} from "../../js/types";
let installPromptEvent: BeforeInstallPromptEvent | null = null;
window.addEventListener("beforeinstallprompt", (e) => {
e.preventDefault();
installPromptEvent = e;
installPromptEvent = e as BeforeInstallPromptEvent;
});
export default {
export default defineComponent({
name: "GeneralSettings",
data() {
return {
canRegisterProtocol: false,
};
},
computed: {
hasInstallPromptEvent() {
setup() {
const store = useStore();
const canRegisterProtocol = ref(false);
const hasInstallPromptEvent = computed(() => {
// TODO: This doesn't hide the button after clicking
return installPromptEvent !== null;
},
},
mounted() {
// Enable protocol handler registration if supported,
// and the network configuration is not locked
this.canRegisterProtocol =
window.navigator.registerProtocolHandler &&
!this.$store.state.serverConfiguration.lockNetwork;
},
methods: {
nativeInstallPrompt() {
installPromptEvent.prompt();
installPromptEvent = null;
},
onForceSyncClick() {
this.$store.dispatch("settings/syncAll", true);
this.$store.dispatch("settings/update", {
name: "syncSettings",
value: true,
sync: true,
});
},
registerProtocol() {
const uri = document.location.origin + document.location.pathname + "?uri=%s";
});
onMounted(() => {
// Enable protocol handler registration if supported,
// and the network configuration is not locked
canRegisterProtocol.value =
!!window.navigator.registerProtocolHandler &&
!store.state.serverConfiguration?.lockNetwork;
});
const nativeInstallPrompt = () => {
if (!installPromptEvent) {
return;
}
installPromptEvent.prompt().catch((e) => {
// eslint-disable-next-line no-console
console.error(e);
});
installPromptEvent = null;
};
const onForceSyncClick = () => {
store.dispatch("settings/syncAll", true).catch((e) => {
// eslint-disable-next-line no-console
console.error(e);
});
store
.dispatch("settings/update", {
name: "syncSettings",
value: true,
sync: true,
})
.catch((e) => {
// eslint-disable-next-line no-console
console.error(e);
});
};
const registerProtocol = () => {
const uri = document.location.origin + document.location.pathname + "?uri=%s";
// @ts-expect-error
// the third argument is deprecated but recommended for compatibility: https://developer.mozilla.org/en-US/docs/Web/API/Navigator/registerProtocolHandler
window.navigator.registerProtocolHandler("irc", uri, "The Lounge");
// @ts-expect-error
window.navigator.registerProtocolHandler("ircs", uri, "The Lounge");
},
};
return {
store,
canRegisterProtocol,
hasInstallPromptEvent,
nativeInstallPrompt,
onForceSyncClick,
registerProtocol,
};
},
};
});
</script>

View File

@ -90,13 +90,14 @@
}
</style>
<script>
<script lang="ts">
import SettingTabItem from "./SettingTabItem.vue";
import {defineComponent} from "vue";
export default {
export default defineComponent({
name: "SettingsTabs",
components: {
SettingTabItem,
},
};
});
</script>

View File

@ -1,6 +1,6 @@
<template>
<div>
<template v-if="!$store.state.serverConfiguration.public">
<template v-if="!store.state.serverConfiguration?.public">
<h2>Push Notifications</h2>
<div>
<button
@ -8,24 +8,24 @@
type="button"
class="btn"
:disabled="
$store.state.pushNotificationState !== 'supported' &&
$store.state.pushNotificationState !== 'subscribed'
store.state.pushNotificationState !== 'supported' &&
store.state.pushNotificationState !== 'subscribed'
"
@click="onPushButtonClick"
>
<template v-if="$store.state.pushNotificationState === 'subscribed'">
<template v-if="store.state.pushNotificationState === 'subscribed'">
Unsubscribe from push notifications
</template>
<template v-else-if="$store.state.pushNotificationState === 'loading'">
<template v-else-if="store.state.pushNotificationState === 'loading'">
Loading
</template>
<template v-else> Subscribe to push notifications </template>
</button>
<div v-if="$store.state.pushNotificationState === 'nohttps'" class="error">
<div v-if="store.state.pushNotificationState === 'nohttps'" class="error">
<strong>Warning</strong>: Push notifications are only supported over HTTPS
connections.
</div>
<div v-if="$store.state.pushNotificationState === 'unsupported'" class="error">
<div v-if="store.state.pushNotificationState === 'unsupported'" class="error">
<strong>Warning</strong>:
<span>Push notifications are not supported by your browser.</span>
@ -48,17 +48,17 @@
<label class="opt">
<input
id="desktopNotifications"
:checked="$store.state.settings.desktopNotifications"
:disabled="$store.state.desktopNotificationState === 'nohttps'"
:checked="store.state.settings.desktopNotifications"
:disabled="store.state.desktopNotificationState === 'nohttps'"
type="checkbox"
name="desktopNotifications"
/>
Enable browser notifications<br />
<div v-if="$store.state.desktopNotificationState === 'unsupported'" class="error">
<div v-if="store.state.desktopNotificationState === 'unsupported'" class="error">
<strong>Warning</strong>: Notifications are not supported by your browser.
</div>
<div
v-if="$store.state.desktopNotificationState === 'nohttps'"
v-if="store.state.desktopNotificationState === 'nohttps'"
id="warnBlockedDesktopNotifications"
class="error"
>
@ -66,7 +66,7 @@
connections.
</div>
<div
v-if="$store.state.desktopNotificationState === 'blocked'"
v-if="store.state.desktopNotificationState === 'blocked'"
id="warnBlockedDesktopNotifications"
class="error"
>
@ -77,7 +77,7 @@
<div>
<label class="opt">
<input
:checked="$store.state.settings.notification"
:checked="store.state.settings.notification"
type="checkbox"
name="notification"
/>
@ -93,7 +93,7 @@
<div>
<label class="opt">
<input
:checked="$store.state.settings.notifyAllMessages"
:checked="store.state.settings.notifyAllMessages"
type="checkbox"
name="notifyAllMessages"
/>
@ -101,7 +101,7 @@
</label>
</div>
<div v-if="!$store.state.serverConfiguration.public">
<div v-if="!store.state.serverConfiguration?.public">
<label class="opt">
<label for="highlights" class="opt">
Custom highlights
@ -115,7 +115,7 @@ expressions, it will trigger a highlight."
</label>
<input
id="highlights"
:value="$store.state.settings.highlights"
:value="store.state.settings.highlights"
type="text"
name="highlights"
class="input"
@ -125,7 +125,7 @@ expressions, it will trigger a highlight."
</label>
</div>
<div v-if="!$store.state.serverConfiguration.public">
<div v-if="!store.state.serverConfiguration?.public">
<label class="opt">
<label for="highlightExceptions" class="opt">
Highlight exceptions
@ -140,7 +140,7 @@ your nickname or expressions defined in custom highlights."
</label>
<input
id="highlightExceptions"
:value="$store.state.settings.highlightExceptions"
:value="store.state.settings.highlightExceptions"
type="text"
name="highlightExceptions"
class="input"
@ -152,15 +152,18 @@ your nickname or expressions defined in custom highlights."
</div>
</template>
<script>
<script lang="ts">
import {computed, defineComponent} from "vue";
import {useStore} from "../../js/store";
import webpush from "../../js/webpush";
export default {
export default defineComponent({
name: "NotificationSettings",
data() {
return {
// https://stackoverflow.com/questions/9038625/detect-if-device-is-ios
isIOS: () =>
setup() {
const store = useStore();
const isIOS = computed(
() =>
[
"iPad Simulator",
"iPhone Simulator",
@ -170,18 +173,27 @@ export default {
"iPod",
].includes(navigator.platform) ||
// iPad on iOS 13 detection
(navigator.userAgent.includes("Mac") && "ontouchend" in document),
};
},
methods: {
playNotification() {
(navigator.userAgent.includes("Mac") && "ontouchend" in document)
);
const playNotification = () => {
const pop = new Audio();
pop.src = "audio/pop.wav";
// eslint-disable-next-line
pop.play();
},
onPushButtonClick() {
};
const onPushButtonClick = () => {
webpush.togglePushSubscription();
},
};
return {
isIOS,
store,
playNotification,
onPushButtonClick,
};
},
};
});
</script>

View File

@ -1,24 +1,22 @@
<template>
<li :aria-label="name">
<router-link
v-slot:default="{navigate, isExactActive}"
:to="'/settings/' + to"
:class="['icon', className]"
:aria-label="name"
role="tab"
aria-controls="settings"
:aria-selected="$route.name === name"
custom
>
<button :class="{active: isExactActive}" @click="navigate" @keypress.enter="navigate">
<li :aria-label="name" role="tab" :aria-selected="route.name === name" aria-controls="settings">
<router-link v-slot:default="{navigate, isExactActive}" :to="'/settings/' + to" custom>
<button
:class="['icon', className, {active: isExactActive}]"
@click="navigate"
@keypress.enter="navigate"
>
{{ name }}
</button>
</router-link>
</li>
</template>
<script>
export default {
<script lang="ts">
import {defineComponent} from "vue";
import {useRoute} from "vue-router";
export default defineComponent({
name: "SettingTabListItem",
props: {
name: {
@ -34,5 +32,12 @@ export default {
required: true,
},
},
};
setup() {
const route = useRoute();
return {
route,
};
},
});
</script>

View File

@ -34,172 +34,234 @@
class="tooltipped tooltipped-n tooltipped-no-touch"
aria-label="Connect to network"
><router-link
v-slot:default="{navigate, isActive}"
to="/connect"
tag="button"
active-class="active"
:class="['icon', 'connect']"
aria-label="Connect to network"
role="tab"
aria-controls="connect"
:aria-selected="$route.name === 'Connect'"
/></span>
>
<button
:class="['icon', 'connect', {active: isActive}]"
:aria-selected="isActive"
@click="navigate"
@keypress.enter="navigate"
/> </router-link
></span>
<span class="tooltipped tooltipped-n tooltipped-no-touch" aria-label="Settings"
><router-link
v-slot:default="{navigate, isActive}"
to="/settings"
tag="button"
active-class="active"
:class="['icon', 'settings']"
aria-label="Settings"
role="tab"
aria-controls="settings"
:aria-selected="$route.name === 'General'"
/></span>
>
<button
:class="['icon', 'settings', {active: isActive}]"
:aria-selected="isActive"
@click="navigate"
@keypress.enter="navigate"
></button> </router-link
></span>
<span
class="tooltipped tooltipped-n tooltipped-no-touch"
:aria-label="
$store.state.serverConfiguration.isUpdateAvailable
store.state.serverConfiguration?.isUpdateAvailable
? 'Help\n(update available)'
: 'Help'
"
><router-link
v-slot:default="{navigate, isActive}"
to="/help"
tag="button"
active-class="active"
:class="[
'icon',
'help',
{notified: $store.state.serverConfiguration.isUpdateAvailable},
]"
aria-label="Help"
role="tab"
aria-controls="help"
:aria-selected="$route.name === 'Help'"
/></span>
>
<button
:aria-selected="route.name === 'Help'"
:class="[
'icon',
'help',
{notified: store.state.serverConfiguration?.isUpdateAvailable},
{active: isActive},
]"
@click="navigate"
@keypress.enter="navigate"
></button> </router-link
></span>
</footer>
</aside>
</template>
<script>
<script lang="ts">
import {defineComponent, onMounted, onUnmounted, PropType, ref} from "vue";
import {useRoute} from "vue-router";
import {useStore} from "../js/store";
import NetworkList from "./NetworkList.vue";
export default {
export default defineComponent({
name: "Sidebar",
components: {
NetworkList,
},
props: {
overlay: HTMLElement,
overlay: {type: Object as PropType<HTMLElement | null>, required: true},
},
data() {
return {
isDevelopment: process.env.NODE_ENV !== "production",
setup(props) {
const isDevelopment = process.env.NODE_ENV !== "production";
const store = useStore();
const route = useRoute();
const touchStartPos = ref<Touch | null>();
const touchCurPos = ref<Touch | null>();
const touchStartTime = ref<number>(0);
const menuWidth = ref<number>(0);
const menuIsMoving = ref<boolean>(false);
const menuIsAbsolute = ref<boolean>(false);
const sidebar = ref<HTMLElement | null>(null);
const toggle = (state: boolean) => {
store.commit("sidebarOpen", state);
};
},
mounted() {
this.touchStartPos = null;
this.touchCurPos = null;
this.touchStartTime = 0;
this.menuWidth = 0;
this.menuIsMoving = false;
this.menuIsAbsolute = false;
this.onTouchStart = (e) => {
this.touchStartPos = this.touchCurPos = e.touches.item(0);
const onTouchMove = (e: TouchEvent) => {
const touch = (touchCurPos.value = e.touches.item(0));
if (e.touches.length !== 1) {
this.onTouchEnd();
if (
!touch ||
!touchStartPos.value ||
!touchStartPos.value.screenX ||
!touchStartPos.value.screenY
) {
return;
}
const styles = window.getComputedStyle(this.$refs.sidebar);
let distX = touch.screenX - touchStartPos.value.screenX;
const distY = touch.screenY - touchStartPos.value.screenY;
this.menuWidth = parseFloat(styles.width);
this.menuIsAbsolute = styles.position === "absolute";
if (!this.$store.state.sidebarOpen || this.touchStartPos.screenX > this.menuWidth) {
this.touchStartTime = Date.now();
document.body.addEventListener("touchmove", this.onTouchMove, {passive: true});
document.body.addEventListener("touchend", this.onTouchEnd, {passive: true});
}
};
this.onTouchMove = (e) => {
const touch = (this.touchCurPos = e.touches.item(0));
let distX = touch.screenX - this.touchStartPos.screenX;
const distY = touch.screenY - this.touchStartPos.screenY;
if (!this.menuIsMoving) {
if (!menuIsMoving.value) {
// tan(45°) is 1. Gestures in 0°-45° (< 1) are considered horizontal, so
// menu must be open; gestures in 45°-90° (>1) are considered vertical, so
// chat windows must be scrolled.
if (Math.abs(distY / distX) >= 1) {
this.onTouchEnd();
// eslint-disable-next-line no-use-before-define
onTouchEnd();
return;
}
const devicePixelRatio = window.devicePixelRatio || 2;
if (Math.abs(distX) > devicePixelRatio) {
this.$store.commit("sidebarDragging", true);
this.menuIsMoving = true;
store.commit("sidebarDragging", true);
menuIsMoving.value = true;
}
}
// Do not animate the menu on desktop view
if (!this.menuIsAbsolute) {
if (!menuIsAbsolute.value) {
return;
}
if (this.$store.state.sidebarOpen) {
distX += this.menuWidth;
if (store.state.sidebarOpen) {
distX += menuWidth.value;
}
if (distX > this.menuWidth) {
distX = this.menuWidth;
if (distX > menuWidth.value) {
distX = menuWidth.value;
} else if (distX < 0) {
distX = 0;
}
this.$refs.sidebar.style.transform = "translate3d(" + distX + "px, 0, 0)";
this.overlay.style.opacity = distX / this.menuWidth;
if (sidebar.value) {
sidebar.value.style.transform = "translate3d(" + distX.toString() + "px, 0, 0)";
}
if (props.overlay) {
props.overlay.style.opacity = `${distX / menuWidth.value}`;
}
};
this.onTouchEnd = () => {
const diff = this.touchCurPos.screenX - this.touchStartPos.screenX;
const onTouchEnd = () => {
if (!touchStartPos.value?.screenX || !touchCurPos.value?.screenX) {
return;
}
const diff = touchCurPos.value.screenX - touchStartPos.value.screenX;
const absDiff = Math.abs(diff);
if (
absDiff > this.menuWidth / 2 ||
(Date.now() - this.touchStartTime < 180 && absDiff > 50)
absDiff > menuWidth.value / 2 ||
(Date.now() - touchStartTime.value < 180 && absDiff > 50)
) {
this.toggle(diff > 0);
toggle(diff > 0);
}
document.body.removeEventListener("touchmove", this.onTouchMove);
document.body.removeEventListener("touchend", this.onTouchEnd);
this.$store.commit("sidebarDragging", false);
document.body.removeEventListener("touchmove", onTouchMove);
document.body.removeEventListener("touchend", onTouchEnd);
this.$refs.sidebar.style.transform = null;
this.overlay.style.opacity = null;
store.commit("sidebarDragging", false);
this.touchStartPos = null;
this.touchCurPos = null;
this.touchStartTime = 0;
this.menuIsMoving = false;
if (sidebar.value) {
sidebar.value.style.transform = "";
}
if (props.overlay) {
props.overlay.style.opacity = "";
}
touchStartPos.value = null;
touchCurPos.value = null;
touchStartTime.value = 0;
menuIsMoving.value = false;
};
this.toggle = (state) => {
this.$store.commit("sidebarOpen", state);
const onTouchStart = (e: TouchEvent) => {
if (!sidebar.value) {
return;
}
touchStartPos.value = touchCurPos.value = e.touches.item(0);
if (e.touches.length !== 1) {
onTouchEnd();
return;
}
const styles = window.getComputedStyle(sidebar.value);
menuWidth.value = parseFloat(styles.width);
menuIsAbsolute.value = styles.position === "absolute";
if (
!store.state.sidebarOpen ||
(touchStartPos.value?.screenX && touchStartPos.value.screenX > menuWidth.value)
) {
touchStartTime.value = Date.now();
document.body.addEventListener("touchmove", onTouchMove, {passive: true});
document.body.addEventListener("touchend", onTouchEnd, {passive: true});
}
};
document.body.addEventListener("touchstart", this.onTouchStart, {passive: true});
onMounted(() => {
document.body.addEventListener("touchstart", onTouchStart, {passive: true});
});
onUnmounted(() => {
document.body.removeEventListener("touchstart", onTouchStart);
});
const isPublic = () => document.body.classList.contains("public");
return {
isDevelopment,
store,
route,
sidebar,
toggle,
onTouchStart,
onTouchMove,
onTouchEnd,
isPublic,
};
},
destroyed() {
document.body.removeEventListener("touchstart", this.onTouchStart, {passive: true});
},
methods: {
isPublic: () => document.body.classList.contains("public"),
},
};
});
</script>

View File

@ -1,9 +1,19 @@
<template>
<button class="lt" aria-label="Toggle channel list" @click="$store.commit('toggleSidebar')" />
<button class="lt" aria-label="Toggle channel list" @click="store.commit('toggleSidebar')" />
</template>
<script>
export default {
<script lang="ts">
import {defineComponent} from "vue";
import {useStore} from "../js/store";
export default defineComponent({
name: "SidebarToggle",
};
setup() {
const store = useStore();
return {
store,
};
},
});
</script>

View File

@ -17,23 +17,29 @@
</table>
</template>
<script>
<script lang="ts">
import ParsedMessage from "../ParsedMessage.vue";
import localetime from "../../js/helpers/localetime";
import localeTime from "../../js/helpers/localetime";
import {defineComponent, PropType} from "vue";
import type {ClientNetwork, ClientChan} from "../../js/types";
export default {
export default defineComponent({
name: "ListBans",
components: {
ParsedMessage,
},
props: {
network: Object,
channel: Object,
network: {type: Object as PropType<ClientNetwork>, required: true},
channel: {type: Object as PropType<ClientChan>, required: true},
},
methods: {
localetime(date) {
return localetime(date);
},
setup() {
const localetime = (date: number | Date) => {
return localeTime(date);
};
return {
localetime,
};
},
};
});
</script>

View File

@ -18,17 +18,19 @@
</table>
</template>
<script>
<script lang="ts">
import {defineComponent, PropType} from "vue";
import {ClientChan, ClientNetwork} from "../../js/types";
import ParsedMessage from "../ParsedMessage.vue";
export default {
export default defineComponent({
name: "ListChannels",
components: {
ParsedMessage,
},
props: {
network: Object,
channel: Object,
network: {type: Object as PropType<ClientNetwork>, required: true},
channel: {type: Object as PropType<ClientChan>, required: true},
},
};
});
</script>

View File

@ -15,23 +15,25 @@
</table>
</template>
<script>
<script lang="ts">
import ParsedMessage from "../ParsedMessage.vue";
import localetime from "../../js/helpers/localetime";
import {defineComponent, PropType} from "vue";
import {ClientNetwork, ClientChan} from "../../js/types";
export default {
export default defineComponent({
name: "ListIgnored",
components: {
ParsedMessage,
},
props: {
network: Object,
channel: Object,
network: {type: Object as PropType<ClientNetwork>, required: true},
channel: {type: Object as PropType<ClientChan>, required: true},
},
methods: {
localetime(date) {
return localetime(date);
},
setup() {
return {
localetime,
};
},
};
});
</script>

View File

@ -19,23 +19,25 @@
</table>
</template>
<script>
<script lang="ts">
import ParsedMessage from "../ParsedMessage.vue";
import localetime from "../../js/helpers/localetime";
import {defineComponent, PropType} from "vue";
import {ClientNetwork, ClientChan} from "../../js/types";
export default {
export default defineComponent({
name: "ListInvites",
components: {
ParsedMessage,
},
props: {
network: Object,
channel: Object,
network: {type: Object as PropType<ClientNetwork>, required: true},
channel: {type: Object as PropType<ClientChan>, required: true},
},
methods: {
localetime(date) {
return localetime(date);
},
setup() {
return {
localetime: (date: Date) => localetime(date),
};
},
};
});
</script>

View File

@ -10,44 +10,71 @@
>
</template>
<script>
<script lang="ts">
import {computed, defineComponent, PropType} from "vue";
import {UserInMessage} from "../../server/models/msg";
import eventbus from "../js/eventbus";
import colorClass from "../js/helpers/colorClass";
import type {ClientChan, ClientNetwork, ClientUser} from "../js/types";
export default {
type UsernameUser = Partial<UserInMessage> & {
mode?: string;
nick: string;
};
export default defineComponent({
name: "Username",
props: {
user: Object,
user: {
// TODO: UserInMessage shouldn't be necessary here.
type: Object as PropType<UsernameUser | UserInMessage>,
required: true,
},
active: Boolean,
onHover: Function,
channel: Object,
network: Object,
onHover: {
type: Function as PropType<(user: UserInMessage) => void>,
required: false,
},
channel: {type: Object as PropType<ClientChan>, required: false},
network: {type: Object as PropType<ClientNetwork>, required: false},
},
computed: {
mode() {
setup(props) {
const mode = computed(() => {
// Message objects have a singular mode, but user objects have modes array
if (this.user.modes) {
return this.user.modes[0];
if (props.user.modes) {
return props.user.modes[0];
}
return this.user.mode;
},
nickColor() {
return colorClass(this.user.nick);
},
},
methods: {
hover() {
return this.onHover(this.user);
},
openContextMenu(event) {
return props.user.mode;
});
// TODO: Nick must be ! because our user prop union includes UserInMessage
const nickColor = computed(() => colorClass(props.user.nick!));
const hover = () => {
if (props.onHover) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return props.onHover(props.user as UserInMessage);
}
return null;
};
const openContextMenu = (event: Event) => {
eventbus.emit("contextmenu:user", {
event: event,
user: this.user,
network: this.network,
channel: this.channel,
user: props.user,
network: props.network,
channel: props.channel,
});
},
};
return {
mode,
nickColor,
hover,
openContextMenu,
};
},
};
});
</script>

View File

@ -1,25 +1,25 @@
<template>
<div id="version-checker" :class="[$store.state.versionStatus]">
<p v-if="$store.state.versionStatus === 'loading'">Checking for updates</p>
<p v-if="$store.state.versionStatus === 'new-version'">
The Lounge <b>{{ $store.state.versionData.latest.version }}</b>
<template v-if="$store.state.versionData.latest.prerelease"> (pre-release) </template>
<div id="version-checker" :class="[store.state.versionStatus]">
<p v-if="store.state.versionStatus === 'loading'">Checking for updates</p>
<p v-if="store.state.versionStatus === 'new-version'">
The Lounge <b>{{ store.state.versionData?.latest.version }}</b>
<template v-if="store.state.versionData?.latest.prerelease"> (pre-release) </template>
is now available.
<br />
<a :href="$store.state.versionData.latest.url" target="_blank" rel="noopener">
<a :href="store.state.versionData?.latest.url" target="_blank" rel="noopener">
Read more on GitHub
</a>
</p>
<p v-if="$store.state.versionStatus === 'new-packages'">
<p v-if="store.state.versionStatus === 'new-packages'">
The Lounge is up to date, but there are out of date packages Run
<code>thelounge upgrade</code> on the server to upgrade packages.
</p>
<template v-if="$store.state.versionStatus === 'up-to-date'">
<template v-if="store.state.versionStatus === 'up-to-date'">
<p>The Lounge is up to date!</p>
<button
v-if="$store.state.versionDataExpired"
v-if="store.state.versionDataExpired"
id="check-now"
class="btn btn-small"
@click="checkNow"
@ -27,7 +27,7 @@
Check now
</button>
</template>
<template v-if="$store.state.versionStatus === 'error'">
<template v-if="store.state.versionStatus === 'error'">
<p>Information about latest release could not be retrieved.</p>
<button id="check-now" class="btn btn-small" @click="checkNow">Try again</button>
@ -35,22 +35,32 @@
</div>
</template>
<script>
<script lang="ts">
import {defineComponent, onMounted} from "vue";
import socket from "../js/socket";
import {useStore} from "../js/store";
export default {
export default defineComponent({
name: "VersionChecker",
mounted() {
if (!this.$store.state.versionData) {
this.checkNow();
}
},
methods: {
checkNow() {
this.$store.commit("versionData", null);
this.$store.commit("versionStatus", "loading");
setup() {
const store = useStore();
const checkNow = () => {
store.commit("versionData", null);
store.commit("versionStatus", "loading");
socket.emit("changelog");
},
};
onMounted(() => {
if (!store.state.versionData) {
checkNow();
}
});
return {
store,
checkNow,
};
},
};
});
</script>

View File

@ -7,29 +7,26 @@
<router-link id="back-to-help" to="/help">« Help</router-link>
<template
v-if="
$store.state.versionData &&
$store.state.versionData.current &&
$store.state.versionData.current.version
"
v-if="store.state.versionData?.current && store.state.versionData?.current.version"
>
<h1 class="title">
Release notes for {{ $store.state.versionData.current.version }}
Release notes for {{ store.state.versionData.current.version }}
</h1>
<template v-if="$store.state.versionData.current.changelog">
<template v-if="store.state.versionData.current.changelog">
<h3>Introduction</h3>
<div
ref="changelog"
class="changelog-text"
v-html="$store.state.versionData.current.changelog"
v-html="store.state.versionData.current.changelog"
></div>
</template>
<template v-else>
<p>Unable to retrieve changelog for current release from GitHub.</p>
<p>
<a
:href="`https://github.com/thelounge/thelounge/releases/tag/v${$store.state.serverConfiguration.version}`"
v-if="store.state.serverConfiguration?.version"
:href="`https://github.com/thelounge/thelounge/releases/tag/v${store.state.serverConfiguration?.version}`"
target="_blank"
rel="noopener"
>View release notes for this version on GitHub</a
@ -42,34 +39,29 @@
</div>
</template>
<script>
<script lang="ts">
import {defineComponent, onMounted, onUpdated, ref} from "vue";
import socket from "../../js/socket";
import {useStore} from "../../js/store";
import SidebarToggle from "../SidebarToggle.vue";
export default {
export default defineComponent({
name: "Changelog",
components: {
SidebarToggle,
},
mounted() {
if (!this.$store.state.versionData) {
socket.emit("changelog");
}
setup() {
const store = useStore();
const changelog = ref<HTMLDivElement | null>(null);
this.patchChangelog();
},
updated() {
this.patchChangelog();
},
methods: {
patchChangelog() {
if (!this.$refs.changelog) {
const patchChangelog = () => {
if (!changelog.value) {
return;
}
const links = this.$refs.changelog.querySelectorAll("a");
const links = changelog.value.querySelectorAll("a");
for (const link of links) {
links.forEach((link) => {
// Make sure all links will open a new tab instead of exiting the application
link.setAttribute("target", "_blank");
link.setAttribute("rel", "noopener");
@ -78,8 +70,24 @@ export default {
// Add required metadata to image links, to support built-in image viewer
link.classList.add("toggle-thumbnail");
}
});
};
onMounted(() => {
if (!store.state.versionData) {
socket.emit("changelog");
}
},
patchChangelog();
});
onUpdated(() => {
patchChangelog();
});
return {
store,
};
},
};
});
</script>

View File

@ -2,11 +2,14 @@
<NetworkForm :handle-submit="handleSubmit" :defaults="defaults" :disabled="disabled" />
</template>
<script>
import socket from "../../js/socket";
import NetworkForm from "../NetworkForm.vue";
<script lang="ts">
import {defineComponent, ref} from "vue";
export default {
import socket from "../../js/socket";
import {useStore} from "../../js/store";
import NetworkForm, {NetworkFormDefaults} from "../NetworkForm.vue";
export default defineComponent({
name: "Connect",
components: {
NetworkForm,
@ -14,25 +17,22 @@ export default {
props: {
queryParams: Object,
},
data() {
// Merge settings from url params into default settings
const defaults = Object.assign(
{},
this.$store.state.serverConfiguration.defaults,
this.parseOverrideParams(this.queryParams)
);
return {
disabled: false,
defaults,
};
},
methods: {
handleSubmit(data) {
this.disabled = true;
setup(props) {
const store = useStore();
const disabled = ref(false);
const handleSubmit = (data: Record<string, any>) => {
disabled.value = true;
socket.emit("network:new", data);
},
parseOverrideParams(params) {
const parsedParams = {};
};
const parseOverrideParams = (params?: Record<string, string>) => {
if (!params) {
return {};
}
const parsedParams: Record<string, any> = {};
for (let key of Object.keys(params)) {
let value = params[key];
@ -49,7 +49,7 @@ export default {
if (
!Object.prototype.hasOwnProperty.call(
this.$store.state.serverConfiguration.defaults,
store.state.serverConfiguration?.defaults,
key
)
) {
@ -58,7 +58,7 @@ export default {
// When the network is locked, URL overrides should not affect disabled fields
if (
this.$store.state.serverConfiguration.lockNetwork &&
store.state.serverConfiguration?.lockNetwork &&
["name", "host", "port", "tls", "rejectUnauthorized"].includes(key)
) {
continue;
@ -78,7 +78,7 @@ export default {
}
// Override server provided defaults with parameters passed in the URL if they match the data type
switch (typeof this.$store.state.serverConfiguration.defaults[key]) {
switch (typeof store.state.serverConfiguration?.defaults[key]) {
case "boolean":
if (value === "0" || value === "false") {
parsedParams[key] = false;
@ -97,7 +97,21 @@ export default {
}
return parsedParams;
},
};
const defaults = ref<Partial<NetworkFormDefaults>>(
Object.assign(
{},
store.state.serverConfiguration?.defaults,
parseOverrideParams(props.queryParams)
)
);
return {
defaults,
disabled,
handleSubmit,
};
},
};
});
</script>

View File

@ -9,7 +9,7 @@
<h2 class="help-version-title">
<span>About The Lounge</span>
<small>
v{{ $store.state.serverConfiguration.version }} (<router-link
v{{ store.state.serverConfiguration?.version }} (<router-link
id="view-changelog"
to="/changelog"
>release notes</router-link
@ -20,13 +20,13 @@
<div class="about">
<VersionChecker />
<template v-if="$store.state.serverConfiguration.gitCommit">
<template v-if="store.state.serverConfiguration?.gitCommit">
<p>
The Lounge is running from source (<a
:href="`https://github.com/thelounge/thelounge/tree/${$store.state.serverConfiguration.gitCommit}`"
:href="`https://github.com/thelounge/thelounge/tree/${store.state.serverConfiguration?.gitCommit}`"
target="_blank"
rel="noopener"
>commit <code>{{ $store.state.serverConfiguration.gitCommit }}</code></a
>commit <code>{{ store.state.serverConfiguration?.gitCommit }}</code></a
>).
</p>
@ -34,11 +34,11 @@
<li>
Compare
<a
:href="`https://github.com/thelounge/thelounge/compare/${$store.state.serverConfiguration.gitCommit}...master`"
:href="`https://github.com/thelounge/thelounge/compare/${store.state.serverConfiguration?.gitCommit}...master`"
target="_blank"
rel="noopener"
>between
<code>{{ $store.state.serverConfiguration.gitCommit }}</code> and
<code>{{ store.state.serverConfiguration?.gitCommit }}</code> and
<code>master</code></a
>
to see what you are missing
@ -46,12 +46,12 @@
<li>
Compare
<a
:href="`https://github.com/thelounge/thelounge/compare/${$store.state.serverConfiguration.version}...${$store.state.serverConfiguration.gitCommit}`"
:href="`https://github.com/thelounge/thelounge/compare/${store.state.serverConfiguration?.version}...${store.state.serverConfiguration?.gitCommit}`"
target="_blank"
rel="noopener"
>between
<code>{{ $store.state.serverConfiguration.version }}</code> and
<code>{{ $store.state.serverConfiguration.gitCommit }}</code></a
<code>{{ store.state.serverConfiguration?.version }}</code> and
<code>{{ store.state.serverConfiguration?.gitCommit }}</code></a
>
to see your local changes
</li>
@ -749,7 +749,7 @@
</div>
</div>
<div v-if="$store.state.settings.searchEnabled" class="help-item">
<div v-if="store.state.settings.searchEnabled" class="help-item">
<div class="subject">
<code>/search query</code>
</div>
@ -829,21 +829,28 @@
</div>
</template>
<script>
<script lang="ts">
import {defineComponent, ref} from "vue";
import {useStore} from "../../js/store";
import SidebarToggle from "../SidebarToggle.vue";
import VersionChecker from "../VersionChecker.vue";
export default {
export default defineComponent({
name: "Help",
components: {
SidebarToggle,
VersionChecker,
},
data() {
setup() {
const store = useStore();
const isApple = navigator.platform.match(/(Mac|iPhone|iPod|iPad)/i) || false;
const isTouch = navigator.maxTouchPoints > 0;
return {
isApple: navigator.platform.match(/(Mac|iPhone|iPod|iPad)/i) || false,
isTouch: navigator.maxTouchPoints > 0,
isApple,
isTouch,
store,
};
},
};
});
</script>

View File

@ -7,44 +7,61 @@
/>
</template>
<script>
<script lang="ts">
import {defineComponent, onMounted, ref, watch} from "vue";
import {useRoute} from "vue-router";
import {switchToChannel} from "../../js/router";
import socket from "../../js/socket";
import NetworkForm from "../NetworkForm.vue";
import {useStore} from "../../js/store";
import NetworkForm, {NetworkFormDefaults} from "../NetworkForm.vue";
export default {
export default defineComponent({
name: "NetworkEdit",
components: {
NetworkForm,
},
data() {
return {
disabled: false,
networkData: null,
setup() {
const route = useRoute();
const store = useStore();
const disabled = ref(false);
const networkData = ref<NetworkFormDefaults | null>(null);
const setNetworkData = () => {
socket.emit("network:get", String(route.params.uuid || ""));
networkData.value = store.getters.findNetwork(String(route.params.uuid || ""));
};
},
watch: {
"$route.params.uuid"() {
this.setNetworkData();
},
},
mounted() {
this.setNetworkData();
},
methods: {
setNetworkData() {
socket.emit("network:get", this.$route.params.uuid);
this.networkData = this.$store.getters.findNetwork(this.$route.params.uuid);
},
handleSubmit(data) {
this.disabled = true;
const handleSubmit = (data: {uuid: string; name: string}) => {
disabled.value = true;
socket.emit("network:edit", data);
// TODO: move networks to vuex and update state when the network info comes in
const network = this.$store.getters.findNetwork(data.uuid);
network.name = network.channels[0].name = data.name;
const network = store.getters.findNetwork(data.uuid);
this.$root.switchToChannel(network.channels[0]);
},
if (network) {
network.name = network.channels[0].name = data.name;
switchToChannel(network.channels[0]);
}
};
watch(
() => route.params.uuid,
(newValue) => {
setNetworkData();
}
);
onMounted(() => {
setNetworkData();
});
return {
disabled,
networkData,
handleSubmit,
};
},
};
});
</script>

View File

@ -3,9 +3,9 @@
<div
id="chat"
:class="{
'colored-nicks': $store.state.settings.coloredNicks,
'time-seconds': $store.state.settings.showSeconds,
'time-12h': $store.state.settings.use12hClock,
'colored-nicks': store.state.settings.coloredNicks,
'time-seconds': store.state.settings.showSeconds,
'time-12h': store.state.settings.use12hClock,
}"
>
<div
@ -14,12 +14,12 @@
aria-label="Search results"
role="tabpanel"
>
<div class="header">
<div v-if="network && channel" class="header">
<SidebarToggle />
<span class="title"
>Searching in <span class="channel-name">{{ channel.name }}</span> for</span
>
<span class="topic">{{ $route.query.q }}</span>
<span class="topic">{{ route.query.q }}</span>
<MessageSearchForm :network="network" :channel="channel" />
<button
class="close"
@ -28,25 +28,24 @@
@click="closeSearch"
/>
</div>
<div class="chat-content">
<div v-if="network && channel" class="chat-content">
<div ref="chat" class="chat" tabindex="-1">
<div v-show="moreResultsAvailable" class="show-more">
<button
ref="loadMoreButton"
:disabled="
$store.state.messageSearchInProgress ||
!$store.state.isConnected
store.state.messageSearchInProgress || !store.state.isConnected
"
class="btn"
@click="onShowMoreClick"
>
<span v-if="$store.state.messageSearchInProgress">Loading</span>
<span v-if="store.state.messageSearchInProgress">Loading</span>
<span v-else>Show older messages</span>
</button>
</div>
<div
v-if="$store.state.messageSearchInProgress && !offset"
v-if="store.state.messageSearchInProgress && !offset"
class="search-status"
>
Searching
@ -55,28 +54,30 @@
No results found.
</div>
<div
v-else
class="messages"
role="log"
aria-live="polite"
aria-relevant="additions"
>
<template v-for="(message, id) in messages">
<div :key="message.id" class="result" @:click="jump(message, id)">
<DateMarker
v-if="shouldDisplayDateMarker(message, id)"
:key="message.date"
:message="message"
/>
<Message
:key="message.id"
:channel="channel"
:network="network"
:message="message"
:data-id="message.id"
/>
</div>
</template>
<div
v-for="(message, id) in messages"
:key="message.id"
class="result"
@click="jump(message, id)"
>
<DateMarker
v-if="shouldDisplayDateMarker(message, id)"
:key="message.id + '-date'"
:message="message"
/>
<Message
:key="message.id"
:channel="channel"
:network="network"
:message="message"
:data-id="message.id"
/>
</div>
</div>
</div>
</div>
@ -91,7 +92,7 @@
}
</style>
<script>
<script lang="ts">
import socket from "../../js/socket";
import eventbus from "../../js/eventbus";
@ -99,8 +100,14 @@ import SidebarToggle from "../SidebarToggle.vue";
import Message from "../Message.vue";
import MessageSearchForm from "../MessageSearchForm.vue";
import DateMarker from "../DateMarker.vue";
import {watch, computed, defineComponent, nextTick, ref, onMounted, onUnmounted} from "vue";
import type {ClientMessage} from "../../js/types";
export default {
import {useStore} from "../../js/store";
import {useRoute, useRouter} from "vue-router";
import {switchToChannel} from "../../js/router";
export default defineComponent({
name: "SearchResults",
components: {
SidebarToggle,
@ -108,145 +115,212 @@ export default {
DateMarker,
MessageSearchForm,
},
data() {
return {
offset: 0,
moreResultsAvailable: false,
oldScrollTop: 0,
oldChatHeight: 0,
};
},
computed: {
search() {
return this.$store.state.messageSearchResults;
},
messages() {
if (!this.search) {
setup() {
const store = useStore();
const route = useRoute();
const router = useRouter();
const chat = ref<HTMLDivElement>();
const loadMoreButton = ref<HTMLButtonElement>();
const offset = ref(0);
const moreResultsAvailable = ref(false);
const oldScrollTop = ref(0);
const oldChatHeight = ref(0);
const search = computed(() => store.state.messageSearchResults);
const messages = computed(() => {
if (!search.value) {
return [];
}
return this.search.results;
},
chan() {
const chanId = parseInt(this.$route.params.id, 10);
return this.$store.getters.findChannel(chanId);
},
network() {
if (!this.chan) {
return search.value.results;
});
const chan = computed(() => {
const chanId = parseInt(String(route.params.id || ""), 10);
return store.getters.findChannel(chanId);
});
const network = computed(() => {
if (!chan.value) {
return null;
}
return this.chan.network;
},
channel() {
if (!this.chan) {
return chan.value.network;
});
const channel = computed(() => {
if (!chan.value) {
return null;
}
return this.chan.channel;
},
},
watch: {
"$route.params.id"() {
this.doSearch();
this.setActiveChannel();
},
"$route.query.q"() {
this.doSearch();
this.setActiveChannel();
},
messages() {
this.moreResultsAvailable = this.messages.length && !(this.messages.length % 100);
return chan.value.channel;
});
if (!this.offset) {
this.jumpToBottom();
} else {
this.$nextTick(() => {
const currentChatHeight = this.$refs.chat.scrollHeight;
this.$refs.chat.scrollTop =
this.oldScrollTop + currentChatHeight - this.oldChatHeight;
});
const setActiveChannel = () => {
if (!chan.value) {
return;
}
},
},
mounted() {
this.setActiveChannel();
this.doSearch();
eventbus.on("escapekey", this.closeSearch);
this.$root.$on("re-search", this.doSearch); // Enable MessageSearchForm to search for the same query again
},
beforeDestroy() {
this.$root.$off("re-search");
},
destroyed() {
eventbus.off("escapekey", this.closeSearch);
},
methods: {
setActiveChannel() {
this.$store.commit("activeChannel", this.chan);
},
closeSearch() {
this.$root.switchToChannel(this.channel);
},
shouldDisplayDateMarker(message, id) {
const previousMessage = this.messages[id - 1];
store.commit("activeChannel", chan.value);
};
const closeSearch = () => {
if (!channel.value) {
return;
}
switchToChannel(channel.value);
};
const shouldDisplayDateMarker = (message: ClientMessage, id: number) => {
const previousMessage = messages.value[id - 1];
if (!previousMessage) {
return true;
}
return new Date(previousMessage.time).getDay() !== new Date(message.time).getDay();
},
doSearch() {
this.offset = 0;
this.$store.commit("messageSearchInProgress", true);
};
if (!this.offset) {
this.$store.commit("messageSearchResults", null); // Only reset if not getting offset
const doSearch = () => {
offset.value = 0;
store.commit("messageSearchInProgress", true);
if (!offset.value) {
store.commit("messageSearchInProgress", undefined); // Only reset if not getting offset
}
socket.emit("search", {
networkUuid: this.network.uuid,
channelName: this.channel.name,
searchTerm: this.$route.query.q,
offset: this.offset,
networkUuid: network.value?.uuid,
channelName: channel.value?.name,
searchTerm: String(route.query.q || ""),
offset: offset.value,
});
},
onShowMoreClick() {
this.offset += 100;
this.$store.commit("messageSearchInProgress", true);
};
this.oldScrollTop = this.$refs.chat.scrollTop;
this.oldChatHeight = this.$refs.chat.scrollHeight;
const onShowMoreClick = () => {
if (!chat.value) {
return;
}
offset.value += 100;
store.commit("messageSearchInProgress", true);
oldScrollTop.value = chat.value.scrollTop;
oldChatHeight.value = chat.value.scrollHeight;
socket.emit("search", {
networkUuid: this.network.uuid,
channelName: this.channel.name,
searchTerm: this.$route.query.q,
offset: this.offset + 1,
networkUuid: network.value?.uuid,
channelName: channel.value?.name,
searchTerm: String(route.query.q || ""),
offset: offset.value + 1,
});
},
jumpToBottom() {
this.$nextTick(() => {
const el = this.$refs.chat;
el.scrollTop = el.scrollHeight;
});
},
jump(message, id) {
};
const jumpToBottom = async () => {
await nextTick();
const el = chat.value;
if (!el) {
return;
}
el.scrollTop = el.scrollHeight;
};
const jump = (message: ClientMessage, id: number) => {
// TODO: Implement jumping to messages!
// This is difficult because it means client will need to handle a potentially nonlinear message set
// (loading IntersectionObserver both before AND after the messages)
this.$router.push({
name: "MessageList",
params: {
id: this.chan.id,
},
query: {
focused: id,
},
});
},
router
.push({
name: "MessageList",
params: {
id: channel.value?.id,
},
query: {
focused: id,
},
})
.catch((e) => {
// eslint-disable-next-line no-console
console.error(`Failed to navigate to message ${id}`, e);
});
};
watch(
() => route.params.id,
() => {
doSearch();
setActiveChannel();
}
);
watch(
() => route.query,
() => {
doSearch();
setActiveChannel();
}
);
watch(messages, async () => {
moreResultsAvailable.value = !!(
messages.value.length && !(messages.value.length % 100)
);
if (!offset.value) {
await jumpToBottom();
} else {
await nextTick();
const el = chat.value;
if (!el) {
return;
}
const currentChatHeight = el.scrollHeight;
el.scrollTop = oldScrollTop.value + currentChatHeight - oldChatHeight.value;
}
});
onMounted(() => {
setActiveChannel();
doSearch();
eventbus.on("escapekey", closeSearch);
eventbus.on("re-search", doSearch);
});
onUnmounted(() => {
eventbus.off("escapekey", closeSearch);
eventbus.off("re-search", doSearch);
});
return {
chat,
loadMoreButton,
messages,
moreResultsAvailable,
search,
network,
channel,
route,
offset,
store,
setActiveChannel,
closeSearch,
shouldDisplayDateMarker,
doSearch,
onShowMoreClick,
jumpToBottom,
jump,
};
},
};
});
</script>

View File

@ -7,42 +7,52 @@
<div class="container">
<form ref="settingsForm" autocomplete="off" @change="onChange" @submit.prevent>
<router-view></router-view>
<router-view :settings-form="settingsForm"></router-view>
</form>
</div>
</div>
</template>
<script>
<script lang="ts">
import {defineComponent, ref} from "vue";
import SidebarToggle from "../SidebarToggle.vue";
import Navigation from "../Settings/Navigation.vue";
import {useStore} from "../../js/store";
export default {
export default defineComponent({
name: "Settings",
components: {
SidebarToggle,
Navigation,
},
methods: {
onChange(event) {
setup() {
const store = useStore();
const settingsForm = ref<HTMLFormElement>();
const onChange = (event: Event) => {
const ignore = ["old_password", "new_password", "verify_password"];
const name = event.target.name;
const name = (event.target as HTMLInputElement).name;
if (ignore.includes(name)) {
return;
}
let value;
let value: boolean | string;
if (event.target.type === "checkbox") {
value = event.target.checked;
if ((event.target as HTMLInputElement).type === "checkbox") {
value = (event.target as HTMLInputElement).checked;
} else {
value = event.target.value;
value = (event.target as HTMLInputElement).value;
}
this.$store.dispatch("settings/update", {name, value, sync: true});
},
void store.dispatch("settings/update", {name, value, sync: true});
};
return {
onChange,
settingsForm,
};
},
};
});
</script>

View File

@ -55,51 +55,69 @@
</div>
</template>
<script>
<script lang="ts">
import storage from "../../js/localStorage";
import socket from "../../js/socket";
import RevealPassword from "../RevealPassword.vue";
import {defineComponent, onBeforeUnmount, onMounted, ref} from "vue";
export default {
export default defineComponent({
name: "SignIn",
components: {
RevealPassword,
},
data() {
return {
inFlight: false,
errorShown: false,
setup() {
const inFlight = ref(false);
const errorShown = ref(false);
const username = ref<HTMLInputElement | null>(null);
const password = ref<HTMLInputElement | null>(null);
const onAuthFailed = () => {
inFlight.value = false;
errorShown.value = true;
};
},
mounted() {
socket.on("auth:failed", this.onAuthFailed);
},
beforeDestroy() {
socket.off("auth:failed", this.onAuthFailed);
},
methods: {
onAuthFailed() {
this.inFlight = false;
this.errorShown = true;
},
onSubmit(event) {
const onSubmit = (event: Event) => {
event.preventDefault();
this.inFlight = true;
this.errorShown = false;
if (!username.value || !password.value) {
return;
}
inFlight.value = true;
errorShown.value = false;
const values = {
user: this.$refs.username.value,
password: this.$refs.password.value,
user: username.value?.value,
password: password.value?.value,
};
storage.set("user", values.user);
socket.emit("auth:perform", values);
},
getStoredUser() {
};
const getStoredUser = () => {
return storage.get("user");
},
};
onMounted(() => {
socket.on("auth:failed", onAuthFailed);
});
onBeforeUnmount(() => {
socket.off("auth:failed", onAuthFailed);
});
return {
inFlight,
errorShown,
username,
password,
onSubmit,
getStoredUser,
};
},
};
});
</script>

View File

@ -587,6 +587,11 @@ p {
/* End icons */
#app {
height: 100%;
width: 100%;
}
#viewport {
display: flex;
height: 100%;

View File

@ -48,7 +48,7 @@
</head>
<body class="<%- public ? " public" : "" %>" data-transports="<%- JSON.stringify(transports) %>">
<div id="viewport"></div>
<div id="app"></div>
<div id="loading">
<div class="window">
<div id="loading-status-container">

View File

@ -1,5 +1,3 @@
"use strict";
import storage from "./localStorage";
import location from "./location";

View File

@ -1,92 +1,92 @@
"use strict";
const constants = require("./constants");
import constants from "./constants";
import Mousetrap from "mousetrap";
import {Textcomplete} from "@textcomplete/core/dist/Textcomplete";
import {TextareaEditor} from "@textcomplete/textarea/dist/TextareaEditor";
import {Strategy, Textcomplete, StrategyProps} from "@textcomplete/core";
import {TextareaEditor} from "@textcomplete/textarea";
import fuzzy from "fuzzy";
import emojiMap from "./helpers/simplemap.json";
import store from "./store";
import {store} from "./store";
export default enableAutocomplete;
const emojiSearchTerms = Object.keys(emojiMap);
const emojiStrategy = {
const emojiStrategy: StrategyProps = {
id: "emoji",
match: /(^|\s):([-+\w:?]{2,}):?$/,
search(term, callback) {
search(term: string, callback: (matches) => void) {
// Trim colon from the matched term,
// as we are unable to get a clean string from match regex
term = term.replace(/:$/, "");
callback(fuzzyGrep(term, emojiSearchTerms));
},
template([string, original]) {
return `<span class="emoji">${emojiMap[original]}</span> ${string}`;
template([string, original]: [string, string]) {
return `<span class="emoji">${String(emojiMap[original])}</span> ${string}`;
},
replace([, original]) {
return "$1" + emojiMap[original];
replace([, original]: [string, string]) {
return "$1" + String(emojiMap[original]);
},
index: 2,
};
const nicksStrategy = {
const nicksStrategy: StrategyProps = {
id: "nicks",
match: /(^|\s)(@([a-zA-Z_[\]\\^{}|`@][a-zA-Z0-9_[\]\\^{}|`-]*)?)$/,
search(term, callback) {
search(term: string, callback: (matches: string[] | string[][]) => void) {
term = term.slice(1);
if (term[0] === "@") {
// TODO: type
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
callback(completeNicks(term.slice(1), true).map((val) => ["@" + val[0], "@" + val[1]]));
} else {
callback(completeNicks(term, true));
}
},
template([string]) {
template([string]: [string, string]) {
return string;
},
replace([, original]) {
replace([, original]: [string, string]) {
return "$1" + replaceNick(original);
},
index: 2,
};
const chanStrategy = {
const chanStrategy: StrategyProps = {
id: "chans",
match: /(^|\s)((?:#|\+|&|![A-Z0-9]{5})(?:[^\s]+)?)$/,
search(term, callback) {
search(term: string, callback: (matches: string[][]) => void) {
callback(completeChans(term));
},
template([string]) {
template([string]: [string, string]) {
return string;
},
replace([, original]) {
replace([, original]: [string, string]) {
return "$1" + original;
},
index: 2,
};
const commandStrategy = {
const commandStrategy: StrategyProps = {
id: "commands",
match: /^\/(\w*)$/,
search(term, callback) {
search(term: string, callback: (matches: string[][]) => void) {
callback(completeCommands("/" + term));
},
template([string]) {
template([string]: [string, string]) {
return string;
},
replace([, original]) {
replace([, original]: [string, string]) {
return original;
},
index: 1,
};
const foregroundColorStrategy = {
const foregroundColorStrategy: StrategyProps = {
id: "foreground-colors",
match: /\x03(\d{0,2}|[A-Za-z ]{0,10})$/,
search(term, callback) {
search(term: string, callback: (matches: string[][]) => void) {
term = term.toLowerCase();
const matchingColorCodes = constants.colorCodeMap
@ -107,19 +107,19 @@ const foregroundColorStrategy = {
callback(matchingColorCodes);
},
template(value) {
template(value: string[]) {
return `<span class="irc-fg${parseInt(value[0], 10)}">${value[1]}</span>`;
},
replace(value) {
replace(value: string) {
return "\x03" + value[0];
},
index: 1,
};
const backgroundColorStrategy = {
const backgroundColorStrategy: StrategyProps = {
id: "background-colors",
match: /\x03(\d{2}),(\d{0,2}|[A-Za-z ]{0,10})$/,
search(term, callback, match) {
search(term: string, callback: (matchingColorCodes: string[][]) => void, match: string[]) {
term = term.toLowerCase();
const matchingColorCodes = constants.colorCodeMap
.filter((i) => fuzzy.test(term, i[0]) || fuzzy.test(term, i[1]))
@ -140,25 +140,25 @@ const backgroundColorStrategy = {
callback(matchingColorCodes);
},
template(value) {
template(value: string[]) {
return `<span class="irc-fg${parseInt(value[2], 10)} irc-bg irc-bg${parseInt(
value[0],
10
)}">${value[1]}</span>`;
},
replace(value) {
replace(value: string[]) {
return "\x03$1," + value[0];
},
index: 2,
};
function enableAutocomplete(input) {
function enableAutocomplete(input: HTMLTextAreaElement) {
let tabCount = 0;
let lastMatch = "";
let currentMatches = [];
let currentMatches: string[] | string[][] = [];
input.addEventListener("input", (e) => {
if (e.detail === "autocomplete") {
if ((e as CustomEvent).detail === "autocomplete") {
return;
}
@ -179,7 +179,7 @@ function enableAutocomplete(input) {
const text = input.value;
if (tabCount === 0) {
lastMatch = text.substring(0, input.selectionStart).split(/\s/).pop();
lastMatch = text.substring(0, input.selectionStart).split(/\s/).pop() || "";
if (lastMatch.length === 0) {
return;
@ -194,12 +194,14 @@ function enableAutocomplete(input) {
const position = input.selectionStart - lastMatch.length;
const newMatch = replaceNick(
currentMatches[tabCount % currentMatches.length],
// TODO: type this properly
String(currentMatches[tabCount % currentMatches.length]),
position
);
const remainder = text.substr(input.selectionStart);
const remainder = text.substring(input.selectionStart);
input.value = text.substr(0, position) + newMatch + remainder;
input.selectionStart -= remainder.length;
input.selectionEnd = input.selectionStart;
@ -252,14 +254,14 @@ function enableAutocomplete(input) {
};
}
function replaceNick(original, position = 1) {
function replaceNick(original: string, position = 1) {
// If no postfix specified, return autocompleted nick as-is
if (!store.state.settings.nickPostfix) {
return original;
}
// If there is whitespace in the input already, append space to nick
if (position > 0 && /\s/.test(store.state.activeChannel.channel.pendingMessage)) {
if (position > 0 && /\s/.test(store.state.activeChannel?.channel.pendingMessage || "")) {
return original + " ";
}
@ -267,7 +269,7 @@ function replaceNick(original, position = 1) {
return original + store.state.settings.nickPostfix;
}
function fuzzyGrep(term, array) {
function fuzzyGrep<T>(term: string, array: Array<T>) {
const results = fuzzy.filter(term, array, {
pre: "<b>",
post: "</b>",
@ -276,6 +278,10 @@ function fuzzyGrep(term, array) {
}
function rawNicks() {
if (!store.state.activeChannel) {
return [];
}
if (store.state.activeChannel.channel.users.length > 0) {
const users = store.state.activeChannel.channel.users.slice();
@ -294,7 +300,7 @@ function rawNicks() {
return [me];
}
function completeNicks(word, isFuzzy) {
function completeNicks(word: string, isFuzzy: boolean) {
const users = rawNicks();
word = word.toLowerCase();
@ -315,18 +321,20 @@ function getCommands() {
return cmds;
}
function completeCommands(word) {
function completeCommands(word: string) {
const commands = getCommands();
return fuzzyGrep(word, commands);
}
function completeChans(word) {
const words = [];
function completeChans(word: string) {
const words: string[] = [];
for (const channel of store.state.activeChannel.network.channels) {
// Push all channels that start with the same CHANTYPE
if (channel.type === "channel" && channel.name[0] === word[0]) {
words.push(channel.name);
if (store.state.activeChannel) {
for (const channel of store.state.activeChannel.network.channels) {
// Push all channels that start with the same CHANTYPE
if (channel.type === "channel" && channel.name[0] === word[0]) {
words.push(channel.name);
}
}
}

View File

@ -1,13 +1,16 @@
"use strict";
export default function (chat) {
export default function (chat: HTMLDivElement) {
// Disable in Firefox as it already copies flex text correctly
// @ts-expect-error Property 'InstallTrigger' does not exist on type 'Window & typeof globalThis'.ts(2339)
if (typeof window.InstallTrigger !== "undefined") {
return;
}
const selection = window.getSelection();
if (!selection) {
return;
}
// If selection does not span multiple elements, do nothing
if (selection.anchorNode === selection.focusNode) {
return;

View File

@ -1,10 +1,12 @@
"use strict";
import socket from "../socket";
import store from "../store";
import {store} from "../store";
function input() {
const messageIds = [];
if (!store.state.activeChannel) {
return;
}
const messageIds: number[] = [];
for (const message of store.state.activeChannel.channel.messages) {
let toggled = false;
@ -24,7 +26,7 @@ function input() {
// Tell the server we're toggling so it remembers at page reload
if (!document.body.classList.contains("public") && messageIds.length > 0) {
socket.emit("msg:preview:toggle", {
target: store.state.activeChannel.channel.id,
target: store.state.activeChannel?.channel.id,
messageIds: messageIds,
shown: false,
});

View File

@ -1,10 +1,12 @@
"use strict";
import socket from "../socket";
import store from "../store";
import {store} from "../store";
function input() {
const messageIds = [];
if (!store.state.activeChannel) {
return;
}
const messageIds: number[] = [];
for (const message of store.state.activeChannel.channel.messages) {
let toggled = false;
@ -24,7 +26,7 @@ function input() {
// Tell the server we're toggling so it remembers at page reload
if (!document.body.classList.contains("public") && messageIds.length > 0) {
socket.emit("msg:preview:toggle", {
target: store.state.activeChannel.channel.id,
target: store.state.activeChannel?.channel.id,
messageIds: messageIds,
shown: true,
});

View File

@ -1,14 +1,12 @@
"use strict";
// Taken from views/index.js
// This creates a version of `require()` in the context of the current
// directory, so we iterate over its content, which is a map statically built by
// Webpack.
// Second argument says it's recursive, third makes sure we only load javascript.
const commands = require.context("./", true, /\.js$/);
const commands = require.context("./", true, /\.ts$/);
export default commands.keys().reduce((acc, path) => {
export default commands.keys().reduce<Record<string, unknown>>((acc, path) => {
const command = path.substring(2, path.length - 3);
if (command === "index") {

View File

@ -1,15 +1,13 @@
"use strict";
import socket from "../socket";
import store from "../store";
import {store} from "../store";
import {switchToChannel} from "../router";
function input(args) {
function input(args: string[]) {
if (args.length > 0) {
let channels = args[0];
if (channels.length > 0) {
const chanTypes = store.state.activeChannel.network.serverOptions.CHANTYPES;
const chanTypes = store.state.activeChannel?.network.serverOptions.CHANTYPES;
const channelList = args[0].split(",");
if (chanTypes && chanTypes.length > 0) {
@ -27,15 +25,17 @@ function input(args) {
if (chan) {
switchToChannel(chan);
} else {
socket.emit("input", {
text: `/join ${channels} ${args.length > 1 ? args[1] : ""}`,
target: store.state.activeChannel.channel.id,
});
if (store.state.activeChannel) {
socket.emit("input", {
text: `/join ${channels} ${args.length > 1 ? args[1] : ""}`,
target: store.state.activeChannel.channel.id,
});
}
return true;
}
}
} else if (store.state.activeChannel.channel.type === "channel") {
} else if (store.state.activeChannel?.channel.type === "channel") {
// If `/join` command is used without any arguments, re-join current channel
socket.emit("input", {
target: store.state.activeChannel.channel.id,

View File

@ -1,24 +0,0 @@
"use strict";
import store from "../store";
import {router} from "../router";
function input(args) {
if (!store.state.settings.searchEnabled) {
return false;
}
router.push({
name: "SearchResults",
params: {
id: store.state.activeChannel.channel.id,
},
query: {
q: args.join(" "),
},
});
return true;
}
export default {input};

View File

@ -0,0 +1,27 @@
import {store} from "../store";
import {router} from "../router";
function input(args: string[]) {
if (!store.state.settings.searchEnabled) {
return false;
}
router
.push({
name: "SearchResults",
params: {
id: store.state.activeChannel?.channel.id,
},
query: {
q: args.join(" "),
},
})
.catch((e: Error) => {
// eslint-disable-next-line no-console
console.error(`Failed to push SearchResults route: ${e.message}`);
});
return true;
}
export default {input};

View File

@ -1,5 +1,3 @@
"use strict";
const colorCodeMap = [
["00", "White"],
["01", "Black"],
@ -28,10 +26,9 @@ const timeFormats = {
msg12hWithSeconds: "hh:mm:ss A",
};
// This file is required by server, can't use es6 export
module.exports = {
export default {
colorCodeMap,
commands: [],
commands: [] as string[],
condensedTypes,
timeFormats,
// Same value as media query in CSS that forces sidebars to become overlays

View File

@ -7,7 +7,7 @@ class EventBus {
* @param {String} type Type of event to listen for.
* @param {Function} handler Function to call in response to given event.
*/
on(type, handler) {
on(type: string, handler: (...evt: any[]) => void) {
if (events.has(type)) {
events.get(type).push(handler);
} else {
@ -21,11 +21,11 @@ class EventBus {
* @param {String} type Type of event to unregister `handler` from.
* @param {Function} handler Handler function to remove.
*/
off(type, handler) {
off(type: string, handler: (...evt: any[]) => void) {
if (events.has(type)) {
events.set(
type,
events.get(type).filter((item) => item !== handler)
events.get(type).filter((item: (...evt: any[]) => void) => item !== handler)
);
}
}
@ -36,12 +36,12 @@ class EventBus {
* @param {String} type The event type to invoke.
* @param {Any} [evt] Any value (object is recommended and powerful), passed to each handler.
*/
emit(type, ...evt) {
emit(type: string, ...evt: any) {
if (events.has(type)) {
events
.get(type)
.slice()
.map((handler) => {
.map((handler: (...evts: any[]) => void) => {
handler(...evt);
});
}

View File

@ -1,5 +1,3 @@
"use strict";
import storage from "../localStorage";
export default (network, isCollapsed) => {

View File

@ -1,7 +1,5 @@
"use strict";
// Generates a string from "color-1" to "color-32" based on an input string
export default (str) => {
export default (str: string) => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
@ -13,5 +11,5 @@ export default (str) => {
due to A being ascii 65 (100 0001)
while a being ascii 97 (110 0001)
*/
return "color-" + (1 + (hash % 32));
return "color-" + (1 + (hash % 32)).toString();
};

View File

@ -1,9 +1,39 @@
"use strict";
import socket from "../socket";
import eventbus from "../eventbus";
import type {ClientChan, ClientNetwork, ClientUser} from "../types";
import {switchToChannel} from "../router";
import {TypedStore} from "../store";
import useCloseChannel from "../hooks/use-close-channel";
type BaseContextMenuItem = {
label: string;
type: string;
class: string;
};
type ContextMenuItemWithAction = BaseContextMenuItem & {
action: () => void;
};
type ContextMenuItemWithLink = BaseContextMenuItem & {
link?: string;
};
type ContextMenuDividerItem = {
type: "divider";
};
export type ContextMenuItem =
| ContextMenuItemWithAction
| ContextMenuItemWithLink
| ContextMenuDividerItem;
export function generateChannelContextMenu(
channel: ClientChan,
network: ClientNetwork
): ContextMenuItem[] {
const closeChannel = useCloseChannel(channel);
export function generateChannelContextMenu($root, channel, network) {
const typeMap = {
lobby: "network",
channel: "chan",
@ -18,7 +48,7 @@ export function generateChannelContextMenu($root, channel, network) {
special: "Close",
};
let items = [
let items: ContextMenuItem[] = [
{
label: channel.name,
type: "item",
@ -98,7 +128,7 @@ export function generateChannelContextMenu($root, channel, network) {
class: "edit",
action() {
channel.editTopic = true;
$root.switchToChannel(channel);
switchToChannel(channel);
},
});
items.push({
@ -122,7 +152,7 @@ export function generateChannelContextMenu($root, channel, network) {
type: "item",
class: "action-whois",
action() {
$root.switchToChannel(channel);
switchToChannel(channel);
socket.emit("input", {
target: channel.id,
text: "/whois " + channel.name,
@ -170,13 +200,13 @@ export function generateChannelContextMenu($root, channel, network) {
});
}
const humanFriendlyChanTypeMap = {
const humanFriendlyChanTypeMap: Record<string, string> = {
lobby: "network",
channel: "channel",
query: "conversation",
};
// We don't allow the muting of Chan.Type.SPECIAL channels
// We don't allow the muting of ChanType.SPECIAL channels
const mutableChanTypes = Object.keys(humanFriendlyChanTypeMap);
if (mutableChanTypes.includes(channel.type)) {
@ -201,23 +231,27 @@ export function generateChannelContextMenu($root, channel, network) {
type: "item",
class: "close",
action() {
$root.closeChannel(channel);
closeChannel();
},
});
return items;
}
export function generateInlineChannelContextMenu($root, chan, network) {
export function generateInlineChannelContextMenu(
store: TypedStore,
chan: string,
network: ClientNetwork
): ContextMenuItem[] {
const join = () => {
const channel = network.channels.find((c) => c.name === chan);
if (channel) {
$root.switchToChannel(channel);
switchToChannel(channel);
}
socket.emit("input", {
target: $root.$store.state.activeChannel.channel.id,
target: store.state.activeChannel.channel.id,
text: "/join " + chan,
});
};
@ -245,8 +279,13 @@ export function generateInlineChannelContextMenu($root, chan, network) {
];
}
export function generateUserContextMenu($root, channel, network, user) {
const currentChannelUser = channel
export function generateUserContextMenu(
store: TypedStore,
channel: ClientChan,
network: ClientNetwork,
user: Pick<ClientUser, "nick" | "modes">
): ContextMenuItem[] {
const currentChannelUser: ClientUser | Record<string, never> = channel
? channel.users.find((u) => u.nick === network.nick) || {}
: {};
@ -254,7 +293,7 @@ export function generateUserContextMenu($root, channel, network, user) {
const chan = network.channels.find((c) => c.name === user.nick);
if (chan) {
$root.switchToChannel(chan);
switchToChannel(chan);
}
socket.emit("input", {
@ -263,7 +302,7 @@ export function generateUserContextMenu($root, channel, network, user) {
});
};
const items = [
const items: ContextMenuItem[] = [
{
label: user.nick,
type: "item",
@ -295,10 +334,10 @@ export function generateUserContextMenu($root, channel, network, user) {
type: "item",
class: "action-query",
action() {
const chan = $root.$store.getters.findChannelOnCurrentNetwork(user.nick);
const chan = store.getters.findChannelOnCurrentNetwork(user.nick);
if (chan) {
$root.switchToChannel(chan);
switchToChannel(chan);
}
socket.emit("input", {
@ -325,13 +364,23 @@ export function generateUserContextMenu($root, channel, network, user) {
// Labels for the mode changes. For example .rev({mode: "a", symbol: "&"}) => 'Revoke admin (-a)'
const modeTextTemplate = {
revoke(m) {
revoke(m: {symbol: string; mode: string}) {
const name = modeCharToName[m.symbol];
if (typeof name !== "string") {
return "";
}
const res = name ? `Revoke ${name} (-${m.mode})` : `Mode -${m.mode}`;
return res;
},
give(m) {
give(m: {symbol: string; mode: string}) {
const name = modeCharToName[m.symbol];
if (typeof name !== "string") {
return "";
}
const res = name ? `Give ${name} (+${m.mode})` : `Mode +${m.mode}`;
return res;
},
@ -351,7 +400,7 @@ export function generateUserContextMenu($root, channel, network, user) {
*
* @return {boolean} whether p1 can perform an action on p2
*/
function compare(p1, p2) {
function compare(p1: string, p2: string): boolean {
// The modes ~ and @ can perform actions on their own mode. The others on modes below.
return "~@".indexOf(p1) > -1
? networkModeSymbols.indexOf(p1) <= networkModeSymbols.indexOf(p2)

View File

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

View File

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

View File

@ -1,8 +1,6 @@
"use strict";
const sizes = ["Bytes", "KiB", "MiB", "GiB", "TiB", "PiB"];
export default (size) => {
export default (size: number) => {
// Loosely inspired from https://stackoverflow.com/a/18650828/1935861
const i = size > 0 ? Math.floor(Math.log(size) / Math.log(1024)) : 0;
const fixedSize = parseFloat((size / Math.pow(1024, i)).toFixed(1));

View File

@ -1,8 +1,9 @@
"use strict";
// Return true if any section of "a" or "b" parts (defined by their start/end
import {Part} from "./merge";
// markers) intersect each other, false otherwise.
function anyIntersection(a, b) {
function anyIntersection(a: Part, b: Part) {
return (
(a.start <= b.start && b.start < a.end) ||
(a.start < b.end && b.end <= a.end) ||

View File

@ -1,6 +1,4 @@
"use strict";
const matchFormatting =
/\x02|\x1D|\x1F|\x16|\x0F|\x11|\x1E|\x03(?:[0-9]{1,2}(?:,[0-9]{1,2})?)?|\x04(?:[0-9a-f]{6}(?:,[0-9a-f]{6})?)?/gi;
module.exports = (message) => message.replace(matchFormatting, "").trim();
export default (message: string) => message.replace(matchFormatting, "").trim();

Some files were not shown because too many files have changed in this diff Show More