Merge pull request #1 from thelounge/bookworm/multiDefaultNets

Resolves merge conflicts in upstream repo
This commit is contained in:
Val Lorentz 2022-12-09 13:43:02 +01:00 committed by GitHub
commit 28552e8d8b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
367 changed files with 16971 additions and 11613 deletions

View file

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

191
.eslintrc.cjs Normal file
View file

@ -0,0 +1,191 @@
// @ts-check
const {defineConfig} = require("eslint-define-config");
const projects = defineConfig({
parserOptions: {
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"],
"dot-notation": "error",
eqeqeq: "error",
"handle-callback-err": "error",
"no-alert": "error",
"no-catch-shadow": "error",
"no-control-regex": "off",
"no-console": "error",
"no-duplicate-imports": "error",
"no-else-return": "error",
"no-implicit-globals": "error",
"no-restricted-globals": ["error", "event", "fdescribe"],
"no-template-curly-in-string": "error",
"no-unsafe-negation": "error",
"no-useless-computed-key": "error",
"no-useless-constructor": "error",
"no-useless-return": "error",
"no-use-before-define": [
"error",
{
functions: false,
},
],
"no-var": "error",
"object-shorthand": [
"error",
"methods",
{
avoidExplicitReturnArrows: true,
},
],
"padding-line-between-statements": [
"error",
{
blankLine: "always",
prev: ["block", "block-like"],
next: "*",
},
{
blankLine: "always",
prev: "*",
next: ["block", "block-like"],
},
],
"prefer-const": "error",
"prefer-rest-params": "error",
"prefer-spread": "error",
"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"],
},
}).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,
});

View file

@ -1,80 +0,0 @@
---
root: true
parserOptions:
ecmaVersion: 2020
env:
es6: true
browser: true
mocha: true
node: true
rules:
block-scoped-var: error
curly: [error, all]
dot-notation: error
eqeqeq: error
handle-callback-err: error
no-alert: error
no-catch-shadow: error
no-control-regex: off
no-console: error
no-duplicate-imports: error
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
no-useless-constructor: error
no-useless-return: error
no-use-before-define:
- error
- functions: false
no-var: error
object-shorthand:
- error
- methods
- avoidExplicitReturnArrows: true
padding-line-between-statements:
- error
- blankLine: always
prev:
- block
- block-like
next: "*"
- blankLine: always
prev: "*"
next:
- block
- block-like
prefer-const: error
prefer-rest-params: error
prefer-spread: error
spaced-comment: [error, always]
strict: off
yoda: error
vue/component-tags-order:
- error
- order:
- template
- style
- script
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

View file

@ -9,10 +9,6 @@ jobs:
strategy: strategy:
matrix: matrix:
include: include:
# EOL: April 2022
- os: ubuntu-latest
node_version: 12.x
# EOL: April 2023 # EOL: April 2023
- os: ubuntu-latest - os: ubuntu-latest
node_version: 14.x node_version: 14.x
@ -25,13 +21,17 @@ jobs:
- os: windows-latest - os: windows-latest
node_version: 16.x node_version: 16.x
# EOL: April 2025
- os: ubuntu-latest
node_version: 18.x
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- uses: actions/checkout@master - uses: actions/checkout@master
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v2 uses: actions/setup-node@v3
with: with:
node-version: ${{ matrix.node_version }} node-version: ${{ matrix.node_version }}

View file

@ -14,7 +14,7 @@ jobs:
- uses: actions/checkout@master - uses: actions/checkout@master
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v2 uses: actions/setup-node@v3
with: with:
registry-url: "https://registry.npmjs.org/" registry-url: "https://registry.npmjs.org/"

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,8 +1,10 @@
coverage/ coverage/
public/ public/
dist/
test/fixtures/.thelounge/logs/ test/fixtures/.thelounge/logs/
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

@ -1,8 +0,0 @@
arrowParens: always
bracketSpacing: false
printWidth: 100
trailingComma: "es5"
overrides:
- files: "*.webmanifest"
options:
parser: json

View file

@ -1,19 +0,0 @@
extends: stylelint-config-standard
rules:
indentation: tab
# complains about FontAwesome
font-family-no-missing-generic-family-keyword:
# needs a lot of refactoring to be enabled
no-descending-specificity:
# we have autoprefixer
at-rule-no-vendor-prefix: true
media-feature-name-no-vendor-prefix: true
property-no-vendor-prefix: true
selector-no-vendor-prefix: true
value-no-vendor-prefix: true
# renaming would break existing themes
selector-class-pattern: null
selector-id-pattern: null

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

