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/ public/
coverage/ coverage/
dist/

View file

@ -1,14 +1,18 @@
module.exports = { // @ts-check
root: true, const {defineConfig} = require("eslint-define-config");
const projects = defineConfig({
parserOptions: { parserOptions: {
ecmaVersion: 2022, project: [
}, "./tsconfig.json",
env: { "./client/tsconfig.json",
es6: true, "./server/tsconfig.json",
browser: true, "./test/tsconfig.json",
mocha: true, ],
node: true,
}, },
}).parserOptions.project;
const baseRules = defineConfig({
rules: { rules: {
"block-scoped-var": "error", "block-scoped-var": "error",
curly: ["error", "all"], curly: ["error", "all"],
@ -23,7 +27,6 @@ module.exports = {
"no-else-return": "error", "no-else-return": "error",
"no-implicit-globals": "error", "no-implicit-globals": "error",
"no-restricted-globals": ["error", "event", "fdescribe"], "no-restricted-globals": ["error", "event", "fdescribe"],
"no-shadow": "error",
"no-template-curly-in-string": "error", "no-template-curly-in-string": "error",
"no-unsafe-negation": "error", "no-unsafe-negation": "error",
"no-useless-computed-key": "error", "no-useless-computed-key": "error",
@ -62,18 +65,127 @@ module.exports = {
"spaced-comment": ["error", "always"], "spaced-comment": ["error", "always"],
strict: "off", strict: "off",
yoda: "error", 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": [ "vue/component-tags-order": [
"error", "error",
{ {
order: ["template", "style", "script"], order: ["template", "style", "script"],
}, },
], ],
"vue/multi-word-component-names": "off",
"vue/no-mutating-props": "off", "vue/no-mutating-props": "off",
"vue/no-v-html": "off", "vue/no-v-html": "off",
"vue/require-default-prop": "off", "vue/require-default-prop": "off",
"vue/v-slot-style": ["error", "longform"], "vue/v-slot-style": ["error", "longform"],
"vue/multi-word-component-names": "off",
}, },
plugins: ["vue"], }).rules;
extends: ["eslint:recommended", "plugin:vue/recommended", "prettier"],
}; 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/ coverage/
public/ public/
dist/

View file

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

View file

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

View file

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

View file

