Browse Source

TypeScript and Vue 3 (#4559)

Co-authored-by: Eric Nemchik <eric@nemchik.com>
Co-authored-by: Pavel Djundik <xPaw@users.noreply.github.com>
renovate/textcomplete-textarea-0.x
Max Leiter 6 days ago committed by GitHub
parent
commit
dd05ee3a65
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      .eslintignore
  2. 140
      .eslintrc.cjs
  3. 1
      .gitignore
  4. 7
      .npmignore
  5. 3
      .prettierignore
  6. 3
      .vscode/extensions.json
  7. 6
      .vscode/settings.json
  8. 7
      README.md
  9. 3
      babel.config.cjs
  10. 203
      client/components/App.vue
  11. 37
      client/components/Channel.vue
  12. 92
      client/components/ChannelWrapper.vue
  13. 184
      client/components/Chat.vue
  14. 373
      client/components/ChatInput.vue
  15. 162
      client/components/ChatUserList.vue
  16. 78
      client/components/ConfirmDialog.vue
  17. 339
      client/components/ContextMenu.vue
  18. 67
      client/components/DateMarker.vue
  19. 120
      client/components/Draggable.vue
  20. 428
      client/components/ImageViewer.vue
  21. 19
      client/components/InlineChannel.vue
  22. 61
      client/components/JoinChannel.vue
  23. 244
      client/components/LinkPreview.vue
  24. 19
      client/components/LinkPreviewFileSize.vue
  25. 36
      client/components/LinkPreviewToggle.vue
  26. 132
      client/components/Mentions.vue
  27. 83
      client/components/Message.vue
  28. 82
      client/components/MessageCondensed.vue
  29. 434
      client/components/MessageList.vue
  30. 113
      client/components/MessageSearchForm.vue
  31. 18
      client/components/MessageTypes/away.vue
  32. 18
      client/components/MessageTypes/back.vue
  33. 18
      client/components/MessageTypes/chghost.vue
  34. 22
      client/components/MessageTypes/ctcp.vue
  35. 18
      client/components/MessageTypes/ctcp_request.vue
  36. 56
      client/components/MessageTypes/error.vue
  37. 4
      client/components/MessageTypes/index.ts
  38. 18
      client/components/MessageTypes/invite.vue
  39. 24
      client/components/MessageTypes/join.vue
  40. 18
      client/components/MessageTypes/kick.vue
  41. 18
      client/components/MessageTypes/mode.vue
  42. 19
      client/components/MessageTypes/mode_channel.vue
  43. 19
      client/components/MessageTypes/mode_user.vue
  44. 32
      client/components/MessageTypes/monospace_block.vue
  45. 18
      client/components/MessageTypes/nick.vue
  46. 18
      client/components/MessageTypes/part.vue
  47. 18
      client/components/MessageTypes/quit.vue
  48. 19
      client/components/MessageTypes/raw.vue
  49. 18
      client/components/MessageTypes/topic.vue
  50. 28
      client/components/MessageTypes/topic_set_by.vue
  51. 32
      client/components/MessageTypes/whois.vue
  52. 183
      client/components/NetworkForm.vue
  53. 497
      client/components/NetworkList.vue
  54. 59
      client/components/NetworkLobby.vue
  55. 23
      client/components/ParsedMessage.vue
  56. 26
      client/components/RevealPassword.vue
  57. 70
      client/components/RoutedChat.vue
  58. 35
      client/components/Session.vue
  59. 100
      client/components/Settings/Account.vue
  60. 46
      client/components/Settings/Appearance.vue
  61. 118
      client/components/Settings/General.vue
  62. 7
      client/components/Settings/Navigation.vue
  63. 78
      client/components/Settings/Notifications.vue
  64. 35
      client/components/Settings/SettingTabItem.vue
  65. 244
      client/components/Sidebar.vue
  66. 18
      client/components/SidebarToggle.vue
  67. 26
      client/components/Special/ListBans.vue
  68. 12
      client/components/Special/ListChannels.vue
  69. 20
      client/components/Special/ListIgnored.vue
  70. 20
      client/components/Special/ListInvites.vue
  71. 79
      client/components/Username.vue
  72. 56
      client/components/VersionChecker.vue
  73. 62
      client/components/Windows/Changelog.vue
  74. 66
      client/components/Windows/Connect.vue
  75. 39
      client/components/Windows/Help.vue
  76. 73
      client/components/Windows/NetworkEdit.vue
  77. 354
      client/components/Windows/SearchResults.vue
  78. 36
      client/components/Windows/Settings.vue
  79. 72
      client/components/Windows/SignIn.vue
  80. 5
      client/css/style.css
  81. 2
      client/index.html.tpl
  82. 2
      client/js/auth.ts
  83. 106
      client/js/autocompletion.ts
  84. 9
      client/js/clipboard.ts
  85. 12
      client/js/commands/collapse.ts
  86. 12
      client/js/commands/expand.ts
  87. 6
      client/js/commands/index.ts
  88. 20
      client/js/commands/join.ts
  89. 24
      client/js/commands/search.js
  90. 27
      client/js/commands/search.ts
  91. 7
      client/js/constants.ts
  92. 10
      client/js/eventbus.ts
  93. 2
      client/js/helpers/collapseNetwork.ts
  94. 6
      client/js/helpers/colorClass.ts
  95. 91
      client/js/helpers/contextMenu.ts
  96. 5
      client/js/helpers/distance.js
  97. 5
      client/js/helpers/distance.ts
  98. 4
      client/js/helpers/friendlysize.ts
  99. 7
      client/js/helpers/ircmessageparser/anyIntersection.ts
  100. 4
      client/js/helpers/ircmessageparser/cleanIrcMessage.ts
  101. Some files were not shown because too many files have changed in this diff Show More

1
.eslintignore

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

140
.eslintrc.cjs

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

1
.gitignore vendored

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

7
.npmignore

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

3
.prettierignore

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

3
.vscode/extensions.json vendored

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

6
.vscode/settings.json vendored

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

7
README.md

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

3
babel.config.cjs

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

203
client/components/App.vue

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

37
client/components/Channel.vue

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

92
client/components/ChannelWrapper.vue

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

184
client/components/Chat.vue

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

373
client/components/ChatInput.vue

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