@ -4,6 +4,89 @@ All notable changes to this project will be documented in this file.
<!-- New entries go after this line --> <!-- New entries go after this line -->
## v4.3.1 - 2022-04-11
For more details, [see the full changelog](https://github.com/thelounge/thelounge/compare/v4.3.0...v4.3.1) and [milestone](https://github.com/thelounge/thelounge/milestone/39?closed=1).
4.3.1 closes numerous bugs and introduces one prominent new feature closing [one of our most voted-on issues](https://github.com/thelounge/thelounge/issues/2490): muting! Users now have the ability to mute channels, networks, and private messages. Muted channels are dimmed in the channel list and notifications from them (including nick mentions) are disabled.
Also note that the npm package manager is no longer officially supported by The Lounge and we now only support using [yarn](https://yarnpkg.com).
### Added
- Add context menu when clicking inline channel name ([#4376](https://github.com/thelounge/thelounge/pull/4376) by [@sfan5](https://github.com/sfan5))
- Add /kickban ([#4361](https://github.com/thelounge/thelounge/pull/4361) by [@supertassu](https://github.com/supertassu))
- Add the option to mute channels, queries, and networks ([#4282](https://github.com/thelounge/thelounge/pull/4282) by [@MaxLeiter](https://github.com/MaxLeiter))
- Handle RPL_UMODEIS ([#4427](https://github.com/thelounge/thelounge/pull/4427) by [@brunnre8](https://github.com/brunnre8))
- Don't download image contents during prefetch if not needed ([#4363](https://github.com/thelounge/thelounge/pull/4363) by [@sfan5](https://github.com/sfan5))
- Emit a message for SASL loggedin/loggedout events ([`1e3a7b1`](https://github.com/thelounge/thelounge/commit/1e3a7b12500d8898500eaf54c01e52f8d5a0b3fd) by [@progval](https://github.com/progval))
- Log when file permissions should be changed ([#4373](https://github.com/thelounge/thelounge/pull/4373) by [@brunnre8](https://github.com/brunnre8))
### Changed
- Count number of mode changes, not mode messages in condensed messages ([#4438](https://github.com/thelounge/thelounge/pull/4438) by [@supertassu](https://github.com/supertassu))
- upload: improve error message ([#4435](https://github.com/thelounge/thelounge/pull/4435) by [@brunnre8](https://github.com/brunnre8))
- Use non 0 exit code in abnormal shutdown ([#4423](https://github.com/thelounge/thelounge/pull/4423) by [@brunnre8](https://github.com/brunnre8))
- Show a nicer error in Chan.loadMessages() when network is misconfigured ([#4476](https://github.com/thelounge/thelounge/pull/4476) by [@progval](https://github.com/progval))
- Remove uses of window.event. ([#4434](https://github.com/thelounge/thelounge/pull/4434) by [@itsjohncs](https://github.com/itsjohncs))
- Upload m4a as audio/mp4; embed audio/mp4, x-flac, and x-m4a ([#4470](https://github.com/thelounge/thelounge/pull/4470) by [@xnaas](https://github.com/xnaas))
- Use the DNS result order returned by the OS ([#4484](https://github.com/thelounge/thelounge/pull/4484) by [@sfan5](https://github.com/sfan5))
- Update dependencies to their latest versions:
- Production: `irc-framework` ([#4425](https://github.com/thelounge/thelounge/pull/4425)), `got` ([#4377](https://github.com/thelounge/thelounge/commit/cb404cd986416a9202a8d452bb29960520703b44)), `mime-types` ([#4378](https://github.com/thelounge/thelounge/commit/b54cdf7880a45387561125d1702a539ec0dca36b)), `yarn` ([#4380](https://github.com/thelounge/thelounge/pull/4380)), `file-type` ([#4384](https://github.com/thelounge/thelounge/pull/4384)), `css-loader` ([#4381](https://github.com/thelounge/thelounge/pull/4381)), `ua-parser-js` ([#4389](https://github.com/thelounge/thelounge/pull/4389)), `filenamify` ([#4391](https://github.com/thelounge/thelounge/pull/4391)), `irc-framework` ([#4392](https://github.com/thelounge/thelounge/pull/4392)), `tlds` ([#4397](https://github.com/thelounge/thelounge/pull/4397)), `vue monorepo` ([#4403](https://github.com/thelounge/thelounge/pull/4403)), `package-json` ([#4414](https://github.com/thelounge/thelounge/pull/4414)), `express` ([#4520](https://github.com/thelounge/thelounge/pull/4520)), `sqlite3` ([#4446](https://github.com/thelounge/thelounge/pull/4446))
- Development: `babel`, `babel-plugin-istanbul`, `cssnano`, `dayjs`, `mini-css-extract-plugin`, `mocha`, `postcss`, `postcss-preset-env`, `posscss-loader`, `webpack`, `webpack-cli`,
- Bump most deps ([#4453](https://github.com/thelounge/thelounge/pull/4453) by [@brunnre8](https://github.com/brunnre8))
- Switch busboy implementation to `@fastify/busboy` ([#4428](https://github.com/thelounge/thelounge/pull/4428) by [@maxpoulin64](https://github.com/maxpoulin64))
### Fixed
- Clear obsolete mentions upon channel part ([#4436](https://github.com/thelounge/thelounge/pull/4436) by [@brunnre8](https://github.com/brunnre8))
- clientCert: fix up error message ([#4462](https://github.com/thelounge/thelounge/pull/4462) by [@brunnre8](https://github.com/brunnre8))
- getGitCommit: allow git worktrees ([#4426](https://github.com/thelounge/thelounge/pull/4426) by [@brunnre8](https://github.com/brunnre8))
- Make sure the leading '<' is select when copypasting a message ([#4473](https://github.com/thelounge/thelounge/pull/4473) by [@progval](https://github.com/progval))
- Mentions window: filter list when we part a chan ([#4436](https://github.com/thelounge/thelounge/pull/4436) by [@brunnre8](https://github.com/brunnre8))
- Fix /collapse and /expand from interacting with the server in public mode ([#4488](https://github.com/thelounge/thelounge/pull/4488) by [@MaxLeiter](https://github.com/MaxLeiter))
### Documentation
In the main repository:
- Remove extra 'be' in default config.js LDAP comment ([#4430](https://github.com/thelounge/thelounge/pull/4430) by [@MaxLeiter](https://github.com/MaxLeiter))
- Adding 'to' in a sentence in config.js ([#4459](https://github.com/thelounge/thelounge/pull/4459) by [@fnutt](https://github.com/fnutt))
- Remove downloads badge and add thelounge/thelounge-docker link to README ([#4371](https://github.com/thelounge/thelounge/pull/4371) by [@MaxLeiter](https://github.com/MaxLeiter))
- README: suggest running 'yarn format:prettier' when linting fails ([#4467](https://github.com/thelounge/thelounge/pull/4467) by [@progval](https://github.com/progval))
On the [website repository](https://github.com/thelounge/thelounge.github.io):
- update lsio link ([#255](https://github.com/thelounge/thelounge.github.io/pull/255) by [@xnaas](https://github.com/xnaas))
- Document prefetchMaxSearchSize config option ([#256](https://github.com/thelounge/thelounge.github.io/pull/256) by [@MaxLeiter](https://github.com/MaxLeiter))
- Update custom-css.md (#258) ([`de8c020`](https://github.com/thelounge/thelounge.github.io/commit/de8c02017cdd8c9bd46e60b899a3bd6a2d8977ec) by [@PeGaSuS-Coder](https://github.com/PeGaSuS-Coder))
- Remove analytics ([`3eb7fdc`](https://github.com/thelounge/thelounge.github.io/commit/3eb7fdc0bf07ade96829bcfe858e06a47e796ab2) by [@xPaw](https://github.com/xPaw))
- Remove star button ([`eec5b9c`](https://github.com/thelounge/thelounge.github.io/commit/eec5b9c99ec48a28b6ccfc5de7f7273eb284f558) by [@xPaw](https://github.com/xPaw))
- Bump addressable from 2.5.2 to 2.8.0 ([#246](https://github.com/thelounge/thelounge.github.io/pull/246) by [@dependabot](https://github.com/apps/dependabot))
- Update to Jekyll ~> 4.2.1 (#259) ([`db06e52`](https://github.com/thelounge/thelounge.github.io/commit/db06e524fdd2c55a929b0751abeaa761c8550882) by [@MaxLeiter](https://github.com/MaxLeiter))
- Update config documentation for 4.3.1 (#260) ([`94a1179`](https://github.com/thelounge/thelounge.github.io/commit/94a1179e7fa513ee6c1006455d4cdd9729033429) by [@MaxLeiter](https://github.com/MaxLeiter))
### Internals
- Remove node 15.x from build matrix ([#4449](https://github.com/thelounge/thelounge/pull/4449) by [@brunnre8](https://github.com/brunnre8))
- Fix vue/this-in-template linter warning ([#4418](https://github.com/thelounge/thelounge/pull/4418) by [@brunnre8](https://github.com/brunnre8))
- Update actions/setup-node action to v3 ([#4496](https://github.com/thelounge/thelounge/pull/4496) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
## v4.3.1-rc.1 - 2022-03-02 [Pre-release]
[See the full changelog](https://github.com/thelounge/thelounge/compare/v4.3.0...v4.3.1-rc.1)
This is a release candidate (RC) for v4.3.1 to ensure maximum stability for public release.
Bugs may be fixed, but no further features will be added until the next stable version.
Please refer to the commit list given above for a complete list of changes, or wait for the stable release to get a thoroughly prepared change log entry.
As with all pre-releases, this version requires explicit use of the `next` tag to be installed:
```sh
yarn global add thelounge@next
```
## v4.3.0 - 2021-11-22 ## v4.3.0 - 2021-11-22
For more details, [see the full changelog](https://github.com/thelounge/thelounge/compare/v4.2.0...v4.3.0) and [milestone](https://github.com/thelounge/thelounge/milestone/37?closed=1). For more details, [see the full changelog](https://github.com/thelounge/thelounge/compare/v4.2.0...v4.3.0) and [milestone](https://github.com/thelounge/thelounge/milestone/37?closed=1).

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

4
babel.config.cjs Normal file
View file

@ -0,0 +1,4 @@
module.exports = {
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

@ -13,9 +13,10 @@
:aria-label="getInputPlaceholder(channel)" :aria-label="getInputPlaceholder(channel)"
@input="setPendingMessage" @input="setPendingMessage"
@keypress.enter.exact.prevent="onSubmit" @keypress.enter.exact.prevent="onSubmit"
@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"
@ -33,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
@ -45,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";
@ -59,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",
@ -85,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) &&
@ -267,18 +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 = () => {
if (!uploadInput.value || !uploadInput.value.files) {
return;
}
const files = Array.from(uploadInput.value.files);
upload.triggerUpload(files); upload.triggerUpload(files);
this.$refs.uploadInput.value = ""; // Reset <input> element so you can upload the same file uploadInput.value.value = ""; // Reset <input> element so you can upload the same file
}, };
openFileUpload() {
this.$refs.uploadInput.click(); const openFileUpload = () => {
}, uploadInput.value?.click();
blurInput() { };
this.$refs.input.blur();
}, 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>
<template v-if="config.defaults.length > 0"> <template v-if="config.defaults.length > 0">
<div class="connect-row"> <div class="connect-row">
@ -36,7 +38,7 @@
<label for="connect:name">Name</label> <label for="connect:name">Name</label>
<input <input
id="connect:name" id="connect:name"
v-model="defaults.name" v-model.trim="defaults.name"
class="input" class="input"
name="name" name="name"
maxlength="100" maxlength="100"
@ -47,7 +49,7 @@
<div class="input-wrap"> <div class="input-wrap">
<input <input
id="connect:host" id="connect:host"
v-model="defaults.host" v-model.trim="defaults.host"
class="input" class="input"
name="host" name="host"
aria-label="Server address" aria-label="Server address"
@ -134,7 +136,7 @@
<div class="input-wrap"> <div class="input-wrap">
<input <input
id="connect:proxyHost" id="connect:proxyHost"
v-model="defaults.proxyHost" v-model.trim="defaults.proxyHost"
class="input" class="input"
name="proxyHost" name="proxyHost"
aria-label="Proxy host" aria-label="Proxy host"
@ -159,7 +161,7 @@
<input <input
id="connect:proxyUsername" id="connect:proxyUsername"
ref="proxyUsernameInput" ref="proxyUsernameInput"
v-model="defaults.proxyUsername" v-model.trim="defaults.proxyUsername"
class="input username" class="input username"
name="proxyUsername" name="proxyUsername"
maxlength="100" maxlength="100"
@ -245,13 +247,13 @@
@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
id="connect:username" id="connect:username"
ref="usernameInput" ref="usernameInput"
v-model="defaults.username" v-model.trim="defaults.username"
class="input username" class="input username"
name="username" name="username"
maxlength="100" maxlength="100"
@ -262,7 +264,7 @@
<label for="connect:realname">Real name</label> <label for="connect:realname">Real name</label>
<input <input
id="connect:realname" id="connect:realname"
v-model="defaults.realname" v-model.trim="defaults.realname"
class="input" class="input"
name="realname" name="realname"
maxlength="300" maxlength="300"
@ -272,14 +274,14 @@
<label for="connect:leaveMessage">Leave message</label> <label for="connect:leaveMessage">Leave message</label>
<input <input
id="connect:leaveMessage" id="connect:leaveMessage"
v-model="defaults.leaveMessage" v-model.trim="defaults.leaveMessage"
autocomplete="off" autocomplete="off"
class="input" class="input"
name="leaveMessage" name="leaveMessage"
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
@ -308,15 +310,15 @@ the server tab on new connection"
<label for="connect:channels">Channels</label> <label for="connect:channels">Channels</label>
<input <input
id="connect:channels" id="connect:channels"
v-model="defaults.join" v-model.trim="defaults.join"
class="input" class="input"
name="join" name="join"
/> />
</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">
@ -370,7 +372,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
@ -389,7 +391,7 @@ the server tab on new connection"
<label for="connect:username">Account</label> <label for="connect:username">Account</label>
<input <input
id="connect:saslAccount" id="connect:saslAccount"
v-model="defaults.saslAccount" v-model.trim="defaults.saslAccount"
class="input" class="input"
name="saslAccount" name="saslAccount"
maxlength="100" maxlength="100"
@ -462,98 +464,148 @@ 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);
presetName: this.$store.state.serverConfiguration.defaults[0].name, const presetName = ref(store.state.serverConfiguration.defaults[0]?.name);
};
},
watch: {
displayPasswordField(value) {
if (value) {
this.$nextTick(() => this.$refs.publicPassword.focus());
}
},
presetName(name) {
for (const defaultNetwork of this.config.defaults) {
if (defaultNetwork.name === name) {
Object.assign(this.defaults, defaultNetwork);
break;
}
}
},
"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]) { watch(displayPasswordField, (newValue) => {
this.defaults.port = ports[1 - newPort]; if (newValue) {
void nextTick(() => {
publicPassword.value?.focus();
});
} }
}, });
},
methods: { watch(presetName, (newValue) => {
setSaslAuth(type) { const defaults = store.state.serverConfiguration.defaults.find(
this.defaults.sasl = type; (def) => def.name === newValue
}, );
onNickChanged(event) {
// Username input is not available when useHexIp is set if (!defaults) {
if (!this.$refs.usernameInput) {
return; return;
} }
if ( Object.assign(props.defaults, defaults);
!this.$refs.usernameInput.value || });
this.$refs.usernameInput.value === this.previousUsername
) {
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,
presetName,
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

@ -0,0 +1,195 @@
<template>
<div>
<div
v-if="
!store.state.serverConfiguration?.public &&
!store.state.serverConfiguration?.ldapEnabled
"
id="change-password"
role="group"
aria-labelledby="label-change-password"
>
<h2 id="label-change-password">Change password</h2>
<div class="password-container">
<label for="current-password" class="sr-only"> Enter current password </label>
<RevealPassword v-slot:default="slotProps">
<input
id="current-password"
autocomplete="current-password"
:type="slotProps.isVisible ? 'text' : 'password'"
name="old_password"
class="input"
placeholder="Enter current password"
/>
</RevealPassword>
</div>
<div class="password-container">
<label for="new-password" class="sr-only"> Enter desired new password </label>
<RevealPassword v-slot:default="slotProps">
<input
id="new-password"
:type="slotProps.isVisible ? 'text' : 'password'"
name="new_password"
autocomplete="new-password"
class="input"
placeholder="Enter desired new password"
/>
</RevealPassword>
</div>
<div class="password-container">
<label for="new-password-verify" class="sr-only"> Repeat new password </label>
<RevealPassword v-slot:default="slotProps">
<input
id="new-password-verify"
:type="slotProps.isVisible ? 'text' : 'password'"
name="verify_password"
autocomplete="new-password"
class="input"
placeholder="Repeat new password"
/>
</RevealPassword>
</div>
<div
v-if="passwordChangeStatus && passwordChangeStatus.success"
class="feedback success"
>
Successfully updated your password
</div>
<div
v-else-if="passwordChangeStatus && passwordChangeStatus.error"
class="feedback error"
>
{{ passwordErrors[passwordChangeStatus.error] }}
</div>
<div>
<button type="submit" class="btn" @click.prevent="changePassword">
Change password
</button>
</div>
</div>
<div v-if="!store.state.serverConfiguration?.public" class="session-list" role="group">
<h2>Sessions</h2>
<h3>Current session</h3>
<Session v-if="currentSession" :session="currentSession" />
<template v-if="activeSessions.length > 0">
<h3>Active sessions</h3>
<Session
v-for="session in activeSessions"
:key="session.token"
:session="session"
/>
</template>
<h3>Other sessions</h3>
<p v-if="store.state.sessions.length === 0">Loading</p>
<p v-else-if="otherSessions.length === 0">
<em>You are not currently logged in to any other device.</em>
</p>
<Session
v-for="session in otherSessions"
v-else
:key="session.token"
:session="session"
/>
</div>
</div>
</template>
<script lang="ts">
import socket from "../../js/socket";
import RevealPassword from "../RevealPassword.vue";
import Session from "../Session.vue";
import {computed, defineComponent, onMounted, PropType, ref} from "vue";
import {useStore} from "../../js/store";
export default defineComponent({
name: "UserSettings",
components: {
RevealPassword,
Session,
},
props: {
settingsForm: {
type: Object as PropType<HTMLFormElement>,
required: true,
},
},
setup(props) {
const store = useStore();
const passwordErrors = {
missing_fields: "Please enter a new password",
password_mismatch: "Both new password fields must match",
password_incorrect: "The current password field does not match your account password",
update_failed: "Failed to update your password",
};
const passwordChangeStatus = ref<{
success: boolean;
error: keyof typeof passwordErrors;
}>();
const currentSession = computed(() => {
return store.state.sessions.find((item) => item.current);
});
const activeSessions = computed(() => {
return store.state.sessions.filter((item) => !item.current && item.active > 0);
});
const otherSessions = computed(() => {
return store.state.sessions.filter((item) => !item.current && !item.active);
});
onMounted(() => {
socket.emit("sessions:get");
});
const changePassword = () => {
const allFields = new FormData(props.settingsForm);
const data = {
old_password: allFields.get("old_password"),
new_password: allFields.get("new_password"),
verify_password: allFields.get("verify_password"),
};
if (!data.old_password || !data.new_password || !data.verify_password) {
passwordChangeStatus.value = {
success: false,
error: "missing_fields",
};
return;
}
if (data.new_password !== data.verify_password) {
passwordChangeStatus.value = {
success: false,
error: "password_mismatch",
};
return;
}
socket.once("change-password", (response) => {
// TODO type
passwordChangeStatus.value = response as any;
});
socket.emit("change-password", data);
};
return {
store,
passwordChangeStatus,
passwordErrors,
currentSession,
activeSessions,
otherSessions,
changePassword,
};
},
});
</script>

View file

@ -0,0 +1,179 @@
<template>
<div>
<h2>Messages</h2>
<div>
<label class="opt">
<input :checked="store.state.settings.motd" type="checkbox" name="motd" />
Show <abbr title="Message Of The Day">MOTD</abbr>
</label>
</div>
<div>
<label class="opt">
<input
:checked="store.state.settings.showSeconds"
type="checkbox"
name="showSeconds"
/>
Include seconds in timestamp
</label>
</div>
<div>
<label class="opt">
<input
:checked="store.state.settings.use12hClock"
type="checkbox"
name="use12hClock"
/>
Use 12-hour timestamps
</label>
</div>
<template v-if="store.state.serverConfiguration?.prefetch">
<h2>Link previews</h2>
<div>
<label class="opt">
<input :checked="store.state.settings.media" type="checkbox" name="media" />
Auto-expand media
</label>
</div>
<div>
<label class="opt">
<input :checked="store.state.settings.links" type="checkbox" name="links" />
Auto-expand websites
</label>
</div>
</template>
<h2 id="label-status-messages">
Status messages
<span
class="tooltipped tooltipped-n tooltipped-no-delay"
aria-label="Joins, parts, quits, kicks, nick changes, and mode changes"
>
<button class="extra-help" />
</span>
</h2>
<div role="group" aria-labelledby="label-status-messages">
<label class="opt">
<input
:checked="store.state.settings.statusMessages === 'shown'"
type="radio"
name="statusMessages"
value="shown"
/>
Show all status messages individually
</label>
<label class="opt">
<input
:checked="store.state.settings.statusMessages === 'condensed'"
type="radio"
name="statusMessages"
value="condensed"
/>
Condense status messages together
</label>
<label class="opt">
<input
:checked="store.state.settings.statusMessages === 'hidden'"
type="radio"
name="statusMessages"
value="hidden"
/>
Hide all status messages
</label>
</div>
<h2>Visual Aids</h2>
<div>
<label class="opt">
<input
:checked="store.state.settings.coloredNicks"
type="checkbox"
name="coloredNicks"
/>
Enable colored nicknames
</label>
<label class="opt">
<input
:checked="store.state.settings.autocomplete"
type="checkbox"
name="autocomplete"
/>
Enable autocomplete
</label>
</div>
<div>
<label class="opt">
<label for="nickPostfix" class="opt">
Nick autocomplete postfix
<span
class="tooltipped tooltipped-n tooltipped-no-delay"
aria-label="Nick autocomplete postfix (for example a comma)"
>
<button class="extra-help" />
</span>
</label>
<input
id="nickPostfix"
:value="store.state.settings.nickPostfix"
type="text"
name="nickPostfix"
class="input"
placeholder="Nick autocomplete postfix (e.g. ', ')"
/>
</label>
</div>
<h2>Theme</h2>
<div>
<label for="theme-select" class="sr-only">Theme</label>
<select
id="theme-select"
:value="store.state.settings.theme"
name="theme"
class="input"
>
<option
v-for="theme in store.state.serverConfiguration?.themes"
:key="theme.name"
:value="theme.name"
>
{{ theme.displayName }}
</option>
</select>
</div>
<div>
<h2>Custom Stylesheet</h2>
<label for="user-specified-css-input" class="sr-only">
Custom stylesheet. You can override any style with CSS here.
</label>
<textarea
id="user-specified-css-input"
:value="store.state.settings.userStyles"
class="input"
name="userStyles"
placeholder="/* You can override any style with CSS here */"
/>
</div>
</div>
</template>
<style>
textarea#user-specified-css-input {
height: 100px;
}
</style>
<script lang="ts">
import {defineComponent} from "vue";
import {useStore} from "../../js/store";
export default defineComponent({
name: "AppearanceSettings",
setup() {
const store = useStore();
return {
store,
};
},
});
</script>

View file

@ -0,0 +1,175 @@
<template>
<div>
<div v-if="canRegisterProtocol || hasInstallPromptEvent">
<h2>Native app</h2>
<button
v-if="hasInstallPromptEvent"
type="button"
class="btn"
@click.prevent="nativeInstallPrompt"
>
Add The Lounge to Home screen
</button>
<button
v-if="canRegisterProtocol"
type="button"
class="btn"
@click.prevent="registerProtocol"
>
Open irc:// URLs with The Lounge
</button>
</div>
<div v-if="store.state.serverConfiguration?.fileUpload">
<h2>File uploads</h2>
<div>
<label class="opt">
<input
:checked="store.state.settings.uploadCanvas"
type="checkbox"
name="uploadCanvas"
/>
Attempt to remove metadata from images before uploading
<span
class="tooltipped tooltipped-n tooltipped-no-delay"
aria-label="This option renders the image into a canvas element to remove metadata from the image.
This may break orientation if your browser does not support that."
>
<button class="extra-help" />
</span>
</label>
</div>
</div>
<div v-if="!store.state.serverConfiguration?.public">
<h2>Settings synchronisation</h2>
<label class="opt">
<input
:checked="store.state.settings.syncSettings"
type="checkbox"
name="syncSettings"
/>
Synchronize settings with other clients
</label>
<template v-if="!store.state.settings.syncSettings">
<div v-if="store.state.serverHasSettings" class="settings-sync-panel">
<p>
<strong>Warning:</strong> Checking this box will override the settings of
this client with those stored on the server.
</p>
<p>
Use the button below to enable synchronization, and override any settings
already synced to the server.
</p>
<button type="button" class="btn btn-small" @click="onForceSyncClick">
Sync settings and enable
</button>
</div>
<div v-else class="settings-sync-panel">
<p>
<strong>Warning:</strong> No settings have been synced before. Enabling this
will sync all settings of this client as the base for other clients.
</p>
</div>
</template>
</div>
<div v-if="!store.state.serverConfiguration?.public">
<h2>Automatic away message</h2>
<label class="opt">
<label for="awayMessage" class="sr-only">Automatic away message</label>
<input
id="awayMessage"
:value="store.state.settings.awayMessage"
type="text"
name="awayMessage"
class="input"
placeholder="Away message if The Lounge is not open"
/>
</label>
</div>
</div>
</template>
<style></style>
<script lang="ts">
import {computed, defineComponent, onMounted, ref} from "vue";
import {useStore} from "../../js/store";
import {BeforeInstallPromptEvent} from "../../js/types";
let installPromptEvent: BeforeInstallPromptEvent | null = null;
window.addEventListener("beforeinstallprompt", (e) => {
e.preventDefault();
installPromptEvent = e as BeforeInstallPromptEvent;
});
export default defineComponent({
name: "GeneralSettings",
setup() {
const store = useStore();
const canRegisterProtocol = ref(false);
const hasInstallPromptEvent = computed(() => {
// TODO: This doesn't hide the button after clicking
return installPromptEvent !== null;
});
onMounted(() => {
// Enable protocol handler registration if supported,
// and the network configuration is not locked
canRegisterProtocol.value =
!!window.navigator.registerProtocolHandler &&
!store.state.serverConfiguration?.lockNetwork;
});
const nativeInstallPrompt = () => {
if (!installPromptEvent) {
return;
}
installPromptEvent.prompt().catch((e) => {
// eslint-disable-next-line no-console
console.error(e);
});
installPromptEvent = null;
};
const onForceSyncClick = () => {
store.dispatch("settings/syncAll", true).catch((e) => {
// eslint-disable-next-line no-console
console.error(e);
});
store
.dispatch("settings/update", {
name: "syncSettings",
value: true,
sync: true,
})
.catch((e) => {
// eslint-disable-next-line no-console
console.error(e);
});
};
const registerProtocol = () => {
const uri = document.location.origin + document.location.pathname + "?uri=%s";
// @ts-expect-error
// the third argument is deprecated but recommended for compatibility: https://developer.mozilla.org/en-US/docs/Web/API/Navigator/registerProtocolHandler
window.navigator.registerProtocolHandler("irc", uri, "The Lounge");
// @ts-expect-error
window.navigator.registerProtocolHandler("ircs", uri, "The Lounge");
};
return {
store,
canRegisterProtocol,
hasInstallPromptEvent,
nativeInstallPrompt,
onForceSyncClick,
registerProtocol,
};
},
});
</script>

View file

@ -0,0 +1,103 @@
<template>
<!-- 220px is the width of the sidebar, and we add 100px to allow for the text -->
<aside class="settings-menu">
<h2>Settings</h2>
<ul role="navigation" aria-label="Settings tabs">
<SettingTabItem name="General" class-name="general" to="" />
<SettingTabItem name="Appearance" class-name="appearance" to="appearance" />
<SettingTabItem name="Notifications" class-name="notifications" to="notifications" />
<SettingTabItem name="Account" class-name="account" to="account" />
</ul>
</aside>
</template>
<style>
.settings-menu {
position: fixed;
/* top: Header + (padding bottom of h2 - border) */
top: calc(45px + 5px);
/* Mid page minus width of container and 30 pixels for padding */
margin-left: calc(50% - 480px - 30px);
}
/** The calculation is mobile + 2/3 of container width. Fairly arbitrary. */
@media screen and (max-width: calc(768px + 320px)) {
.settings-menu {
position: static;
width: min(480px, 100%);
align-self: center;
margin: 0 auto;
padding: 0 15px;
}
}
.settings-menu ul {
padding: 0;
}
.settings-menu li {
font-size: 18px;
list-style: none;
}
.settings-menu button {
color: var(--body-color-muted);
width: 100%;
height: 100%;
display: inline-block;
text-align: left;
}
.settings-menu li:not(:last-of-type) button {
margin-bottom: 8px;
}
.settings-menu button::before {
width: 18px;
height: 18px;
display: inline-block;
content: "";
margin-right: 8px;
}
.settings-menu .appearance::before {
content: "\f108"; /* http://fontawesome.io/icon/desktop/ */
}
.settings-menu .account::before {
content: "\f007"; /* http://fontawesome.io/icon/user/ */
}
.settings-menu .messages::before {
content: "\f0e0"; /* http://fontawesome.io/icon/envelope/ */
}
.settings-menu .notifications::before {
content: "\f0f3"; /* http://fontawesome.io/icon/bell/ */
}
.settings-menu .general::before {
content: "\f013"; /* http://fontawesome.io/icon/cog/ */
}
.settings-menu button:hover,
.settings-menu button.active {
color: var(--body-color);
}
.settings-menu button.active {
cursor: default;
}
</style>
<script lang="ts">
import SettingTabItem from "./SettingTabItem.vue";
import {defineComponent} from "vue";
export default defineComponent({
name: "SettingsTabs",
components: {
SettingTabItem,
},
});
</script>

View file

@ -0,0 +1,199 @@
<template>
<div>
<template v-if="!store.state.serverConfiguration?.public">
<h2>Push Notifications</h2>
<div>
<button
id="pushNotifications"
type="button"
class="btn"
:disabled="
store.state.pushNotificationState !== 'supported' &&
store.state.pushNotificationState !== 'subscribed'
"
@click="onPushButtonClick"
>
<template v-if="store.state.pushNotificationState === 'subscribed'">
Unsubscribe from push notifications
</template>
<template v-else-if="store.state.pushNotificationState === 'loading'">
Loading
</template>
<template v-else> Subscribe to push notifications </template>
</button>
<div v-if="store.state.pushNotificationState === 'nohttps'" class="error">
<strong>Warning</strong>: Push notifications are only supported over HTTPS
connections.
</div>
<div v-if="store.state.pushNotificationState === 'unsupported'" class="error">
<strong>Warning</strong>:
<span>Push notifications are not supported by your browser.</span>
<div v-if="isIOS" class="apple-push-unsupported">
Safari does
<a
href="https://bugs.webkit.org/show_bug.cgi?id=182566"
target="_blank"
rel="noopener"
>not support the web push notification specification</a
>, and because all browsers on iOS use Safari under the hood, The Lounge is
unable to provide push notifications on iOS devices.
</div>
</div>
</div>
</template>
<h2>Browser Notifications</h2>
<div>
<label class="opt">
<input
id="desktopNotifications"
:checked="store.state.settings.desktopNotifications"
:disabled="store.state.desktopNotificationState === 'nohttps'"
type="checkbox"
name="desktopNotifications"
/>
Enable browser notifications<br />
<div v-if="store.state.desktopNotificationState === 'unsupported'" class="error">
<strong>Warning</strong>: Notifications are not supported by your browser.
</div>
<div
v-if="store.state.desktopNotificationState === 'nohttps'"
id="warnBlockedDesktopNotifications"
class="error"
>
<strong>Warning</strong>: Notifications are only supported over HTTPS
connections.
</div>
<div
v-if="store.state.desktopNotificationState === 'blocked'"
id="warnBlockedDesktopNotifications"
class="error"
>
<strong>Warning</strong>: Notifications are blocked by your browser.
</div>
</label>
</div>
<div>
<label class="opt">
<input
:checked="store.state.settings.notification"
type="checkbox"
name="notification"
/>
Enable notification sound
</label>
</div>
<div>
<div class="opt">
<button id="play" @click.prevent="playNotification">Play sound</button>
</div>
</div>
<div>
<label class="opt">
<input
:checked="store.state.settings.notifyAllMessages"
type="checkbox"
name="notifyAllMessages"
/>
Enable notification for all messages
</label>
</div>
<div v-if="!store.state.serverConfiguration?.public">
<label class="opt">
<label for="highlights" class="opt">
Custom highlights
<span
class="tooltipped tooltipped-n tooltipped-no-delay"
aria-label="If a message contains any of these comma-separated
expressions, it will trigger a highlight."
>
<button class="extra-help" />
</span>
</label>
<input
id="highlights"
:value="store.state.settings.highlights"
type="text"
name="highlights"
class="input"
autocomplete="off"
placeholder="Comma-separated, e.g.: word, some more words, anotherword"
/>
</label>
</div>
<div v-if="!store.state.serverConfiguration?.public">
<label class="opt">
<label for="highlightExceptions" class="opt">
Highlight exceptions
<span
class="tooltipped tooltipped-n tooltipped-no-delay"
aria-label="If a message contains any of these comma-separated
expressions, it will not trigger a highlight even if it contains
your nickname or expressions defined in custom highlights."
>
<button class="extra-help" />
</span>
</label>
<input
id="highlightExceptions"
:value="store.state.settings.highlightExceptions"
type="text"
name="highlightExceptions"
class="input"
autocomplete="off"
placeholder="Comma-separated, e.g.: word, some more words, anotherword"
/>
</label>
</div>
</div>
</template>
<script lang="ts">
import {computed, defineComponent} from "vue";
import {useStore} from "../../js/store";
import webpush from "../../js/webpush";
export default defineComponent({
name: "NotificationSettings",
setup() {
const store = useStore();
const isIOS = computed(
() =>
[
"iPad Simulator",
"iPhone Simulator",
"iPod Simulator",
"iPad",
"iPhone",
"iPod",
].includes(navigator.platform) ||
// iPad on iOS 13 detection
(navigator.userAgent.includes("Mac") && "ontouchend" in document)
);
const playNotification = () => {
const pop = new Audio();
pop.src = "audio/pop.wav";
// eslint-disable-next-line
pop.play();
};
const onPushButtonClick = () => {
webpush.togglePushSubscription();
};
return {
isIOS,
store,
playNotification,
onPushButtonClick,
};
},
});
</script>

View file

@ -0,0 +1,43 @@
<template>
<li :aria-label="name" role="tab" :aria-selected="route.name === name" aria-controls="settings">
<router-link v-slot:default="{navigate, isExactActive}" :to="'/settings/' + to" custom>
<button
:class="['icon', className, {active: isExactActive}]"
@click="navigate"
@keypress.enter="navigate"
>
{{ name }}
</button>
</router-link>
</li>
</template>
<script lang="ts">
import {defineComponent} from "vue";
import {useRoute} from "vue-router";
export default defineComponent({
name: "SettingTabListItem",
props: {
name: {
type: String,
required: true,
},
className: {
type: String,
required: true,
},
to: {
type: String,
required: true,
},
},
setup() {
const route = useRoute();
return {
route,
};
},
});
</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 === 'Settings'" >
/></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,26 +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[0],
this.parseOverrideParams(this.queryParams) const handleSubmit = (data: Record<string, any>) => {
); disabled.value = true;
return {
disabled: false,
defaultNetworks: this.$store.state.serverConfiguration,
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];
@ -50,7 +49,7 @@ export default {
if ( if (
!Object.prototype.hasOwnProperty.call( !Object.prototype.hasOwnProperty.call(
this.$store.state.serverConfiguration.defaults[0], store.state.serverConfiguration?.defaults[0],
key key
) )
) { ) {
@ -58,16 +57,13 @@ 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 (this.$store.state.serverConfiguration.lockNetwork) { if (store.state.serverConfiguration?.lockNetwork) {
if (["host", "port", "tls", "rejectUnauthorized"].includes(key)) { if (["host", "port", "tls", "rejectUnauthorized"].includes(key)) {
continue; continue;
} }
// Network name is only disabled if there is a single network // Network name is only disabled if there is a single network
if ( if (key === "name" && store.state.serverConfiguration?.defaults.length < 2) {
this.$store.state.serverConfiguration.defaults.length < 2 &&
key === "name"
) {
continue; continue;
} }
} }
@ -86,7 +82,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[0][key]) { switch (typeof store.state.serverConfiguration?.defaults[0][key]) {
case "boolean": case "boolean":
if (value === "0" || value === "false") { if (value === "0" || value === "false") {
parsedParams[key] = false; parsedParams[key] = false;
@ -105,7 +101,21 @@ export default {
} }
return parsedParams; return parsedParams;
}, };
const defaults = ref<Partial<NetworkFormDefaults>>(
Object.assign(
{},
store.state.serverConfiguration?.defaults[0],
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

@ -3,674 +3,56 @@
<div class="header"> <div class="header">
<SidebarToggle /> <SidebarToggle />
</div> </div>
<form <Navigation />
ref="settingsForm"
class="container"
autocomplete="off"
@change="onChange"
@submit.prevent
>
<h1 class="title">Settings</h1>
<div> <div class="container">
<label class="opt"> <form ref="settingsForm" autocomplete="off" @change="onChange" @submit.prevent>
<input <router-view :settings-form="settingsForm"></router-view>
:checked="$store.state.settings.advanced" </form>
type="checkbox" </div>
name="advanced"
/>
Advanced settings
</label>
</div>
<div v-if="canRegisterProtocol || hasInstallPromptEvent">
<h2>Native app</h2>
<button
v-if="hasInstallPromptEvent"
type="button"
class="btn"
@click.prevent="nativeInstallPrompt"
>
Add The Lounge to Home screen
</button>
<button
v-if="canRegisterProtocol"
type="button"
class="btn"
@click.prevent="registerProtocol"
>
Open irc:// URLs with The Lounge
</button>
</div>
<div v-if="!$store.state.serverConfiguration.public && $store.state.settings.advanced">
<h2>Settings synchronisation</h2>
<label class="opt">
<input
:checked="$store.state.settings.syncSettings"
type="checkbox"
name="syncSettings"
/>
Synchronize settings with other clients
</label>
<template v-if="!$store.state.settings.syncSettings">
<div v-if="$store.state.serverHasSettings" class="settings-sync-panel">
<p>
<strong>Warning:</strong> Checking this box will override the settings
of this client with those stored on the server.
</p>
<p>
Use the button below to enable synchronization, and override any
settings already synced to the server.
</p>
<button type="button" class="btn btn-small" @click="onForceSyncClick">
Sync settings and enable
</button>
</div>
<div v-else class="settings-sync-panel">
<p>
<strong>Warning:</strong> No settings have been synced before. Enabling
this will sync all settings of this client as the base for other
clients.
</p>
</div>
</template>
</div>
<h2>Messages</h2>
<div>
<label class="opt">
<input :checked="$store.state.settings.motd" type="checkbox" name="motd" />
Show <abbr title="Message Of The Day">MOTD</abbr>
</label>
</div>
<div>
<label class="opt">
<input
:checked="$store.state.settings.showSeconds"
type="checkbox"
name="showSeconds"
/>
Include seconds in timestamp
</label>
</div>
<div>
<label class="opt">
<input
:checked="$store.state.settings.use12hClock"
type="checkbox"
name="use12hClock"
/>
Use 12-hour timestamps
</label>
</div>
<div v-if="!$store.state.serverConfiguration.public && $store.state.settings.advanced">
<h2>Automatic away message</h2>
<label class="opt">
<label for="awayMessage" class="sr-only">Automatic away message</label>
<input
id="awayMessage"
:value="$store.state.settings.awayMessage"
type="text"
name="awayMessage"
class="input"
placeholder="Away message if The Lounge is not open"
/>
</label>
</div>
<h2 id="label-status-messages">
Status messages
<span
class="tooltipped tooltipped-n tooltipped-no-delay"
aria-label="Joins, parts, quits, kicks, nick changes, and mode changes"
>
<button class="extra-help" />
</span>
</h2>
<div role="group" aria-labelledby="label-status-messages">
<label class="opt">
<input
:checked="$store.state.settings.statusMessages === 'shown'"
type="radio"
name="statusMessages"
value="shown"
/>
Show all status messages individually
</label>
<label class="opt">
<input
:checked="$store.state.settings.statusMessages === 'condensed'"
type="radio"
name="statusMessages"
value="condensed"
/>
Condense status messages together
</label>
<label class="opt">
<input
:checked="$store.state.settings.statusMessages === 'hidden'"
type="radio"
name="statusMessages"
value="hidden"
/>
Hide all status messages
</label>
</div>
<h2>Visual Aids</h2>
<div>
<label class="opt">
<input
:checked="$store.state.settings.coloredNicks"
type="checkbox"
name="coloredNicks"
/>
Enable colored nicknames
</label>
<label class="opt">
<input
:checked="$store.state.settings.autocomplete"
type="checkbox"
name="autocomplete"
/>
Enable autocomplete
</label>
</div>
<div v-if="$store.state.settings.advanced">
<label class="opt">
<label for="nickPostfix" class="opt">
Nick autocomplete postfix
<span
class="tooltipped tooltipped-n tooltipped-no-delay"
aria-label="Nick autocomplete postfix (for example a comma)"
>
<button class="extra-help" />
</span>
</label>
<input
id="nickPostfix"
:value="$store.state.settings.nickPostfix"
type="text"
name="nickPostfix"
class="input"
placeholder="Nick autocomplete postfix (e.g. ', ')"
/>
</label>
</div>
<h2>Theme</h2>
<div>
<label for="theme-select" class="sr-only">Theme</label>
<select
id="theme-select"
:value="$store.state.settings.theme"
name="theme"
class="input"
>
<option
v-for="theme in $store.state.serverConfiguration.themes"
:key="theme.name"
:value="theme.name"
>
{{ theme.displayName }}
</option>
</select>
</div>
<template v-if="$store.state.serverConfiguration.prefetch">
<h2>Link previews</h2>
<div>
<label class="opt">
<input
:checked="$store.state.settings.media"
type="checkbox"
name="media"
/>
Auto-expand media
</label>
</div>
<div>
<label class="opt">
<input
:checked="$store.state.settings.links"
type="checkbox"
name="links"
/>
Auto-expand websites
</label>
</div>
</template>
<div
v-if="$store.state.settings.advanced && $store.state.serverConfiguration.fileUpload"
>
<h2>File uploads</h2>
<div>
<label class="opt">
<input
:checked="$store.state.settings.uploadCanvas"
type="checkbox"
name="uploadCanvas"
/>
Attempt to remove metadata from images before uploading
<span
class="tooltipped tooltipped-n tooltipped-no-delay"
aria-label="This option renders the image into a canvas element to remove metadata from the image.
This may break orientation if your browser does not support that."
>
<button class="extra-help" />
</span>
</label>
</div>
</div>
<template v-if="!$store.state.serverConfiguration.public">
<h2>Push Notifications</h2>
<div>
<button
id="pushNotifications"
type="button"
class="btn"
:disabled="
$store.state.pushNotificationState !== 'supported' &&
$store.state.pushNotificationState !== 'subscribed'
"
@click="onPushButtonClick"
>
<template v-if="$store.state.pushNotificationState === 'subscribed'">
Unsubscribe from push notifications
</template>
<template v-else-if="$store.state.pushNotificationState === 'loading'">
Loading
</template>
<template v-else> Subscribe to push notifications </template>
</button>
<div v-if="$store.state.pushNotificationState === 'nohttps'" class="error">
<strong>Warning</strong>: Push notifications are only supported over HTTPS
connections.
</div>
<div v-if="$store.state.pushNotificationState === 'unsupported'" class="error">
<strong>Warning</strong>:
<span>Push notifications are not supported by your browser.</span>
<div v-if="isIOS" class="apple-push-unsupported">
Safari does
<a
href="https://bugs.webkit.org/show_bug.cgi?id=182566"
target="_blank"
rel="noopener"
>not support the web push notification specification</a
>, and because all browsers on iOS use Safari under the hood, The Lounge
is unable to provide push notifications on iOS devices.
</div>
</div>
</div>
</template>
<h2>Browser Notifications</h2>
<div>
<label class="opt">
<input
id="desktopNotifications"
:checked="$store.state.settings.desktopNotifications"
:disabled="$store.state.desktopNotificationState === 'nohttps'"
type="checkbox"
name="desktopNotifications"
/>
Enable browser notifications<br />
<div
v-if="$store.state.desktopNotificationState === 'unsupported'"
class="error"
>
<strong>Warning</strong>: Notifications are not supported by your browser.
</div>
<div
v-if="$store.state.desktopNotificationState === 'nohttps'"
id="warnBlockedDesktopNotifications"
class="error"
>
<strong>Warning</strong>: Notifications are only supported over HTTPS
connections.
</div>
<div
v-if="$store.state.desktopNotificationState === 'blocked'"
id="warnBlockedDesktopNotifications"
class="error"
>
<strong>Warning</strong>: Notifications are blocked by your browser.
</div>
</label>
</div>
<div>
<label class="opt">
<input
:checked="$store.state.settings.notification"
type="checkbox"
name="notification"
/>
Enable notification sound
</label>
</div>
<div>
<div class="opt">
<button id="play" @click.prevent="playNotification">Play sound</button>
</div>
</div>
<div v-if="$store.state.settings.advanced">
<label class="opt">
<input
:checked="$store.state.settings.notifyAllMessages"
type="checkbox"
name="notifyAllMessages"
/>
Enable notification for all messages
</label>
</div>
<div v-if="!$store.state.serverConfiguration.public && $store.state.settings.advanced">
<label class="opt">
<label for="highlights" class="opt">
Custom highlights
<span
class="tooltipped tooltipped-n tooltipped-no-delay"
aria-label="If a message contains any of these comma-separated
expressions, it will trigger a highlight."
>
<button class="extra-help" />
</span>
</label>
<input
id="highlights"
:value="$store.state.settings.highlights"
type="text"
name="highlights"
class="input"
autocomplete="off"
placeholder="Comma-separated, e.g.: word, some more words, anotherword"
/>
</label>
</div>
<div v-if="!$store.state.serverConfiguration.public && $store.state.settings.advanced">
<label class="opt">
<label for="highlightExceptions" class="opt">
Highlight exceptions
<span
class="tooltipped tooltipped-n tooltipped-no-delay"
aria-label="If a message contains any of these comma-separated
expressions, it will not trigger a highlight even if it contains
your nickname or expressions defined in custom highlights."
>
<button class="extra-help" />
</span>
</label>
<input
id="highlightExceptions"
:value="$store.state.settings.highlightExceptions"
type="text"
name="highlightExceptions"
class="input"
autocomplete="off"
placeholder="Comma-separated, e.g.: word, some more words, anotherword"
/>
</label>
</div>
<div
v-if="
!$store.state.serverConfiguration.public &&
!$store.state.serverConfiguration.ldapEnabled
"
id="change-password"
role="group"
aria-labelledby="label-change-password"
>
<h2 id="label-change-password">Change password</h2>
<div class="password-container">
<label for="current-password" class="sr-only"> Enter current password </label>
<RevealPassword v-slot:default="slotProps">
<input
id="current-password"
autocomplete="current-password"
:type="slotProps.isVisible ? 'text' : 'password'"
name="old_password"
class="input"
placeholder="Enter current password"
/>
</RevealPassword>
</div>
<div class="password-container">
<label for="new-password" class="sr-only"> Enter desired new password </label>
<RevealPassword v-slot:default="slotProps">
<input
id="new-password"
:type="slotProps.isVisible ? 'text' : 'password'"
name="new_password"
autocomplete="new-password"
class="input"
placeholder="Enter desired new password"
/>
</RevealPassword>
</div>
<div class="password-container">
<label for="new-password-verify" class="sr-only"> Repeat new password </label>
<RevealPassword v-slot:default="slotProps">
<input
id="new-password-verify"
:type="slotProps.isVisible ? 'text' : 'password'"
name="verify_password"
autocomplete="new-password"
class="input"
placeholder="Repeat new password"
/>
</RevealPassword>
</div>
<div
v-if="passwordChangeStatus && passwordChangeStatus.success"
class="feedback success"
>
Successfully updated your password
</div>
<div
v-else-if="passwordChangeStatus && passwordChangeStatus.error"
class="feedback error"
>
{{ passwordErrors[passwordChangeStatus.error] }}
</div>
<div>
<button type="submit" class="btn" @click.prevent="changePassword">
Change password
</button>
</div>
</div>
<div v-if="$store.state.settings.advanced">
<h2>Custom Stylesheet</h2>
<label for="user-specified-css-input" class="sr-only">
Custom stylesheet. You can override any style with CSS here.
</label>
<textarea
id="user-specified-css-input"
:value="$store.state.settings.userStyles"
class="input"
name="userStyles"
placeholder="/* You can override any style with CSS here */"
/>
</div>
<div v-if="!$store.state.serverConfiguration.public" class="session-list" role="group">
<h2>Sessions</h2>
<h3>Current session</h3>
<Session v-if="currentSession" :session="currentSession" />
<template v-if="activeSessions.length > 0">
<h3>Active sessions</h3>
<Session
v-for="session in activeSessions"
:key="session.token"
:session="session"
/>
</template>
<h3>Other sessions</h3>
<p v-if="$store.state.sessions.length === 0">Loading</p>
<p v-else-if="otherSessions.length === 0">
<em>You are not currently logged in to any other device.</em>
</p>
<Session
v-for="session in otherSessions"
v-else
:key="session.token"
:session="session"
/>
</div>
</form>
</div> </div>
</template> </template>
<style> <script lang="ts">
textarea#user-specified-css-input { import {defineComponent, ref} from "vue";
height: 100px;
}
</style>
<script>
import socket from "../../js/socket";
import webpush from "../../js/webpush";
import RevealPassword from "../RevealPassword.vue";
import Session from "../Session.vue";
import SidebarToggle from "../SidebarToggle.vue"; import SidebarToggle from "../SidebarToggle.vue";
import Navigation from "../Settings/Navigation.vue";
import {useStore} from "../../js/store";
let installPromptEvent = null; export default defineComponent({
window.addEventListener("beforeinstallprompt", (e) => {
e.preventDefault();
installPromptEvent = e;
});
export default {
name: "Settings", name: "Settings",
components: { components: {
RevealPassword,
Session,
SidebarToggle, SidebarToggle,
Navigation,
}, },
data() { setup() {
return { const store = useStore();
canRegisterProtocol: false, const settingsForm = ref<HTMLFormElement>();
passwordChangeStatus: null,
passwordErrors: {
missing_fields: "Please enter a new password",
password_mismatch: "Both new password fields must match",
password_incorrect:
"The current password field does not match your account password",
update_failed: "Failed to update your password",
},
isIOS: navigator.platform.match(/(iPhone|iPod|iPad)/i) || false,
};
},
computed: {
hasInstallPromptEvent() {
// TODO: This doesn't hide the button after clicking
return installPromptEvent !== null;
},
currentSession() {
return this.$store.state.sessions.find((item) => item.current);
},
activeSessions() {
return this.$store.state.sessions.filter((item) => !item.current && item.active > 0);
},
otherSessions() {
return this.$store.state.sessions.filter((item) => !item.current && !item.active);
},
},
mounted() {
socket.emit("sessions:get");
// Enable protocol handler registration if supported, const onChange = (event: Event) => {
// and the network configuration is not locked
this.canRegisterProtocol =
window.navigator.registerProtocolHandler &&
!this.$store.state.serverConfiguration.lockNetwork;
},
methods: {
onChange(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});
}, };
changePassword() {
const allFields = new FormData(this.$refs.settingsForm);
const data = {
old_password: allFields.get("old_password"),
new_password: allFields.get("new_password"),
verify_password: allFields.get("verify_password"),
};
if (!data.old_password || !data.new_password || !data.verify_password) { return {
this.passwordChangeStatus = { onChange,
success: false, settingsForm,
error: "missing_fields", };
};
return;
}
if (data.new_password !== data.verify_password) {
this.passwordChangeStatus = {
success: false,
error: "password_mismatch",
};
return;
}
socket.once("change-password", (response) => {
this.passwordChangeStatus = response;
});
socket.emit("change-password", data);
},
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";
window.navigator.registerProtocolHandler("irc", uri, "The Lounge");
window.navigator.registerProtocolHandler("ircs", uri, "The Lounge");
},
nativeInstallPrompt() {
installPromptEvent.prompt();
installPromptEvent = null;
},
playNotification() {
const pop = new Audio();
pop.src = "audio/pop.wav";
pop.play();
},
onPushButtonClick() {
webpush.togglePushSubscription();
},
}, },
}; });
</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

@ -298,6 +298,7 @@ p {
#connect .extra-help, #connect .extra-help,
#settings .extra-help, #settings .extra-help,
#settings #play::before, #settings #play::before,
#settings .settings-menu .icon::before,
#form #upload::before, #form #upload::before,
#form #submit::before, #form #submit::before,
#chat .msg[data-type="away"] .from::before, #chat .msg[data-type="away"] .from::before,
@ -397,7 +398,9 @@ p {
} }
#footer .connect::before { content: "\f067"; /* http://fontawesome.io/icon/plus/ */ } #footer .connect::before { content: "\f067"; /* http://fontawesome.io/icon/plus/ */ }
#footer .settings::before { content: "\f013"; /* http://fontawesome.io/icon/cog/ */ } #footer .settings::before { content: "\f013"; /* http://fontawesome.io/icon/cog/ */ }
#footer .help::before { content: "\f059"; /* http://fontawesome.io/icon/question/ */ } #footer .help::before { content: "\f059"; /* http://fontawesome.io/icon/question/ */ }
#form #upload::before { content: "\f0c6"; /* https://fontawesome.com/icons/paperclip?style=solid */ } #form #upload::before { content: "\f0c6"; /* https://fontawesome.com/icons/paperclip?style=solid */ }
@ -584,6 +587,11 @@ p {
/* End icons */ /* End icons */
#app {
height: 100%;
width: 100%;
}
#viewport { #viewport {
display: flex; display: flex;
height: 100%; height: 100%;
@ -613,7 +621,6 @@ p {
line-height: 1; line-height: 1;
height: 36px; height: 36px;
width: 36px; width: 36px;
margin-top: 6px;
flex-shrink: 0; flex-shrink: 0;
} }
@ -723,7 +730,7 @@ background on hover (unless active) */
background-color: rgb(48 62 74 / 50%); /* #303e4a x 50% alpha */ background-color: rgb(48 62 74 / 50%); /* #303e4a x 50% alpha */
} }
/* Darker background and defualt cursor for active channels */ /* Darker background and default cursor for active channels */
#footer button.active, #footer button.active,
.channel-list-item.active { .channel-list-item.active {
background-color: #303e4a; background-color: #303e4a;
@ -1061,6 +1068,7 @@ textarea.input {
} }
.header { .header {
align-items: center;
line-height: 45px; line-height: 45px;
height: 45px; height: 45px;
padding: 0 6px; padding: 0 6px;
@ -2913,8 +2921,6 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
/* Correctly handle multiple successive whitespace characters. /* Correctly handle multiple successive whitespace characters.
For example: user has quit ( ===> L O L <=== ) */ For example: user has quit ( ===> L O L <=== ) */
.header .topic,
#chat .msg[data-type="action"] .content, #chat .msg[data-type="action"] .content,
#chat .msg[data-type="message"] .content, #chat .msg[data-type="message"] .content,
#chat .msg[data-type="monospace_block"] .content, #chat .msg[data-type="monospace_block"] .content,
@ -2922,11 +2928,15 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
#chat .ctcp-message, #chat .ctcp-message,
#chat .part-reason, #chat .part-reason,
#chat .quit-reason, #chat .quit-reason,
#chat .new-topic, #chat .new-topic {
#chat table.channel-list .topic {
white-space: pre-wrap; white-space: pre-wrap;
} }
#chat table.channel-list .topic,
.header .topic {
white-space: nowrap;
}
.chat-view[data-type="search-results"] .search-status { .chat-view[data-type="search-results"] .search-status {
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,90 +1,92 @@
"use strict"; import constants from "./constants";
const constants = require("./constants");
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
import {Textcomplete, Textarea} from "textcomplete"; import {Strategy, Textcomplete, StrategyProps} from "@textcomplete/core";
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
@ -105,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]))
@ -138,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;
} }
@ -177,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;
@ -192,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;
@ -214,29 +218,21 @@ function enableAutocomplete(input) {
"keydown" "keydown"
); );
const editor = new Textarea(input); const strategies = [
const textcomplete = new Textcomplete(editor, {
dropdown: {
className: "textcomplete-menu",
placement: "top",
},
});
textcomplete.register([
emojiStrategy, emojiStrategy,
nicksStrategy, nicksStrategy,
chanStrategy, chanStrategy,
commandStrategy, commandStrategy,
foregroundColorStrategy, foregroundColorStrategy,
backgroundColorStrategy, backgroundColorStrategy,
]); ];
// Activate the first item by default const editor = new TextareaEditor(input);
// https://github.com/yuku-t/textcomplete/issues/93 const textcomplete = new Textcomplete(editor, strategies, {
textcomplete.on("rendered", () => { dropdown: {
if (textcomplete.dropdown.items.length > 0) { className: "textcomplete-menu",
textcomplete.dropdown.items[0].activate(); placement: "top",
} },
}); });
textcomplete.on("show", () => { textcomplete.on("show", () => {
@ -258,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 + " ";
} }
@ -273,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>",
@ -282,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();
@ -300,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();
@ -321,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,9 +1,9 @@
"use strict";
import storage from "../localStorage"; import storage from "../localStorage";
export default (network, isCollapsed) => { export default (network, isCollapsed) => {
const networks = new Set(JSON.parse(storage.get("thelounge.networks.collapsed"))); const stored = storage.get("thelounge.networks.collapsed");
const networks = stored ? new Set(JSON.parse(stored)) : new Set();
network.isCollapsed = isCollapsed; network.isCollapsed = isCollapsed;
if (isCollapsed) { if (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();
}; };

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