@ -1,10 +1,10 @@
{ {
"editor.formatOnSave": true, "editor.formatOnSave": true,
"vetur.format.enable": false,
"prettier.useEditorConfig": true, "prettier.useEditorConfig": true,
"prettier.requireConfig": true, "prettier.requireConfig": true,
"prettier.disableLanguages": [], "prettier.disableLanguages": [],
"prettier.packageManager": "yarn",
"eslint.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 ## Installation and usage
The Lounge requires latest [Node.js](https://nodejs.org/) LTS version or more recent. 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. If you want to install with npm, `--unsafe-perm` is required for a correct install.
### Running stable releases ### 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) - 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 test` to execute linters and the test suite
- Run `yarn format:prettier` if linting fails - 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 - `yarn dev` can be used to start The Lounge with hot module reloading

View file

@ -1,3 +1,4 @@
module.exports = { 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> <template>
<div id="viewport" :class="viewportClasses" role="tablist"> <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 <div
id="sidebar-overlay" id="sidebar-overlay"
ref="overlay" ref="overlay"
aria-hidden="true" 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 /> <Mentions />
<ImageViewer ref="imageViewer" /> <ImageViewer ref="imageViewer" />
<ContextMenu ref="contextMenu" /> <ContextMenu ref="contextMenu" />
@ -16,10 +16,10 @@
</div> </div>
</template> </template>
<script> <script lang="ts">
const constants = require("../js/constants"); import constants from "../js/constants";
import eventbus from "../js/eventbus"; import eventbus from "../js/eventbus";
import Mousetrap from "mousetrap"; import Mousetrap, {ExtendedKeyboardEvent} from "mousetrap";
import throttle from "lodash/throttle"; import throttle from "lodash/throttle";
import storage from "../js/localStorage"; import storage from "../js/localStorage";
import isIgnoredKeybind from "../js/helpers/isIgnoredKeybind"; import isIgnoredKeybind from "../js/helpers/isIgnoredKeybind";
@ -29,8 +29,29 @@ import ImageViewer from "./ImageViewer.vue";
import ContextMenu from "./ContextMenu.vue"; import ContextMenu from "./ContextMenu.vue";
import ConfirmDialog from "./ConfirmDialog.vue"; import ConfirmDialog from "./ConfirmDialog.vue";
import Mentions from "./Mentions.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", name: "App",
components: { components: {
Sidebar, Sidebar,
@ -39,93 +60,78 @@ export default {
ConfirmDialog, ConfirmDialog,
Mentions, Mentions,
}, },
computed: { setup() {
viewportClasses() { 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 { return {
notified: this.$store.getters.highlightCount > 0, notified: store.getters.highlightCount > 0,
"menu-open": this.$store.state.appLoaded && this.$store.state.sidebarOpen, "menu-open": store.state.appLoaded && store.state.sidebarOpen,
"menu-dragging": this.$store.state.sidebarDragging, "menu-dragging": store.state.sidebarDragging,
"userlist-open": this.$store.state.userlistOpen, "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 const debouncedResize = ref<DebouncedFunc<() => void>>();
this.debouncedResize = throttle(() => { const dayChangeTimeout = ref<any>();
eventbus.emit("resize");
}, 100);
window.addEventListener("resize", this.debouncedResize, {passive: true}); const escapeKey = () => {
eventbus.emit("escapekey");
// 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());
}; };
this.dayChangeTimeout = setTimeout(emitDayChange, this.msUntilNextDay()); const toggleSidebar = (e: ExtendedKeyboardEvent) => {
},
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) {
if (isIgnoredKeybind(e)) { if (isIgnoredKeybind(e)) {
return true; return true;
} }
this.$store.commit("toggleSidebar"); store.commit("toggleSidebar");
return false; return false;
}, };
toggleUserList(e) {
const toggleUserList = (e: ExtendedKeyboardEvent) => {
if (isIgnoredKeybind(e)) { if (isIgnoredKeybind(e)) {
return true; return true;
} }
this.$store.commit("toggleUserlist"); store.commit("toggleUserlist");
return false; return false;
}, };
toggleMentions() {
if (this.$store.state.networks.length !== 0) { const toggleMentions = () => {
if (store.state.networks.length !== 0) {
eventbus.emit("mentions:toggle"); eventbus.emit("mentions:toggle");
} }
}, };
msUntilNextDay() {
const msUntilNextDay = () => {
// Compute how many milliseconds are remaining until the next day starts // Compute how many milliseconds are remaining until the next day starts
const today = new Date(); 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; return tommorow - today.getTime();
}, };
prepareOpenStates() {
const prepareOpenStates = () => {
const viewportWidth = window.innerWidth; const viewportWidth = window.innerWidth;
let isUserlistOpen = storage.get("thelounge.state.userlist"); let isUserlistOpen = storage.get("thelounge.state.userlist");
if (viewportWidth > constants.mobileViewportPixels) { if (viewportWidth > constants.mobileViewportPixels) {
this.$store.commit( store.commit("sidebarOpen", storage.get("thelounge.state.sidebar") !== "false");
"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 // 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"; 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> </script>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,13 +1,13 @@
<template> <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 v-if="data !== null" id="confirm-dialog">
<div class="confirm-text"> <div class="confirm-text">
<div class="confirm-text-title">{{ data.title }}</div> <div class="confirm-text-title">{{ data?.title }}</div>
<p>{{ data.text }}</p> <p>{{ data?.text }}</p>
</div> </div>
<div class="confirm-buttons"> <div class="confirm-buttons">
<button class="btn btn-cancel" @click="close(false)">Cancel</button> <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> </div>
</div> </div>
@ -50,37 +50,53 @@
} }
</style> </style>
<script> <script lang="ts">
import eventbus from "../js/eventbus"; 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", 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 { return {
data: null, data,
callback: null, 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> </script>

View file

@ -14,14 +14,17 @@
id="context-menu" id="context-menu"
ref="contextMenu" ref="contextMenu"
role="menu" role="menu"
:style="style" :style="{
top: style.top + 'px',
left: style.left + 'px',
}"
tabindex="-1" tabindex="-1"
@mouseleave="activeItem = -1" @mouseleave="activeItem = -1"
@keydown.enter.prevent="clickActiveItem" @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 <li
:key="item.name"
:class="[ :class="[
'context-menu-' + item.type, 'context-menu-' + item.type,
item.class ? 'context-menu-' + item.class : null, item.class ? 'context-menu-' + item.class : null,
@ -38,164 +41,77 @@
</div> </div>
</template> </template>
<script> <script lang="ts">
import { import {
generateUserContextMenu, generateUserContextMenu,
generateChannelContextMenu, generateChannelContextMenu,
generateInlineChannelContextMenu, generateInlineChannelContextMenu,
} from "../js/helpers/contextMenu.js"; ContextMenuItem,
} from "../js/helpers/contextMenu";
import eventbus from "../js/eventbus"; 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", name: "ContextMenu",
props: { props: {
message: Object, message: {
required: false,
type: Object as PropType<ClientMessage>,
},
}, },
data() { setup() {
return { const store = useStore();
isOpen: false, const router = useRouter();
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);
this.close(); const isOpen = ref(false);
}, const passthrough = ref(false);
methods: {
enablePointerEvents() {
this.passthrough = false;
document.body.removeEventListener("pointerup", this.enablePointerEvents, {
passive: true,
});
},
openChannelContextMenu(data) {
if (data.event.type === "contextmenu") {
// Pass through all pointer events to allow the network list's
// dragging events to continue triggering.
this.passthrough = true;
document.body.addEventListener("pointerup", this.enablePointerEvents, {
passive: true,
});
}
const items = generateChannelContextMenu(this.$root, data.channel, data.network); const contextMenu = ref<HTMLUListElement | null>();
this.open(data.event, items); const previousActiveElement = ref<HTMLElement | null>();
}, const items = ref<ContextMenuItem[]>([]);
openInlineChannelContextMenu(data) { const activeItem = ref(-1);
const {network} = this.$store.state.activeChannel; const style = ref({
const items = generateInlineChannelContextMenu(this.$root, data.channel, network); top: 0,
this.open(data.event, items); left: 0,
}, });
openUserContextMenu(data) {
const {network, channel} = this.$store.state.activeChannel;
const items = generateUserContextMenu( const close = () => {
this.$root, if (!isOpen.value) {
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) {
return; return;
} }
this.isOpen = false; isOpen.value = false;
this.items = []; items.value = [];
if (this.previousActiveElement) { if (previousActiveElement.value) {
this.previousActiveElement.focus(); previousActiveElement.value.focus();
this.previousActiveElement = null; previousActiveElement.value = null;
} }
}, };
hoverItem(id) {
this.activeItem = id;
},
clickItem(item) {
this.close();
if (item.action) { const enablePointerEvents = () => {
item.action(); passthrough.value = false;
} else if (item.link) { document.body.removeEventListener("pointerup", enablePointerEvents);
this.$router.push(item.link); };
}
},
clickActiveItem() {
if (this.items[this.activeItem]) {
this.clickItem(this.items[this.activeItem]);
}
},
navigateMenu(direction) {
let currentIndex = this.activeItem;
currentIndex += direction; const containerClick = (event: MouseEvent) => {
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) {
if (event.currentTarget === event.target) { if (event.currentTarget === event.target) {
this.close(); close();
} }
}, };
positionContextMenu(event) {
const element = event.target; const positionContextMenu = (event: MouseEvent) => {
const menuWidth = this.$refs.contextMenu.offsetWidth; const element = event.target as HTMLElement;
const menuHeight = this.$refs.contextMenu.offsetHeight;
if (!contextMenu.value) {
return;
}
const menuWidth = contextMenu.value?.offsetWidth;
const menuHeight = contextMenu.value?.offsetHeight;
if (element && element.classList.contains("menu")) { if (element && element.classList.contains("menu")) {
return { return {
@ -215,7 +131,154 @@ export default {
} }
return offset; 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> </script>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,7 +3,7 @@
<div v-show="channel.moreHistoryAvailable" class="show-more"> <div v-show="channel.moreHistoryAvailable" class="show-more">
<button <button
ref="loadMoreButton" ref="loadMoreButton"
:disabled="channel.historyLoading || !$store.state.isConnected" :disabled="channel.historyLoading || !store.state.isConnected"
class="btn" class="btn"
@click="onShowMoreClick" @click="onShowMoreClick"
> >
@ -22,11 +22,11 @@
<DateMarker <DateMarker
v-if="shouldDisplayDateMarker(message, id)" v-if="shouldDisplayDateMarker(message, id)"
:key="message.id + '-date'" :key="message.id + '-date'"
:message="message" :message="message as any"
:focused="message.id == focused" :focused="message.id === focused"
/> />
<div <div
v-if="shouldDisplayUnreadMarker(message.id)" v-if="shouldDisplayUnreadMarker(Number(message.id))"
:key="message.id + '-unread'" :key="message.id + '-unread'"
class="unread-marker" class="unread-marker"
> >
@ -39,7 +39,7 @@
:network="network" :network="network"
:keep-scroll-position="keepScrollPosition" :keep-scroll-position="keepScrollPosition"
:messages="message.messages" :messages="message.messages"
:focused="message.id == focused" :focused="message.id === focused"
/> />
<Message <Message
v-else v-else
@ -49,7 +49,7 @@
:message="message" :message="message"
:keep-scroll-position="keepScrollPosition" :keep-scroll-position="keepScrollPosition"
:is-previous-source="isPreviousSource(message, id)" :is-previous-source="isPreviousSource(message, id)"
:focused="message.id == focused" :focused="message.id === focused"
@toggle-link-preview="onLinkPreviewToggle" @toggle-link-preview="onLinkPreviewToggle"
/> />
</template> </template>
@ -57,18 +57,41 @@
</div> </div>
</template> </template>
<script> <script lang="ts">
const constants = require("../js/constants"); import constants from "../js/constants";
import eventbus from "../js/eventbus"; import eventbus from "../js/eventbus";
import clipboard from "../js/clipboard"; import clipboard from "../js/clipboard";
import socket from "../js/socket"; import socket from "../js/socket";
import Message from "./Message.vue"; import Message from "./Message.vue";
import MessageCondensed from "./MessageCondensed.vue"; import MessageCondensed from "./MessageCondensed.vue";
import DateMarker from "./DateMarker.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; let unreadMarkerShown = false;
export default { export default defineComponent({
name: "MessageList", name: "MessageList",
components: { components: {
Message, Message,
@ -76,32 +99,105 @@ export default {
DateMarker, DateMarker,
}, },
props: { props: {
network: Object, network: {type: Object as PropType<ClientNetwork>, required: true},
channel: Object, channel: {type: Object as PropType<ClientChan>, required: true},
focused: String, focused: Number,
}, },
computed: { setup(props, {emit}) {
condensedMessages() { const store = useStore();
if (this.channel.type !== "channel") {
return this.channel.messages; 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 actions are hidden, just return a message list with them excluded
if (this.$store.state.settings.statusMessages === "hidden") { if (store.state.settings.statusMessages === "hidden") {
return this.channel.messages.filter( return props.channel.messages.filter(
(message) => !constants.condensedTypes.has(message.type) (message) => !constants.condensedTypes.has(message.type)
); );
} }
// If actions are not condensed, just return raw message list // If actions are not condensed, just return raw message list
if (this.$store.state.settings.statusMessages !== "condensed") { if (store.state.settings.statusMessages !== "condensed") {
return this.channel.messages; return props.channel.messages;
} }
const condensed = []; let lastCondensedContainer: CondensedMessageContainer | null = null;
let lastCondensedContainer = 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, // 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 // then just append the message to container and be done with it
if ( if (
@ -116,7 +212,7 @@ export default {
continue; continue;
} }
if (lastCondensedContainer === null) { if (!lastCondensedContainer) {
lastCondensedContainer = { lastCondensedContainer = {
time: message.time, time: message.time,
type: "condensed", type: "condensed",
@ -126,14 +222,14 @@ export default {
condensed.push(lastCondensedContainer); condensed.push(lastCondensedContainer);
} }
lastCondensedContainer.messages.push(message); lastCondensedContainer!.messages.push(message);
// Set id of the condensed container to last message id, // Set id of the condensed container to last message id,
// which is required for the unread marker to work correctly // 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 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; lastCondensedContainer = null;
} }
} }
@ -147,70 +243,13 @@ export default {
return message; 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); const shouldDisplayDateMarker = (
message: Msg | ClientMessage | CondensedMessageContainer,
this.$nextTick(() => { id: number
if (this.historyObserver) { ) => {
this.historyObserver.observe(this.$refs.loadMoreButton); const previousMessage = condensedMessages.value[id - 1];
}
});
},
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];
if (!previousMessage) { if (!previousMessage) {
return true; return true;
@ -224,135 +263,180 @@ export default {
oldDate.getMonth() !== newDate.getMonth() || oldDate.getMonth() !== newDate.getMonth() ||
oldDate.getFullYear() !== newDate.getFullYear() oldDate.getFullYear() !== newDate.getFullYear()
); );
}, };
shouldDisplayUnreadMarker(id) {
if (!unreadMarkerShown && id > this.channel.firstUnread) { const shouldDisplayUnreadMarker = (id: number) => {
if (!unreadMarkerShown && id > props.channel.firstUnread) {
unreadMarkerShown = true; unreadMarkerShown = true;
return true; return true;
} }
return false; return false;
}, };
isPreviousSource(currentMessage, id) {
const previousMessage = this.condensedMessages[id - 1]; const isPreviousSource = (currentMessage: ClientMessage | Msg, id: number) => {
return ( const previousMessage = condensedMessages[id - 1];
return !!(
previousMessage && previousMessage &&
currentMessage.type === "message" && currentMessage.type === "message" &&
previousMessage.type === "message" && previousMessage.type === "message" &&
previousMessage.from && previousMessage.from &&
currentMessage.from.nick === previousMessage.from.nick currentMessage.from.nick === previousMessage.from.nick
); );
}, };
onCopy() {
clipboard(this.$el); const onCopy = () => {
}, if (chat.value) {
onLinkPreviewToggle(preview, message) { clipboard(chat.value);
this.keepScrollPosition(); }
};
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 // Tell the server we're toggling so it remembers at page reload
socket.emit("msg:preview:toggle", { socket.emit("msg:preview:toggle", {
target: this.channel.id, target: props.channel.id,
msgId: message.id, msgId: message.id,
link: preview.link, link: preview.link,
shown: preview.shown, shown: preview.shown,
}); });
}, };
onShowMoreClick() {
if (!this.$store.state.isConnected) {
return;
}
let lastMessage = -1; const handleScroll = () => {
// 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() {
// Setting scrollTop also triggers scroll event // Setting scrollTop also triggers scroll event
// We don't want to perform calculations for that // We don't want to perform calculations for that
if (this.skipNextScrollEvent) { if (skipNextScrollEvent.value) {
this.skipNextScrollEvent = false; skipNextScrollEvent.value = false;
return; return;
} }
const el = this.$refs.chat; const el = chat.value;
if (!el) { if (!el) {
return; return;
} }
this.channel.scrolledToBottom = el.scrollHeight - el.scrollTop - el.offsetHeight <= 30; props.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;
const el = this.$refs.chat; const handleResize = () => {
el.scrollTop = el.scrollHeight; // 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> </script>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,55 +4,67 @@
</span> </span>
</template> </template>
<script> <script lang="ts">
import ParsedMessage from "../ParsedMessage.vue"; import ParsedMessage from "../ParsedMessage.vue";
import {computed, defineComponent, PropType} from "vue";
import {ClientNetwork, ClientMessage} from "../../js/types";
export default { export default defineComponent({
name: "MessageTypeError", name: "MessageTypeError",
components: { components: {
ParsedMessage, ParsedMessage,
}, },
props: { props: {
network: Object, network: {
message: Object, type: Object as PropType<ClientNetwork>,
required: true,
},
message: {
type: Object as PropType<ClientMessage>,
required: true,
},
}, },
computed: { setup(props) {
errorMessage() { const errorMessage = computed(() => {
switch (this.message.error) { switch (props.message.error) {
case "bad_channel_key": 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": 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": 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": 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": case "chanop_privs_needed":
return "Cannot perform action: You're not a channel operator."; return "Cannot perform action: You're not a channel operator.";
case "invite_only_channel": 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": 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": case "not_on_channel":
return "Cannot perform action: You're not on the channel."; return "Cannot perform action: You're not on the channel.";
case "password_mismatch": case "password_mismatch":
return "Password mismatch."; return "Password mismatch.";
case "too_many_channels": 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": case "unknown_command":
return `Unknown command: ${this.message.command}`; return `Unknown command: ${props.message.command}`;
case "user_not_in_channel": 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": 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: default:
if (this.message.reason) { if (props.message.reason) {
return `${this.message.reason} (${this.message.error})`; return `${props.message.reason} (${props.message.error})`;
} }
return this.message.error; return props.message.error;
} }
}, });
return {
errorMessage,
};
}, },
}; });
</script> </script>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
<template> <template>
<div <div
v-if="$store.state.networks.length === 0" v-if="store.state.networks.length === 0"
class="empty" class="empty"
role="navigation" role="navigation"
aria-label="Network and Channel list" aria-label="Network and Channel list"
@ -55,7 +55,7 @@
</div> </div>
<Draggable <Draggable
v-else v-else
:list="$store.state.networks" :list="store.state.networks"
:delay="LONG_TOUCH_DURATION" :delay="LONG_TOUCH_DURATION"
:delay-on-touch-only="true" :delay-on-touch-only="true"
:touch-start-threshold="10" :touch-start-threshold="10"
@ -65,71 +65,79 @@
drag-class="ui-sortable-dragging" drag-class="ui-sortable-dragging"
group="networks" group="networks"
class="networks" class="networks"
item-key="uuid"
@change="onNetworkSort" @change="onNetworkSort"
@choose="onDraggableChoose" @choose="onDraggableChoose"
@unchoose="onDraggableUnchoose" @unchoose="onDraggableUnchoose"
> >
<div <template v-slot:item="{element: network}">
v-for="network in $store.state.networks" <div
:id="'network-' + network.uuid" :id="'network-' + network.uuid"
:key="network.uuid" :key="network.uuid"
:class="{ :class="{
collapsed: network.isCollapsed, collapsed: network.isCollapsed,
'not-connected': !network.status.connected, 'not-connected': !network.status.connected,
'not-secure': !network.status.secure, 'not-secure': !network.status.secure,
}" }"
class="network" class="network"
role="region" role="region"
aria-live="polite" aria-live="polite"
@touchstart="onDraggableTouchStart" @touchstart="onDraggableTouchStart"
@touchmove="onDraggableTouchMove" @touchmove="onDraggableTouchMove"
@touchend="onDraggableTouchEnd" @touchend="onDraggableTouchEnd"
@touchcancel="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-for="(channel, index) in network.channels"> <NetworkLobby
<Channel :network="network"
v-if="index > 0" :is-join-channel-shown="network.isJoinChannelShown"
:key="channel.id" :active="
:channel="channel" store.state.activeChannel &&
:network="network" network.channels[0] === store.state.activeChannel.channel
:active=" "
$store.state.activeChannel && @toggle-join-channel="
channel === $store.state.activeChannel.channel network.isJoinChannelShown = !network.isJoinChannelShown
" "
/> />
</template> <JoinChannel
</Draggable> v-if="network.isJoinChannelShown"
</div> :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> </Draggable>
</div> </div>
</template> </template>
@ -195,21 +203,27 @@
} }
</style> </style>
<script> <script lang="ts">
import {computed, watch, defineComponent, nextTick, onBeforeUnmount, onMounted, ref} from "vue";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
import Draggable from "vuedraggable"; import Draggable from "./Draggable.vue";
import {filter as fuzzyFilter} from "fuzzy"; import {filter as fuzzyFilter} from "fuzzy";
import NetworkLobby from "./NetworkLobby.vue"; import NetworkLobby from "./NetworkLobby.vue";
import Channel from "./Channel.vue"; import Channel from "./Channel.vue";
import JoinChannel from "./JoinChannel.vue"; import JoinChannel from "./JoinChannel.vue";
import socket from "../js/socket"; import socket from "../js/socket";
import collapseNetwork from "../js/helpers/collapseNetwork"; import collapseNetworkHelper from "../js/helpers/collapseNetwork";
import isIgnoredKeybind from "../js/helpers/isIgnoredKeybind"; import isIgnoredKeybind from "../js/helpers/isIgnoredKeybind";
import distance from "../js/helpers/distance"; import distance from "../js/helpers/distance";
import eventbus from "../js/eventbus"; 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", name: "NetworkList",
components: { components: {
JoinChannel, JoinChannel,
@ -217,120 +231,137 @@ export default {
Channel, Channel,
Draggable, Draggable,
}, },
data() { setup() {
return { const store = useStore();
searchText: "", const searchText = ref("");
activeSearchItem: null, const activeSearchItem = ref<ClientChan | null>();
}; // Number of milliseconds a touch has to last to be considered long
}, const LONG_TOUCH_DURATION = 500;
computed: {
items() {
const items = [];
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) { for (const channel of network.channels) {
if ( if (
this.$store.state.activeChannel && store.state.activeChannel &&
channel === this.$store.state.activeChannel.channel channel === store.state.activeChannel.channel
) { ) {
continue; continue;
} }
items.push({network, channel}); newItems.push({network, channel});
} }
} }
return items; return newItems;
}, });
results() {
const results = fuzzyFilter(this.searchText, this.items, { const results = computed(() => {
const newResults = fuzzyFilter(searchText.value, items.value, {
extract: (item) => item.channel.name, extract: (item) => item.channel.name,
}).map((item) => item.original); }).map((item) => item.original);
return results; return newResults;
}, });
},
watch: { const collapseNetwork = (event: Mousetrap.ExtendedKeyboardEvent) => {
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) {
if (isIgnoredKeybind(event)) { if (isIgnoredKeybind(event)) {
return true; return true;
} }
if (this.$store.state.activeChannel) { if (store.state.activeChannel) {
collapseNetwork(this.$store.state.activeChannel.network, false); collapseNetworkHelper(store.state.activeChannel.network, true);
} }
return false; return false;
}, };
collapseNetwork(event) {
const expandNetwork = (event: Mousetrap.ExtendedKeyboardEvent) => {
if (isIgnoredKeybind(event)) { if (isIgnoredKeybind(event)) {
return true; return true;
} }
if (this.$store.state.activeChannel) { if (store.state.activeChannel) {
collapseNetwork(this.$store.state.activeChannel.network, true); collapseNetworkHelper(store.state.activeChannel.network, false);
} }
return 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; return;
} }
moveItemInArray(store.state.networks, oldIndex, newIndex);
socket.emit("sort", { socket.emit("sort", {
type: "networks", 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; 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; return;
} }
const id = parseInt(unparsedId);
const netChan = store.getters.findChannel(id);
if (!netChan) {
return;
}
moveItemInArray(netChan.network.channels, oldIndex, newIndex);
socket.emit("sort", { socket.emit("sort", {
type: "channels", type: "channels",
target: channel.network.uuid, target: netChan.network.uuid,
order: channel.network.channels.map((c) => c.id), 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 // This is the same way Sortable.js detects a touch event. See
// SortableJS/Sortable@daaefeda:/src/Sortable.js#L465 // SortableJS/Sortable@daaefeda:/src/Sortable.js#L465
return (
return !!(
(event.touches && event.touches[0]) || (event.touches && event.touches[0]) ||
(event.pointerType && event.pointerType === "touch") (event.pointerType && event.pointerType === "touch")
); );
}, };
onDraggableChoose(event) {
const onDraggableChoose = (event: any) => {
const original = event.originalEvent; const original = event.originalEvent;
if (this.isTouchEvent(original)) { if (isTouchEvent(original)) {
// onDrag is only triggered when the user actually moves the // onDrag is only triggered when the user actually moves the
// dragged object but onChoose is triggered as soon as the // dragged object but onChoose is triggered as soon as the
// item is eligible for dragging. This gives us an opportunity // 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"); event.item.classList.add("ui-sortable-dragging-touch-cue");
if (original instanceof TouchEvent && original.touches.length > 0) { 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) { } 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"); event.item.classList.remove("ui-sortable-dragging-touch-cue");
this.startDrag = null; startDrag.value = null;
}, };
onDraggableTouchStart(event) {
const onDraggableTouchStart = (event: TouchEvent) => {
if (event.touches.length === 1) { if (event.touches.length === 1) {
// This prevents an iOS long touch default behavior: selecting // This prevents an iOS long touch default behavior: selecting
// the nearest selectable text. // the nearest selectable text.
document.body.classList.add("force-no-select"); 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 touch = event.touches[0];
const currentPosition = [touch.clientX, touch.clientY]; 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. // Context menu is shown on Android after long touch.
// Dismiss it now that we're sure the user is dragging. // Dismiss it now that we're sure the user is dragging.
eventbus.emit("contextmenu:cancel"); eventbus.emit("contextmenu:cancel");
} }
} }
}, };
onDraggableTouchEnd(event) {
const onDraggableTouchEnd = (event: TouchEvent) => {
if (event.touches.length === 0) { if (event.touches.length === 0) {
document.body.classList.remove("force-no-select"); 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)) { if (isIgnoredKeybind(event)) {
return true; return true;
} }
if (this.$refs.searchInput === document.activeElement) { if (searchInput.value === document.activeElement) {
this.deactivateSearch(); deactivateSearch();
return false; return false;
} }
this.activateSearch(); activateSearch();
return false; return false;
}, };
activateSearch() {
if (this.$refs.searchInput === document.activeElement) {
return;
}
this.sidebarWasClosed = this.$store.state.sidebarOpen ? false : true; const setSearchText = (e: Event) => {
this.$store.commit("sidebarOpen", true); searchText.value = (e.target as HTMLInputElement).value;
this.$nextTick(() => { };
this.$refs.searchInput.focus();
});
},
deactivateSearch() {
this.activeSearchItem = null;
this.searchText = "";
this.$refs.searchInput.blur();
if (this.sidebarWasClosed) { const setActiveSearchItem = (channel?: ClientChan) => {
this.$store.commit("sidebarOpen", false); if (!results.value.length) {
}
},
setSearchText(e) {
this.searchText = e.target.value;
},
setActiveSearchItem(channel) {
if (!this.results.length) {
return; return;
} }
if (!channel) { if (!channel) {
channel = this.results[0].channel; channel = results.value[0].channel;
} }
this.activeSearchItem = channel; activeSearchItem.value = channel;
}, };
selectResult() {
if (!this.searchText || !this.results.length) { 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; return;
} }
this.$root.switchToChannel(this.activeSearchItem); if (activeSearchItem.value) {
this.deactivateSearch(); switchToChannel(activeSearchItem.value);
this.scrollToActive(); deactivateSearch();
}, scrollToActive();
navigateResults(event, direction) { }
};
const navigateResults = (event: Event, direction: number) => {
// Prevent propagation to stop global keybind handler from capturing pagedown/pageup // Prevent propagation to stop global keybind handler from capturing pagedown/pageup
// and redirecting it to the message list container for scrolling // and redirecting it to the message list container for scrolling
event.stopImmediatePropagation(); event.stopImmediatePropagation();
event.preventDefault(); event.preventDefault();
if (!this.searchText) { if (!searchText.value) {
return; 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 // Bail out if there's no channels to select
if (!channels.length) { if (!channels.length) {
this.activeSearchItem = null; activeSearchItem.value = null;
return; 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 there's no active channel select the first or last one depending on direction
if (!this.activeSearchItem || currentIndex === -1) { if (!activeSearchItem.value || currentIndex === -1) {
this.activeSearchItem = direction ? channels[0] : channels[channels.length - 1]; activeSearchItem.value = direction ? channels[0] : channels[channels.length - 1];
this.scrollToActive(); scrollToActive();
return; return;
} }
@ -467,19 +525,54 @@ export default {
currentIndex -= channels.length; currentIndex -= channels.length;
} }
this.activeSearchItem = channels[currentIndex]; activeSearchItem.value = channels[currentIndex];
this.scrollToActive(); 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");
if (el) { watch(searchText, () => {
el.scrollIntoView({block: "nearest", inline: "nearest"}); 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> </script>

View file

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

View file

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

View file

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

View file

@ -3,38 +3,64 @@
v-if="activeChannel" v-if="activeChannel"
:network="activeChannel.network" :network="activeChannel.network"
:channel="activeChannel.channel" :channel="activeChannel.channel"
:focused="$route.query.focused" :focused="parseInt(String(route.query.focused), 10)"
@channel-changed="channelChanged"
/> />
</template> </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 // Temporary component for routing channels and lobbies
import Chat from "./Chat.vue"; import Chat from "./Chat.vue";
export default { export default defineComponent({
name: "RoutedChat", name: "RoutedChat",
components: { components: {
Chat, Chat,
}, },
computed: { setup() {
activeChannel() { const route = useRoute();
const chanId = parseInt(this.$route.params.id, 10); const store = useStore();
const channel = this.$store.getters.findChannel(chanId);
const activeChannel = computed(() => {
const chanId = parseInt(String(route.params.id || ""), 10);
const channel = store.getters.findChannel(chanId);
return channel; 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> </script>

View file

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

View file

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

View file

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

View file

@ -19,12 +19,12 @@
Open irc:// URLs with The Lounge Open irc:// URLs with The Lounge
</button> </button>
</div> </div>
<div v-if="$store.state.serverConfiguration.fileUpload"> <div v-if="store.state.serverConfiguration?.fileUpload">
<h2>File uploads</h2> <h2>File uploads</h2>
<div> <div>
<label class="opt"> <label class="opt">
<input <input
:checked="$store.state.settings.uploadCanvas" :checked="store.state.settings.uploadCanvas"
type="checkbox" type="checkbox"
name="uploadCanvas" name="uploadCanvas"
/> />
@ -39,18 +39,18 @@
</label> </label>
</div> </div>
</div> </div>
<div v-if="!$store.state.serverConfiguration.public"> <div v-if="!store.state.serverConfiguration?.public">
<h2>Settings synchronisation</h2> <h2>Settings synchronisation</h2>
<label class="opt"> <label class="opt">
<input <input
:checked="$store.state.settings.syncSettings" :checked="store.state.settings.syncSettings"
type="checkbox" type="checkbox"
name="syncSettings" name="syncSettings"
/> />
Synchronize settings with other clients Synchronize settings with other clients
</label> </label>
<template v-if="!$store.state.settings.syncSettings"> <template v-if="!store.state.settings.syncSettings">
<div v-if="$store.state.serverHasSettings" class="settings-sync-panel"> <div v-if="store.state.serverHasSettings" class="settings-sync-panel">
<p> <p>
<strong>Warning:</strong> Checking this box will override the settings of <strong>Warning:</strong> Checking this box will override the settings of
this client with those stored on the server. this client with those stored on the server.
@ -71,14 +71,14 @@
</div> </div>
</template> </template>
</div> </div>
<div v-if="!$store.state.serverConfiguration.public"> <div v-if="!store.state.serverConfiguration?.public">
<h2>Automatic away message</h2> <h2>Automatic away message</h2>
<label class="opt"> <label class="opt">
<label for="awayMessage" class="sr-only">Automatic away message</label> <label for="awayMessage" class="sr-only">Automatic away message</label>
<input <input
id="awayMessage" id="awayMessage"
:value="$store.state.settings.awayMessage" :value="store.state.settings.awayMessage"
type="text" type="text"
name="awayMessage" name="awayMessage"
class="input" class="input"
@ -91,53 +91,85 @@
<style></style> <style></style>
<script> <script lang="ts">
let installPromptEvent = null; 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) => { window.addEventListener("beforeinstallprompt", (e) => {
e.preventDefault(); e.preventDefault();
installPromptEvent = e; installPromptEvent = e as BeforeInstallPromptEvent;
}); });
export default { export default defineComponent({
name: "GeneralSettings", name: "GeneralSettings",
data() { setup() {
return { const store = useStore();
canRegisterProtocol: false, const canRegisterProtocol = ref(false);
};
}, const hasInstallPromptEvent = computed(() => {
computed: {
hasInstallPromptEvent() {
// TODO: This doesn't hide the button after clicking // TODO: This doesn't hide the button after clicking
return installPromptEvent !== null; 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"); window.navigator.registerProtocolHandler("irc", uri, "The Lounge");
// @ts-expect-error
window.navigator.registerProtocolHandler("ircs", uri, "The Lounge"); window.navigator.registerProtocolHandler("ircs", uri, "The Lounge");
}, };
return {
store,
canRegisterProtocol,
hasInstallPromptEvent,
nativeInstallPrompt,
onForceSyncClick,
registerProtocol,
};
}, },
}; });
</script> </script>

View file

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

View file

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

View file

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

View file

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

View file

@ -1,9 +1,19 @@
<template> <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> </template>
<script> <script lang="ts">
export default { import {defineComponent} from "vue";
import {useStore} from "../js/store";
export default defineComponent({
name: "SidebarToggle", name: "SidebarToggle",
}; setup() {
const store = useStore();
return {
store,
};
},
});
</script> </script>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,44 +7,61 @@
/> />
</template> </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 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", name: "NetworkEdit",
components: { components: {
NetworkForm, NetworkForm,
}, },
data() { setup() {
return { const route = useRoute();
disabled: false, const store = useStore();
networkData: null,
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: { const handleSubmit = (data: {uuid: string; name: string}) => {
"$route.params.uuid"() { disabled.value = true;
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;
socket.emit("network:edit", data); socket.emit("network:edit", data);
// TODO: move networks to vuex and update state when the network info comes in // TODO: move networks to vuex and update state when the network info comes in
const network = this.$store.getters.findNetwork(data.uuid); const network = store.getters.findNetwork(data.uuid);
network.name = network.channels[0].name = data.name;
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> </script>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,14 +1,12 @@
"use strict";
// Taken from views/index.js // Taken from views/index.js
// This creates a version of `require()` in the context of the current // 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 // directory, so we iterate over its content, which is a map statically built by
// Webpack. // Webpack.
// Second argument says it's recursive, third makes sure we only load javascript. // 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); const command = path.substring(2, path.length - 3);
if (command === "index") { if (command === "index") {

View file

@ -1,15 +1,13 @@
"use strict";
import socket from "../socket"; import socket from "../socket";
import store from "../store"; import {store} from "../store";
import {switchToChannel} from "../router"; import {switchToChannel} from "../router";
function input(args) { function input(args: string[]) {
if (args.length > 0) { if (args.length > 0) {
let channels = args[0]; let channels = args[0];
if (channels.length > 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(","); const channelList = args[0].split(",");
if (chanTypes && chanTypes.length > 0) { if (chanTypes && chanTypes.length > 0) {
@ -27,15 +25,17 @@ function input(args) {
if (chan) { if (chan) {
switchToChannel(chan); switchToChannel(chan);
} else { } else {
socket.emit("input", { if (store.state.activeChannel) {
text: `/join ${channels} ${args.length > 1 ? args[1] : ""}`, socket.emit("input", {
target: store.state.activeChannel.channel.id, text: `/join ${channels} ${args.length > 1 ? args[1] : ""}`,
}); target: store.state.activeChannel.channel.id,
});
}
return true; 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 // If `/join` command is used without any arguments, re-join current channel
socket.emit("input", { socket.emit("input", {
target: store.state.activeChannel.channel.id, 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 = [ const colorCodeMap = [
["00", "White"], ["00", "White"],
["01", "Black"], ["01", "Black"],
@ -28,10 +26,9 @@ const timeFormats = {
msg12hWithSeconds: "hh:mm:ss A", msg12hWithSeconds: "hh:mm:ss A",
}; };
// This file is required by server, can't use es6 export export default {
module.exports = {
colorCodeMap, colorCodeMap,
commands: [], commands: [] as string[],
condensedTypes, condensedTypes,
timeFormats, timeFormats,
// Same value as media query in CSS that forces sidebars to become overlays // 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 {String} type Type of event to listen for.
* @param {Function} handler Function to call in response to given event. * @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)) { if (events.has(type)) {
events.get(type).push(handler); events.get(type).push(handler);
} else { } else {
@ -21,11 +21,11 @@ class EventBus {
* @param {String} type Type of event to unregister `handler` from. * @param {String} type Type of event to unregister `handler` from.
* @param {Function} handler Handler function to remove. * @param {Function} handler Handler function to remove.
*/ */
off(type, handler) { off(type: string, handler: (...evt: any[]) => void) {
if (events.has(type)) { if (events.has(type)) {
events.set( events.set(
type, 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 {String} type The event type to invoke.
* @param {Any} [evt] Any value (object is recommended and powerful), passed to each handler. * @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)) { if (events.has(type)) {
events events
.get(type) .get(type)
.slice() .slice()
.map((handler) => { .map((handler: (...evts: any[]) => void) => {
handler(...evt); handler(...evt);
}); });
} }

View file

@ -1,5 +1,3 @@
"use strict";
import storage from "../localStorage"; import storage from "../localStorage";
export default (network, isCollapsed) => { 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 // Generates a string from "color-1" to "color-32" based on an input string
export default (str) => { export default (str: string) => {
let hash = 0; let hash = 0;
for (let i = 0; i < str.length; i++) { for (let i = 0; i < str.length; i++) {
@ -13,5 +11,5 @@ export default (str) => {
due to A being ascii 65 (100 0001) due to A being ascii 65 (100 0001)
while a being ascii 97 (110 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 socket from "../socket";
import eventbus from "../eventbus"; 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 = { const typeMap = {
lobby: "network", lobby: "network",
channel: "chan", channel: "chan",
@ -18,7 +48,7 @@ export function generateChannelContextMenu($root, channel, network) {
special: "Close", special: "Close",
}; };
let items = [ let items: ContextMenuItem[] = [
{ {
label: channel.name, label: channel.name,
type: "item", type: "item",
@ -98,7 +128,7 @@ export function generateChannelContextMenu($root, channel, network) {
class: "edit", class: "edit",
action() { action() {
channel.editTopic = true; channel.editTopic = true;
$root.switchToChannel(channel); switchToChannel(channel);
}, },
}); });
items.push({ items.push({
@ -122,7 +152,7 @@ export function generateChannelContextMenu($root, channel, network) {
type: "item", type: "item",
class: "action-whois", class: "action-whois",
action() { action() {
$root.switchToChannel(channel); switchToChannel(channel);
socket.emit("input", { socket.emit("input", {
target: channel.id, target: channel.id,
text: "/whois " + channel.name, text: "/whois " + channel.name,
@ -170,13 +200,13 @@ export function generateChannelContextMenu($root, channel, network) {
}); });
} }
const humanFriendlyChanTypeMap = { const humanFriendlyChanTypeMap: Record<string, string> = {
lobby: "network", lobby: "network",
channel: "channel", channel: "channel",
query: "conversation", 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); const mutableChanTypes = Object.keys(humanFriendlyChanTypeMap);
if (mutableChanTypes.includes(channel.type)) { if (mutableChanTypes.includes(channel.type)) {
@ -201,23 +231,27 @@ export function generateChannelContextMenu($root, channel, network) {
type: "item", type: "item",
class: "close", class: "close",
action() { action() {
$root.closeChannel(channel); closeChannel();
}, },
}); });
return items; return items;
} }
export function generateInlineChannelContextMenu($root, chan, network) { export function generateInlineChannelContextMenu(
store: TypedStore,
chan: string,
network: ClientNetwork
): ContextMenuItem[] {
const join = () => { const join = () => {
const channel = network.channels.find((c) => c.name === chan); const channel = network.channels.find((c) => c.name === chan);
if (channel) { if (channel) {
$root.switchToChannel(channel); switchToChannel(channel);
} }
socket.emit("input", { socket.emit("input", {
target: $root.$store.state.activeChannel.channel.id, target: store.state.activeChannel.channel.id,
text: "/join " + chan, text: "/join " + chan,
}); });
}; };
@ -245,8 +279,13 @@ export function generateInlineChannelContextMenu($root, chan, network) {
]; ];
} }
export function generateUserContextMenu($root, channel, network, user) { export function generateUserContextMenu(
const currentChannelUser = channel 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) || {} ? 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); const chan = network.channels.find((c) => c.name === user.nick);
if (chan) { if (chan) {
$root.switchToChannel(chan); switchToChannel(chan);
} }
socket.emit("input", { socket.emit("input", {
@ -263,7 +302,7 @@ export function generateUserContextMenu($root, channel, network, user) {
}); });
}; };
const items = [ const items: ContextMenuItem[] = [
{ {
label: user.nick, label: user.nick,
type: "item", type: "item",
@ -295,10 +334,10 @@ export function generateUserContextMenu($root, channel, network, user) {
type: "item", type: "item",
class: "action-query", class: "action-query",
action() { action() {
const chan = $root.$store.getters.findChannelOnCurrentNetwork(user.nick); const chan = store.getters.findChannelOnCurrentNetwork(user.nick);
if (chan) { if (chan) {
$root.switchToChannel(chan); switchToChannel(chan);
} }
socket.emit("input", { 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)' // Labels for the mode changes. For example .rev({mode: "a", symbol: "&"}) => 'Revoke admin (-a)'
const modeTextTemplate = { const modeTextTemplate = {
revoke(m) { revoke(m: {symbol: string; mode: string}) {
const name = modeCharToName[m.symbol]; const name = modeCharToName[m.symbol];
if (typeof name !== "string") {
return "";
}
const res = name ? `Revoke ${name} (-${m.mode})` : `Mode -${m.mode}`; const res = name ? `Revoke ${name} (-${m.mode})` : `Mode -${m.mode}`;
return res; return res;
}, },
give(m) { give(m: {symbol: string; mode: string}) {
const name = modeCharToName[m.symbol]; const name = modeCharToName[m.symbol];
if (typeof name !== "string") {
return "";
}
const res = name ? `Give ${name} (+${m.mode})` : `Mode +${m.mode}`; const res = name ? `Give ${name} (+${m.mode})` : `Mode +${m.mode}`;
return res; return res;
}, },
@ -351,7 +400,7 @@ export function generateUserContextMenu($root, channel, network, user) {
* *
* @return {boolean} whether p1 can perform an action on p2 * @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. // The modes ~ and @ can perform actions on their own mode. The others on modes below.
return "~@".indexOf(p1) > -1 return "~@".indexOf(p1) > -1
? networkModeSymbols.indexOf(p1) <= networkModeSymbols.indexOf(p2) ? 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"]; const sizes = ["Bytes", "KiB", "MiB", "GiB", "TiB", "PiB"];
export default (size) => { export default (size: number) => {
// Loosely inspired from https://stackoverflow.com/a/18650828/1935861 // Loosely inspired from https://stackoverflow.com/a/18650828/1935861
const i = size > 0 ? Math.floor(Math.log(size) / Math.log(1024)) : 0; const i = size > 0 ? Math.floor(Math.log(size) / Math.log(1024)) : 0;
const fixedSize = parseFloat((size / Math.pow(1024, i)).toFixed(1)); 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 // 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. // markers) intersect each other, false otherwise.
function anyIntersection(a, b) { function anyIntersection(a: Part, b: Part) {
return ( return (
(a.start <= b.start && b.start < a.end) || (a.start <= b.start && b.start < a.end) ||
(a.start < b.end && b.end <= a.end) || (a.start < b.end && b.end <= a.end) ||

View file

@ -1,6 +1,4 @@
"use strict";
const matchFormatting = 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; /\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