mirror of
https://github.com/thelounge/thelounge
synced 2024-11-26 05:50:22 +00:00
Merge pull request #1 from thelounge/bookworm/multiDefaultNets
Resolves merge conflicts in upstream repo
This commit is contained in:
commit
28552e8d8b
367 changed files with 16971 additions and 11613 deletions
|
@ -1,2 +1,3 @@
|
|||
public/
|
||||
coverage/
|
||||
dist/
|
||||
|
|
191
.eslintrc.cjs
Normal file
191
.eslintrc.cjs
Normal 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,
|
||||
});
|
|
@ -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
|
10
.github/workflows/build.yml
vendored
10
.github/workflows/build.yml
vendored
|
@ -9,10 +9,6 @@ jobs:
|
|||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
# EOL: April 2022
|
||||
- os: ubuntu-latest
|
||||
node_version: 12.x
|
||||
|
||||
# EOL: April 2023
|
||||
- os: ubuntu-latest
|
||||
node_version: 14.x
|
||||
|
@ -25,13 +21,17 @@ jobs:
|
|||
- os: windows-latest
|
||||
node_version: 16.x
|
||||
|
||||
# EOL: April 2025
|
||||
- os: ubuntu-latest
|
||||
node_version: 18.x
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node_version }}
|
||||
|
||||
|
|
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
@ -14,7 +14,7 @@ jobs:
|
|||
- uses: actions/checkout@master
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
registry-url: "https://registry.npmjs.org/"
|
||||
|
||||
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -6,3 +6,4 @@ package-lock.json
|
|||
|
||||
coverage/
|
||||
public/
|
||||
dist/
|
||||
|
|
|
@ -9,9 +9,9 @@
|
|||
# Ignore client folder as it's being built into public/ folder
|
||||
# except for the specified files which are used by the server
|
||||
client/**
|
||||
!client/js/constants.js
|
||||
!client/js/helpers/ircmessageparser/findLinks.js
|
||||
!client/js/helpers/ircmessageparser/cleanIrcMessage.js
|
||||
!client/js/constants.ts
|
||||
!client/js/helpers/ircmessageparser/findLinks.ts
|
||||
!client/js/helpers/ircmessageparser/cleanIrcMessage.ts
|
||||
!client/index.html.tpl
|
||||
|
||||
public/js/bundle.vendor.js.map
|
||||
|
@ -22,3 +22,4 @@ appveyor.yml
|
|||
webpack.config*.js
|
||||
postcss.config.js
|
||||
renovate.json
|
||||
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
coverage/
|
||||
public/
|
||||
dist/
|
||||
test/fixtures/.thelounge/logs/
|
||||
test/fixtures/.thelounge/certificates/
|
||||
test/fixtures/.thelounge/storage/
|
||||
|
||||
test/fixtures/.thelounge/sts-policies.json
|
||||
*.log
|
||||
*.png
|
||||
*.svg
|
||||
|
|
|
@ -1,8 +0,0 @@
|
|||
arrowParens: always
|
||||
bracketSpacing: false
|
||||
printWidth: 100
|
||||
trailingComma: "es5"
|
||||
overrides:
|
||||
- files: "*.webmanifest"
|
||||
options:
|
||||
parser: json
|
|
@ -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
|
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
|
@ -3,7 +3,8 @@
|
|||
"EditorConfig.EditorConfig",
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"octref.vetur"
|
||||
"Vue.volar",
|
||||
"Vue.vscode-typescript-vue-plugin"
|
||||
],
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
|
|
6
.vscode/settings.json
vendored
6
.vscode/settings.json
vendored
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"editor.formatOnSave": true,
|
||||
"vetur.format.enable": false,
|
||||
"prettier.useEditorConfig": true,
|
||||
"prettier.requireConfig": true,
|
||||
"prettier.disableLanguages": [],
|
||||
"prettier.packageManager": "yarn",
|
||||
"eslint.packageManager": "yarn",
|
||||
"eslint.codeActionsOnSave.mode": "all"
|
||||
"eslint.codeActionsOnSave.mode": "all",
|
||||
"[typescript]": {"editor.defaultFormatter": "esbenp.prettier-vscode"},
|
||||
"[vue]": {"editor.defaultFormatter": "esbenp.prettier-vscode"}
|
||||
}
|
||||
|
|
83
CHANGELOG.md
83
CHANGELOG.md
|
@ -4,6 +4,89 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
<!-- 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
|
||||
|
||||
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).
|
||||
|
|
|
@ -51,7 +51,7 @@ The Lounge is the official and community-managed fork of [Shout](https://github.
|
|||
## Installation and usage
|
||||
|
||||
The Lounge requires latest [Node.js](https://nodejs.org/) LTS version or more recent.
|
||||
[Yarn package manager](https://yarnpkg.com/) is also recommended.
|
||||
The [Yarn package manager](https://yarnpkg.com/) is also recommended.
|
||||
If you want to install with npm, `--unsafe-perm` is required for a correct install.
|
||||
|
||||
### Running stable releases
|
||||
|
@ -85,5 +85,8 @@ Before submitting any change, make sure to:
|
|||
- Read the [Contributing instructions](https://github.com/thelounge/thelounge/blob/master/.github/CONTRIBUTING.md#contributing)
|
||||
- Run `yarn test` to execute linters and the test suite
|
||||
- Run `yarn format:prettier` if linting fails
|
||||
- Run `yarn build` if you change or add anything in `client/js` or `client/components`
|
||||
- Run `yarn build:client` if you change or add anything in `client/js` or `client/components`
|
||||
- The built files will be output to `public/` by webpack
|
||||
- Run `yarn build:server` if you change anything in `server/`
|
||||
- The built files will be output to `dist/` by tsc
|
||||
- `yarn dev` can be used to start The Lounge with hot module reloading
|
||||
|
|
4
babel.config.cjs
Normal file
4
babel.config.cjs
Normal file
|
@ -0,0 +1,4 @@
|
|||
module.exports = {
|
||||
presets: [["@babel/preset-env", {bugfixes: true}], "babel-preset-typescript-vue3"],
|
||||
plugins: ["@babel/plugin-transform-runtime"],
|
||||
};
|
|
@ -1,13 +1,13 @@
|
|||
<template>
|
||||
<div id="viewport" :class="viewportClasses" role="tablist">
|
||||
<Sidebar v-if="$store.state.appLoaded" :overlay="$refs.overlay" />
|
||||
<Sidebar v-if="store.state.appLoaded" :overlay="overlay" />
|
||||
<div
|
||||
id="sidebar-overlay"
|
||||
ref="overlay"
|
||||
aria-hidden="true"
|
||||
@click="$store.commit('sidebarOpen', false)"
|
||||
@click="store.commit('sidebarOpen', false)"
|
||||
/>
|
||||
<router-view ref="window"></router-view>
|
||||
<router-view ref="loungeWindow"></router-view>
|
||||
<Mentions />
|
||||
<ImageViewer ref="imageViewer" />
|
||||
<ContextMenu ref="contextMenu" />
|
||||
|
@ -16,10 +16,10 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const constants = require("../js/constants");
|
||||
<script lang="ts">
|
||||
import constants from "../js/constants";
|
||||
import eventbus from "../js/eventbus";
|
||||
import Mousetrap from "mousetrap";
|
||||
import Mousetrap, {ExtendedKeyboardEvent} from "mousetrap";
|
||||
import throttle from "lodash/throttle";
|
||||
import storage from "../js/localStorage";
|
||||
import isIgnoredKeybind from "../js/helpers/isIgnoredKeybind";
|
||||
|
@ -29,8 +29,29 @@ import ImageViewer from "./ImageViewer.vue";
|
|||
import ContextMenu from "./ContextMenu.vue";
|
||||
import ConfirmDialog from "./ConfirmDialog.vue";
|
||||
import Mentions from "./Mentions.vue";
|
||||
import {
|
||||
computed,
|
||||
provide,
|
||||
defineComponent,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
Ref,
|
||||
InjectionKey,
|
||||
inject,
|
||||
} from "vue";
|
||||
import {useStore} from "../js/store";
|
||||
import type {DebouncedFunc} from "lodash";
|
||||
|
||||
export default {
|
||||
export const imageViewerKey = Symbol() as InjectionKey<Ref<typeof ImageViewer | null>>;
|
||||
const contextMenuKey = Symbol() as InjectionKey<Ref<typeof ContextMenu | null>>;
|
||||
const confirmDialogKey = Symbol() as InjectionKey<Ref<typeof ConfirmDialog | null>>;
|
||||
|
||||
export const useImageViewer = () => {
|
||||
return inject(imageViewerKey) as Ref<typeof ImageViewer | null>;
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: "App",
|
||||
components: {
|
||||
Sidebar,
|
||||
|
@ -39,93 +60,78 @@ export default {
|
|||
ConfirmDialog,
|
||||
Mentions,
|
||||
},
|
||||
computed: {
|
||||
viewportClasses() {
|
||||
setup() {
|
||||
const store = useStore();
|
||||
const overlay = ref(null);
|
||||
const loungeWindow = ref(null);
|
||||
const imageViewer = ref(null);
|
||||
const contextMenu = ref(null);
|
||||
const confirmDialog = ref(null);
|
||||
|
||||
provide(imageViewerKey, imageViewer);
|
||||
provide(contextMenuKey, contextMenu);
|
||||
provide(confirmDialogKey, confirmDialog);
|
||||
|
||||
const viewportClasses = computed(() => {
|
||||
return {
|
||||
notified: this.$store.getters.highlightCount > 0,
|
||||
"menu-open": this.$store.state.appLoaded && this.$store.state.sidebarOpen,
|
||||
"menu-dragging": this.$store.state.sidebarDragging,
|
||||
"userlist-open": this.$store.state.userlistOpen,
|
||||
notified: store.getters.highlightCount > 0,
|
||||
"menu-open": store.state.appLoaded && store.state.sidebarOpen,
|
||||
"menu-dragging": store.state.sidebarDragging,
|
||||
"userlist-open": store.state.userlistOpen,
|
||||
};
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.prepareOpenStates();
|
||||
},
|
||||
mounted() {
|
||||
Mousetrap.bind("esc", this.escapeKey);
|
||||
Mousetrap.bind("alt+u", this.toggleUserList);
|
||||
Mousetrap.bind("alt+s", this.toggleSidebar);
|
||||
Mousetrap.bind("alt+m", this.toggleMentions);
|
||||
});
|
||||
|
||||
// Make a single throttled resize listener available to all components
|
||||
this.debouncedResize = throttle(() => {
|
||||
eventbus.emit("resize");
|
||||
}, 100);
|
||||
const debouncedResize = ref<DebouncedFunc<() => void>>();
|
||||
const dayChangeTimeout = ref<any>();
|
||||
|
||||
window.addEventListener("resize", this.debouncedResize, {passive: true});
|
||||
|
||||
// Emit a daychange event every time the day changes so date markers know when to update themselves
|
||||
const emitDayChange = () => {
|
||||
eventbus.emit("daychange");
|
||||
// This should always be 24h later but re-computing exact value just in case
|
||||
this.dayChangeTimeout = setTimeout(emitDayChange, this.msUntilNextDay());
|
||||
};
|
||||
|
||||
this.dayChangeTimeout = setTimeout(emitDayChange, this.msUntilNextDay());
|
||||
},
|
||||
beforeDestroy() {
|
||||
Mousetrap.unbind("esc", this.escapeKey);
|
||||
Mousetrap.unbind("alt+u", this.toggleUserList);
|
||||
Mousetrap.unbind("alt+s", this.toggleSidebar);
|
||||
Mousetrap.unbind("alt+m", this.toggleMentions);
|
||||
|
||||
window.removeEventListener("resize", this.debouncedResize);
|
||||
clearTimeout(this.dayChangeTimeout);
|
||||
},
|
||||
methods: {
|
||||
escapeKey() {
|
||||
const escapeKey = () => {
|
||||
eventbus.emit("escapekey");
|
||||
},
|
||||
toggleSidebar(e) {
|
||||
};
|
||||
|
||||
const toggleSidebar = (e: ExtendedKeyboardEvent) => {
|
||||
if (isIgnoredKeybind(e)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.$store.commit("toggleSidebar");
|
||||
store.commit("toggleSidebar");
|
||||
|
||||
return false;
|
||||
},
|
||||
toggleUserList(e) {
|
||||
};
|
||||
|
||||
const toggleUserList = (e: ExtendedKeyboardEvent) => {
|
||||
if (isIgnoredKeybind(e)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.$store.commit("toggleUserlist");
|
||||
store.commit("toggleUserlist");
|
||||
|
||||
return false;
|
||||
},
|
||||
toggleMentions() {
|
||||
if (this.$store.state.networks.length !== 0) {
|
||||
};
|
||||
|
||||
const toggleMentions = () => {
|
||||
if (store.state.networks.length !== 0) {
|
||||
eventbus.emit("mentions:toggle");
|
||||
}
|
||||
},
|
||||
msUntilNextDay() {
|
||||
};
|
||||
|
||||
const msUntilNextDay = () => {
|
||||
// Compute how many milliseconds are remaining until the next day starts
|
||||
const today = new Date();
|
||||
const tommorow = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1);
|
||||
const tommorow = new Date(
|
||||
today.getFullYear(),
|
||||
today.getMonth(),
|
||||
today.getDate() + 1
|
||||
).getTime();
|
||||
|
||||
return tommorow - today;
|
||||
},
|
||||
prepareOpenStates() {
|
||||
return tommorow - today.getTime();
|
||||
};
|
||||
|
||||
const prepareOpenStates = () => {
|
||||
const viewportWidth = window.innerWidth;
|
||||
let isUserlistOpen = storage.get("thelounge.state.userlist");
|
||||
|
||||
if (viewportWidth > constants.mobileViewportPixels) {
|
||||
this.$store.commit(
|
||||
"sidebarOpen",
|
||||
storage.get("thelounge.state.sidebar") !== "false"
|
||||
);
|
||||
store.commit("sidebarOpen", storage.get("thelounge.state.sidebar") !== "false");
|
||||
}
|
||||
|
||||
// If The Lounge is opened on a small screen (less than 1024px), and we don't have stored
|
||||
|
@ -134,8 +140,61 @@ export default {
|
|||
isUserlistOpen = "true";
|
||||
}
|
||||
|
||||
this.$store.commit("userlistOpen", isUserlistOpen === "true");
|
||||
store.commit("userlistOpen", isUserlistOpen === "true");
|
||||
};
|
||||
|
||||
prepareOpenStates();
|
||||
|
||||
onMounted(() => {
|
||||
Mousetrap.bind("esc", escapeKey);
|
||||
Mousetrap.bind("alt+u", toggleUserList);
|
||||
Mousetrap.bind("alt+s", toggleSidebar);
|
||||
Mousetrap.bind("alt+m", toggleMentions);
|
||||
|
||||
debouncedResize.value = throttle(() => {
|
||||
eventbus.emit("resize");
|
||||
}, 100);
|
||||
|
||||
window.addEventListener("resize", debouncedResize.value, {passive: true});
|
||||
|
||||
// Emit a daychange event every time the day changes so date markers know when to update themselves
|
||||
const emitDayChange = () => {
|
||||
eventbus.emit("daychange");
|
||||
// This should always be 24h later but re-computing exact value just in case
|
||||
dayChangeTimeout.value = setTimeout(emitDayChange, msUntilNextDay());
|
||||
};
|
||||
|
||||
dayChangeTimeout.value = setTimeout(emitDayChange, msUntilNextDay());
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
Mousetrap.unbind("esc");
|
||||
Mousetrap.unbind("alt+u");
|
||||
Mousetrap.unbind("alt+s");
|
||||
Mousetrap.unbind("alt+m");
|
||||
|
||||
if (debouncedResize.value) {
|
||||
window.removeEventListener("resize", debouncedResize.value);
|
||||
}
|
||||
|
||||
if (dayChangeTimeout.value) {
|
||||
clearTimeout(dayChangeTimeout.value);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
viewportClasses,
|
||||
escapeKey,
|
||||
toggleSidebar,
|
||||
toggleUserList,
|
||||
toggleMentions,
|
||||
store,
|
||||
overlay,
|
||||
loungeWindow,
|
||||
imageViewer,
|
||||
contextMenu,
|
||||
confirmDialog,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
<template>
|
||||
<!-- TODO: investigate -->
|
||||
<ChannelWrapper ref="wrapper" v-bind="$props">
|
||||
<span class="name">{{ channel.name }}</span>
|
||||
<span
|
||||
|
@ -27,30 +28,38 @@
|
|||
</ChannelWrapper>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import {PropType, defineComponent, computed} from "vue";
|
||||
import roundBadgeNumber from "../js/helpers/roundBadgeNumber";
|
||||
import useCloseChannel from "../js/hooks/use-close-channel";
|
||||
import {ClientChan, ClientNetwork} from "../js/types";
|
||||
import ChannelWrapper from "./ChannelWrapper.vue";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "Channel",
|
||||
components: {
|
||||
ChannelWrapper,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
channel: Object,
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
channel: {
|
||||
type: Object as PropType<ClientChan>,
|
||||
required: true,
|
||||
},
|
||||
active: Boolean,
|
||||
isFiltering: Boolean,
|
||||
},
|
||||
computed: {
|
||||
unreadCount() {
|
||||
return roundBadgeNumber(this.channel.unread);
|
||||
setup(props) {
|
||||
const unreadCount = computed(() => roundBadgeNumber(props.channel.unread));
|
||||
const close = useCloseChannel(props.channel);
|
||||
|
||||
return {
|
||||
unreadCount,
|
||||
close,
|
||||
};
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
close() {
|
||||
this.$root.closeChannel(this.channel);
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -23,72 +23,90 @@
|
|||
:data-type="channel.type"
|
||||
:aria-controls="'#chan-' + channel.id"
|
||||
:aria-selected="active"
|
||||
:style="channel.closed ? {transition: 'none', opacity: 0.4} : null"
|
||||
:style="channel.closed ? {transition: 'none', opacity: 0.4} : undefined"
|
||||
role="tab"
|
||||
@click="click"
|
||||
@contextmenu.prevent="openContextMenu"
|
||||
>
|
||||
<slot :network="network" :channel="channel" :activeChannel="activeChannel" />
|
||||
<slot :network="network" :channel="channel" :active-channel="activeChannel" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import eventbus from "../js/eventbus";
|
||||
import isChannelCollapsed from "../js/helpers/isChannelCollapsed";
|
||||
import {ClientNetwork, ClientChan} from "../js/types";
|
||||
import {computed, defineComponent, PropType} from "vue";
|
||||
import {useStore} from "../js/store";
|
||||
import {switchToChannel} from "../js/router";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "ChannelWrapper",
|
||||
props: {
|
||||
network: Object,
|
||||
channel: Object,
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
channel: {
|
||||
type: Object as PropType<ClientChan>,
|
||||
required: true,
|
||||
},
|
||||
active: Boolean,
|
||||
isFiltering: Boolean,
|
||||
},
|
||||
computed: {
|
||||
activeChannel() {
|
||||
return this.$store.state.activeChannel;
|
||||
},
|
||||
isChannelVisible() {
|
||||
return this.isFiltering || !isChannelCollapsed(this.network, this.channel);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getAriaLabel() {
|
||||
const extra = [];
|
||||
const type = this.channel.type;
|
||||
setup(props) {
|
||||
const store = useStore();
|
||||
const activeChannel = computed(() => store.state.activeChannel);
|
||||
const isChannelVisible = computed(
|
||||
() => props.isFiltering || !isChannelCollapsed(props.network, props.channel)
|
||||
);
|
||||
|
||||
if (this.channel.unread > 0) {
|
||||
if (this.channel.unread > 1) {
|
||||
extra.push(`${this.channel.unread} unread messages`);
|
||||
const getAriaLabel = () => {
|
||||
const extra: string[] = [];
|
||||
const type = props.channel.type;
|
||||
|
||||
if (props.channel.unread > 0) {
|
||||
if (props.channel.unread > 1) {
|
||||
extra.push(`${props.channel.unread} unread messages`);
|
||||
} else {
|
||||
extra.push(`${this.channel.unread} unread message`);
|
||||
extra.push(`${props.channel.unread} unread message`);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.channel.highlight > 0) {
|
||||
if (this.channel.highlight > 1) {
|
||||
extra.push(`${this.channel.highlight} mentions`);
|
||||
if (props.channel.highlight > 0) {
|
||||
if (props.channel.highlight > 1) {
|
||||
extra.push(`${props.channel.highlight} mentions`);
|
||||
} else {
|
||||
extra.push(`${this.channel.highlight} mention`);
|
||||
extra.push(`${props.channel.highlight} mention`);
|
||||
}
|
||||
}
|
||||
|
||||
return `${type}: ${this.channel.name} ${extra.length ? `(${extra.join(", ")})` : ""}`;
|
||||
},
|
||||
click() {
|
||||
if (this.isFiltering) {
|
||||
return `${type}: ${props.channel.name} ${extra.length ? `(${extra.join(", ")})` : ""}`;
|
||||
};
|
||||
|
||||
const click = () => {
|
||||
if (props.isFiltering) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$root.switchToChannel(this.channel);
|
||||
},
|
||||
openContextMenu(event) {
|
||||
switchToChannel(props.channel);
|
||||
};
|
||||
|
||||
const openContextMenu = (event: MouseEvent) => {
|
||||
eventbus.emit("contextmenu:channel", {
|
||||
event: event,
|
||||
channel: this.channel,
|
||||
network: this.network,
|
||||
channel: props.channel,
|
||||
network: props.network,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
activeChannel,
|
||||
isChannelVisible,
|
||||
getAriaLabel,
|
||||
click,
|
||||
openContextMenu,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -3,10 +3,10 @@
|
|||
<div
|
||||
id="chat"
|
||||
:class="{
|
||||
'hide-motd': !$store.state.settings.motd,
|
||||
'colored-nicks': $store.state.settings.coloredNicks,
|
||||
'time-seconds': $store.state.settings.showSeconds,
|
||||
'time-12h': $store.state.settings.use12hClock,
|
||||
'hide-motd': store.state.settings.motd,
|
||||
'colored-nicks': store.state.settings.coloredNicks,
|
||||
'time-seconds': store.state.settings.showSeconds,
|
||||
'time-12h': store.state.settings.use12hClock,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
|
@ -47,7 +47,7 @@
|
|||
/></span>
|
||||
<MessageSearchForm
|
||||
v-if="
|
||||
$store.state.settings.searchEnabled &&
|
||||
store.state.settings.searchEnabled &&
|
||||
['channel', 'query'].includes(channel.type)
|
||||
"
|
||||
:network="network"
|
||||
|
@ -71,7 +71,7 @@
|
|||
<button
|
||||
class="rt"
|
||||
aria-label="Toggle user list"
|
||||
@click="$store.commit('toggleUserlist')"
|
||||
@click="store.commit('toggleUserlist')"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
@ -95,7 +95,7 @@
|
|||
{'scroll-down-shown': !channel.scrolledToBottom},
|
||||
]"
|
||||
aria-label="Jump to recent messages"
|
||||
@click="$refs.messageList.jumpToBottom()"
|
||||
@click="messageList?.jumpToBottom()"
|
||||
>
|
||||
<div class="scroll-down-arrow" />
|
||||
</div>
|
||||
|
@ -110,17 +110,17 @@
|
|||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="$store.state.currentUserVisibleError"
|
||||
v-if="store.state.currentUserVisibleError"
|
||||
id="user-visible-error"
|
||||
@click="hideUserVisibleError"
|
||||
>
|
||||
{{ $store.state.currentUserVisibleError }}
|
||||
{{ store.state.currentUserVisibleError }}
|
||||
</div>
|
||||
<ChatInput :network="network" :channel="channel" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import socket from "../js/socket";
|
||||
import eventbus from "../js/eventbus";
|
||||
import ParsedMessage from "./ParsedMessage.vue";
|
||||
|
@ -133,8 +133,11 @@ import ListBans from "./Special/ListBans.vue";
|
|||
import ListInvites from "./Special/ListInvites.vue";
|
||||
import ListChannels from "./Special/ListChannels.vue";
|
||||
import ListIgnored from "./Special/ListIgnored.vue";
|
||||
import {defineComponent, PropType, ref, computed, watch, nextTick, onMounted, Component} from "vue";
|
||||
import type {ClientNetwork, ClientChan} from "../js/types";
|
||||
import {useStore} from "../js/store";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "Chat",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
|
@ -145,93 +148,126 @@ export default {
|
|||
MessageSearchForm,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
channel: Object,
|
||||
focused: String,
|
||||
network: {type: Object as PropType<ClientNetwork>, required: true},
|
||||
channel: {type: Object as PropType<ClientChan>, required: true},
|
||||
focused: Number,
|
||||
},
|
||||
computed: {
|
||||
specialComponent() {
|
||||
switch (this.channel.special) {
|
||||
emits: ["channel-changed"],
|
||||
setup(props, {emit}) {
|
||||
const store = useStore();
|
||||
|
||||
const messageList = ref<typeof MessageList>();
|
||||
const topicInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const specialComponent = computed(() => {
|
||||
switch (props.channel.special) {
|
||||
case "list_bans":
|
||||
return ListBans;
|
||||
return ListBans as Component;
|
||||
case "list_invites":
|
||||
return ListInvites;
|
||||
return ListInvites as Component;
|
||||
case "list_channels":
|
||||
return ListChannels;
|
||||
return ListChannels as Component;
|
||||
case "list_ignored":
|
||||
return ListIgnored;
|
||||
return ListIgnored as Component;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
channel() {
|
||||
this.channelChanged();
|
||||
},
|
||||
"channel.editTopic"(newValue) {
|
||||
if (newValue) {
|
||||
this.$nextTick(() => {
|
||||
this.$refs.topicInput.focus();
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.channelChanged();
|
||||
|
||||
if (this.channel.editTopic) {
|
||||
this.$nextTick(() => {
|
||||
this.$refs.topicInput.focus();
|
||||
});
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
channelChanged() {
|
||||
const channelChanged = () => {
|
||||
// Triggered when active channel is set or changed
|
||||
this.channel.highlight = 0;
|
||||
this.channel.unread = 0;
|
||||
emit("channel-changed", props.channel);
|
||||
|
||||
socket.emit("open", this.channel.id);
|
||||
socket.emit("open", props.channel.id);
|
||||
|
||||
if (this.channel.usersOutdated) {
|
||||
this.channel.usersOutdated = false;
|
||||
if (props.channel.usersOutdated) {
|
||||
props.channel.usersOutdated = false;
|
||||
|
||||
socket.emit("names", {
|
||||
target: this.channel.id,
|
||||
target: props.channel.id,
|
||||
});
|
||||
}
|
||||
},
|
||||
hideUserVisibleError() {
|
||||
this.$store.commit("currentUserVisibleError", null);
|
||||
},
|
||||
editTopic() {
|
||||
if (this.channel.type === "channel") {
|
||||
this.channel.editTopic = true;
|
||||
}
|
||||
},
|
||||
saveTopic() {
|
||||
this.channel.editTopic = false;
|
||||
const newTopic = this.$refs.topicInput.value;
|
||||
};
|
||||
|
||||
if (this.channel.topic !== newTopic) {
|
||||
const target = this.channel.id;
|
||||
const text = `/raw TOPIC ${this.channel.name} :${newTopic}`;
|
||||
const hideUserVisibleError = () => {
|
||||
store.commit("currentUserVisibleError", null);
|
||||
};
|
||||
|
||||
const editTopic = () => {
|
||||
if (props.channel.type === "channel") {
|
||||
props.channel.editTopic = true;
|
||||
}
|
||||
};
|
||||
|
||||
const saveTopic = () => {
|
||||
props.channel.editTopic = false;
|
||||
|
||||
if (!topicInput.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newTopic = topicInput.value.value;
|
||||
|
||||
if (props.channel.topic !== newTopic) {
|
||||
const target = props.channel.id;
|
||||
const text = `/raw TOPIC ${props.channel.name} :${newTopic}`;
|
||||
socket.emit("input", {target, text});
|
||||
}
|
||||
},
|
||||
openContextMenu(event) {
|
||||
};
|
||||
|
||||
const openContextMenu = (event: any) => {
|
||||
eventbus.emit("contextmenu:channel", {
|
||||
event: event,
|
||||
channel: this.channel,
|
||||
network: this.network,
|
||||
channel: props.channel,
|
||||
network: props.network,
|
||||
});
|
||||
},
|
||||
openMentions(event) {
|
||||
};
|
||||
|
||||
const openMentions = (event: any) => {
|
||||
eventbus.emit("mentions:toggle", {
|
||||
event: event,
|
||||
});
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.channel,
|
||||
() => {
|
||||
channelChanged();
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.channel.editTopic,
|
||||
(newTopic) => {
|
||||
if (newTopic) {
|
||||
void nextTick(() => {
|
||||
topicInput.value?.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
channelChanged();
|
||||
|
||||
if (props.channel.editTopic) {
|
||||
void nextTick(() => {
|
||||
topicInput.value?.focus();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
store,
|
||||
messageList,
|
||||
topicInput,
|
||||
specialComponent,
|
||||
hideUserVisibleError,
|
||||
editTopic,
|
||||
saveTopic,
|
||||
openContextMenu,
|
||||
openMentions,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -13,9 +13,10 @@
|
|||
:aria-label="getInputPlaceholder(channel)"
|
||||
@input="setPendingMessage"
|
||||
@keypress.enter.exact.prevent="onSubmit"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
<span
|
||||
v-if="$store.state.serverConfiguration.fileUpload"
|
||||
v-if="store.state.serverConfiguration?.fileUpload"
|
||||
id="upload-tooltip"
|
||||
class="tooltipped tooltipped-w tooltipped-no-touch"
|
||||
aria-label="Upload file"
|
||||
|
@ -33,7 +34,7 @@
|
|||
id="upload"
|
||||
type="button"
|
||||
aria-label="Upload file"
|
||||
:disabled="!$store.state.isConnected"
|
||||
:disabled="!store.state.isConnected"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
|
@ -45,13 +46,13 @@
|
|||
id="submit"
|
||||
type="submit"
|
||||
aria-label="Send message"
|
||||
:disabled="!$store.state.isConnected"
|
||||
:disabled="!store.state.isConnected"
|
||||
/>
|
||||
</span>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import Mousetrap from "mousetrap";
|
||||
import {wrapCursor} from "undate";
|
||||
import autocompletion from "../js/autocompletion";
|
||||
|
@ -59,6 +60,9 @@ import commands from "../js/commands/index";
|
|||
import socket from "../js/socket";
|
||||
import upload from "../js/upload";
|
||||
import eventbus from "../js/eventbus";
|
||||
import {watch, defineComponent, nextTick, onMounted, PropType, ref, onUnmounted} from "vue";
|
||||
import type {ClientNetwork, ClientChan} from "../js/types";
|
||||
import {useStore} from "../js/store";
|
||||
|
||||
const formattingHotkeys = {
|
||||
"mod+k": "\x03",
|
||||
|
@ -85,48 +89,191 @@ const bracketWraps = {
|
|||
_: "_",
|
||||
};
|
||||
|
||||
let autocompletionRef = null;
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "ChatInput",
|
||||
props: {
|
||||
network: Object,
|
||||
channel: Object,
|
||||
network: {type: Object as PropType<ClientNetwork>, required: true},
|
||||
channel: {type: Object as PropType<ClientChan>, required: true},
|
||||
},
|
||||
watch: {
|
||||
"channel.id"() {
|
||||
if (autocompletionRef) {
|
||||
autocompletionRef.hide();
|
||||
}
|
||||
},
|
||||
"channel.pendingMessage"() {
|
||||
this.setInputSize();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
eventbus.on("escapekey", this.blurInput);
|
||||
setup(props) {
|
||||
const store = useStore();
|
||||
const input = ref<HTMLTextAreaElement>();
|
||||
const uploadInput = ref<HTMLInputElement>();
|
||||
const autocompletionRef = ref<ReturnType<typeof autocompletion>>();
|
||||
|
||||
if (this.$store.state.settings.autocomplete) {
|
||||
autocompletionRef = autocompletion(this.$refs.input);
|
||||
const setInputSize = () => {
|
||||
void nextTick(() => {
|
||||
if (!input.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const inputTrap = Mousetrap(this.$refs.input);
|
||||
const style = window.getComputedStyle(input.value);
|
||||
const lineHeight = parseFloat(style.lineHeight) || 1;
|
||||
|
||||
// Start by resetting height before computing as scrollHeight does not
|
||||
// decrease when deleting characters
|
||||
input.value.style.height = "";
|
||||
|
||||
// Use scrollHeight to calculate how many lines there are in input, and ceil the value
|
||||
// because some browsers tend to incorrently round the values when using high density
|
||||
// displays or using page zoom feature
|
||||
input.value.style.height = `${
|
||||
Math.ceil(input.value.scrollHeight / lineHeight) * lineHeight
|
||||
}px`;
|
||||
});
|
||||
};
|
||||
|
||||
const setPendingMessage = (e: Event) => {
|
||||
props.channel.pendingMessage = (e.target as HTMLInputElement).value;
|
||||
props.channel.inputHistoryPosition = 0;
|
||||
setInputSize();
|
||||
};
|
||||
|
||||
const getInputPlaceholder = (channel: ClientChan) => {
|
||||
if (channel.type === "channel" || channel.type === "query") {
|
||||
return `Write to ${channel.name}`;
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
const onSubmit = () => {
|
||||
if (!input.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Triggering click event opens the virtual keyboard on mobile
|
||||
// This can only be called from another interactive event (e.g. button click)
|
||||
input.value.click();
|
||||
input.value.focus();
|
||||
|
||||
if (!store.state.isConnected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const target = props.channel.id;
|
||||
const text = props.channel.pendingMessage;
|
||||
|
||||
if (text.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (autocompletionRef.value) {
|
||||
autocompletionRef.value.hide();
|
||||
}
|
||||
|
||||
props.channel.inputHistoryPosition = 0;
|
||||
props.channel.pendingMessage = "";
|
||||
input.value.value = "";
|
||||
setInputSize();
|
||||
|
||||
// Store new message in history if last message isn't already equal
|
||||
if (props.channel.inputHistory[1] !== text) {
|
||||
props.channel.inputHistory.splice(1, 0, text);
|
||||
}
|
||||
|
||||
// Limit input history to a 100 entries
|
||||
if (props.channel.inputHistory.length > 100) {
|
||||
props.channel.inputHistory.pop();
|
||||
}
|
||||
|
||||
if (text[0] === "/") {
|
||||
const args = text.substring(1).split(" ");
|
||||
const cmd = args.shift()?.toLowerCase();
|
||||
|
||||
if (!cmd) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(commands, cmd) &&
|
||||
commands[cmd].input(args)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
socket.emit("input", {target, text});
|
||||
};
|
||||
|
||||
const onUploadInputChange = () => {
|
||||
if (!uploadInput.value || !uploadInput.value.files) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = Array.from(uploadInput.value.files);
|
||||
upload.triggerUpload(files);
|
||||
uploadInput.value.value = ""; // Reset <input> element so you can upload the same file
|
||||
};
|
||||
|
||||
const openFileUpload = () => {
|
||||
uploadInput.value?.click();
|
||||
};
|
||||
|
||||
const blurInput = () => {
|
||||
input.value?.blur();
|
||||
};
|
||||
|
||||
const onBlur = () => {
|
||||
if (autocompletionRef.value) {
|
||||
autocompletionRef.value.hide();
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.channel.id,
|
||||
() => {
|
||||
if (autocompletionRef.value) {
|
||||
autocompletionRef.value.hide();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.channel.pendingMessage,
|
||||
() => {
|
||||
setInputSize();
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
eventbus.on("escapekey", blurInput);
|
||||
|
||||
if (store.state.settings.autocomplete) {
|
||||
if (!input.value) {
|
||||
throw new Error("ChatInput autocomplete: input element is not available");
|
||||
}
|
||||
|
||||
autocompletionRef.value = autocompletion(input.value);
|
||||
}
|
||||
|
||||
const inputTrap = Mousetrap(input.value);
|
||||
|
||||
inputTrap.bind(Object.keys(formattingHotkeys), function (e, key) {
|
||||
const modifier = formattingHotkeys[key];
|
||||
|
||||
if (!e.target) {
|
||||
return;
|
||||
}
|
||||
|
||||
wrapCursor(
|
||||
e.target,
|
||||
e.target as HTMLTextAreaElement,
|
||||
modifier,
|
||||
e.target.selectionStart === e.target.selectionEnd ? "" : 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.selectionStart !== e.target.selectionEnd) {
|
||||
wrapCursor(e.target, key, bracketWraps[key]);
|
||||
if (
|
||||
(e.target as HTMLTextAreaElement)?.selectionStart !==
|
||||
(e.target as HTMLTextAreaElement).selectionEnd
|
||||
) {
|
||||
wrapCursor(e.target as HTMLTextAreaElement, key, bracketWraps[key]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
@ -134,19 +281,21 @@ export default {
|
|||
|
||||
inputTrap.bind(["up", "down"], (e, key) => {
|
||||
if (
|
||||
this.$store.state.isAutoCompleting ||
|
||||
e.target.selectionStart !== e.target.selectionEnd
|
||||
store.state.isAutoCompleting ||
|
||||
(e.target as HTMLTextAreaElement).selectionStart !==
|
||||
(e.target as HTMLTextAreaElement).selectionEnd ||
|
||||
!input.value
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onRow = (
|
||||
this.$refs.input.value.slice(null, this.$refs.input.selectionStart).match(/\n/g) ||
|
||||
input.value.value.slice(undefined, input.value.selectionStart).match(/\n/g) ||
|
||||
[]
|
||||
).length;
|
||||
const totalRows = (this.$refs.input.value.match(/\n/g) || []).length;
|
||||
const totalRows = (input.value.value.match(/\n/g) || []).length;
|
||||
|
||||
const {channel} = this;
|
||||
const {channel} = props;
|
||||
|
||||
if (channel.inputHistoryPosition === 0) {
|
||||
channel.inputHistory[channel.inputHistoryPosition] = channel.pendingMessage;
|
||||
|
@ -158,127 +307,53 @@ export default {
|
|||
} else {
|
||||
return;
|
||||
}
|
||||
} else if (key === "down" && channel.inputHistoryPosition > 0 && onRow === totalRows) {
|
||||
} 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();
|
||||
input.value.value = channel.pendingMessage;
|
||||
setInputSize();
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
if (this.$store.state.serverConfiguration.fileUpload) {
|
||||
if (store.state.serverConfiguration?.fileUpload) {
|
||||
upload.mounted();
|
||||
}
|
||||
},
|
||||
destroyed() {
|
||||
eventbus.off("escapekey", this.blurInput);
|
||||
});
|
||||
|
||||
if (autocompletionRef) {
|
||||
autocompletionRef.destroy();
|
||||
autocompletionRef = null;
|
||||
onUnmounted(() => {
|
||||
eventbus.off("escapekey", blurInput);
|
||||
|
||||
if (autocompletionRef.value) {
|
||||
autocompletionRef.value.destroy();
|
||||
autocompletionRef.value = undefined;
|
||||
}
|
||||
|
||||
upload.abort();
|
||||
},
|
||||
methods: {
|
||||
setPendingMessage(e) {
|
||||
this.channel.pendingMessage = e.target.value;
|
||||
this.channel.inputHistoryPosition = 0;
|
||||
this.setInputSize();
|
||||
},
|
||||
setInputSize() {
|
||||
this.$nextTick(() => {
|
||||
if (!this.$refs.input) {
|
||||
return;
|
||||
}
|
||||
|
||||
const style = window.getComputedStyle(this.$refs.input);
|
||||
const lineHeight = parseFloat(style.lineHeight, 10) || 1;
|
||||
|
||||
// Start by resetting height before computing as scrollHeight does not
|
||||
// decrease when deleting characters
|
||||
this.$refs.input.style.height = "";
|
||||
|
||||
// Use scrollHeight to calculate how many lines there are in input, and ceil the value
|
||||
// because some browsers tend to incorrently round the values when using high density
|
||||
// displays or using page zoom feature
|
||||
this.$refs.input.style.height =
|
||||
Math.ceil(this.$refs.input.scrollHeight / lineHeight) * lineHeight + "px";
|
||||
});
|
||||
|
||||
return {
|
||||
store,
|
||||
input,
|
||||
uploadInput,
|
||||
onUploadInputChange,
|
||||
openFileUpload,
|
||||
blurInput,
|
||||
onBlur,
|
||||
setInputSize,
|
||||
upload,
|
||||
getInputPlaceholder,
|
||||
onSubmit,
|
||||
setPendingMessage,
|
||||
};
|
||||
},
|
||||
getInputPlaceholder(channel) {
|
||||
if (channel.type === "channel" || channel.type === "query") {
|
||||
return `Write to ${channel.name}`;
|
||||
}
|
||||
|
||||
return "";
|
||||
},
|
||||
onSubmit() {
|
||||
// Triggering click event opens the virtual keyboard on mobile
|
||||
// This can only be called from another interactive event (e.g. button click)
|
||||
this.$refs.input.click();
|
||||
this.$refs.input.focus();
|
||||
|
||||
if (!this.$store.state.isConnected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const target = this.channel.id;
|
||||
const text = this.channel.pendingMessage;
|
||||
|
||||
if (text.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (autocompletionRef) {
|
||||
autocompletionRef.hide();
|
||||
}
|
||||
|
||||
this.channel.inputHistoryPosition = 0;
|
||||
this.channel.pendingMessage = "";
|
||||
this.$refs.input.value = "";
|
||||
this.setInputSize();
|
||||
|
||||
// Store new message in history if last message isn't already equal
|
||||
if (this.channel.inputHistory[1] !== text) {
|
||||
this.channel.inputHistory.splice(1, 0, text);
|
||||
}
|
||||
|
||||
// Limit input history to a 100 entries
|
||||
if (this.channel.inputHistory.length > 100) {
|
||||
this.channel.inputHistory.pop();
|
||||
}
|
||||
|
||||
if (text[0] === "/") {
|
||||
const args = text.substr(1).split(" ");
|
||||
const cmd = args.shift().toLowerCase();
|
||||
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(commands, cmd) &&
|
||||
commands[cmd].input(args)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
socket.emit("input", {target, text});
|
||||
},
|
||||
onUploadInputChange() {
|
||||
const files = Array.from(this.$refs.uploadInput.files);
|
||||
upload.triggerUpload(files);
|
||||
this.$refs.uploadInput.value = ""; // Reset <input> element so you can upload the same file
|
||||
},
|
||||
openFileUpload() {
|
||||
this.$refs.uploadInput.click();
|
||||
},
|
||||
blurInput() {
|
||||
this.$refs.input.blur();
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -28,9 +28,10 @@
|
|||
<div
|
||||
v-for="(users, mode) in groupedUsers"
|
||||
:key="mode"
|
||||
:class="['user-mode', getModeClass(mode)]"
|
||||
:class="['user-mode', getModeClass(String(mode))]"
|
||||
>
|
||||
<template v-if="userSearchInput.length > 0">
|
||||
<!-- eslint-disable vue/no-v-text-v-html-on-component -->
|
||||
<Username
|
||||
v-for="user in users"
|
||||
:key="user.original.nick + '-search'"
|
||||
|
@ -39,6 +40,7 @@
|
|||
:user="user.original"
|
||||
v-html="user.string"
|
||||
/>
|
||||
<!-- eslint-enable -->
|
||||
</template>
|
||||
<template v-else>
|
||||
<Username
|
||||
|
@ -54,8 +56,11 @@
|
|||
</aside>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import {filter as fuzzyFilter} from "fuzzy";
|
||||
import {computed, defineComponent, nextTick, PropType, ref} from "vue";
|
||||
import type {UserInMessage} from "../../server/models/msg";
|
||||
import type {ClientChan, ClientUser} from "../js/types";
|
||||
import Username from "./Username.vue";
|
||||
|
||||
const modes = {
|
||||
|
@ -68,39 +73,35 @@ const modes = {
|
|||
"": "normal",
|
||||
};
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "ChatUserList",
|
||||
components: {
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
channel: Object,
|
||||
channel: {type: Object as PropType<ClientChan>, required: true},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
userSearchInput: "",
|
||||
activeUser: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
// filteredUsers is computed, to avoid unnecessary filtering
|
||||
// as it is shared between filtering and keybindings.
|
||||
filteredUsers() {
|
||||
if (!this.userSearchInput) {
|
||||
setup(props) {
|
||||
const userSearchInput = ref("");
|
||||
const activeUser = ref<UserInMessage | null>();
|
||||
const userlist = ref<HTMLDivElement>();
|
||||
const filteredUsers = computed(() => {
|
||||
if (!userSearchInput.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
return fuzzyFilter(this.userSearchInput, this.channel.users, {
|
||||
return fuzzyFilter(userSearchInput.value, props.channel.users, {
|
||||
pre: "<b>",
|
||||
post: "</b>",
|
||||
extract: (u) => u.nick,
|
||||
});
|
||||
},
|
||||
groupedUsers() {
|
||||
});
|
||||
|
||||
const groupedUsers = computed(() => {
|
||||
const groups = {};
|
||||
|
||||
if (this.userSearchInput) {
|
||||
const result = this.filteredUsers;
|
||||
if (userSearchInput.value && filteredUsers.value) {
|
||||
const result = filteredUsers.value;
|
||||
|
||||
for (const user of result) {
|
||||
const mode = user.original.modes[0] || "";
|
||||
|
@ -115,7 +116,7 @@ export default {
|
|||
groups[mode].push(user);
|
||||
}
|
||||
} else {
|
||||
for (const user of this.channel.users) {
|
||||
for (const user of props.channel.users) {
|
||||
const mode = user.modes[0] || "";
|
||||
|
||||
if (!groups[mode]) {
|
||||
|
@ -126,24 +127,35 @@ export default {
|
|||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
setUserSearchInput(e) {
|
||||
this.userSearchInput = e.target.value;
|
||||
},
|
||||
getModeClass(mode) {
|
||||
return modes[mode];
|
||||
},
|
||||
selectUser() {
|
||||
return groups as {
|
||||
[mode: string]: (ClientUser & {
|
||||
original: UserInMessage;
|
||||
string: string;
|
||||
})[];
|
||||
};
|
||||
});
|
||||
|
||||
const setUserSearchInput = (e: Event) => {
|
||||
userSearchInput.value = (e.target as HTMLInputElement).value;
|
||||
};
|
||||
|
||||
const getModeClass = (mode: string) => {
|
||||
return modes[mode] as typeof modes;
|
||||
};
|
||||
|
||||
const selectUser = () => {
|
||||
// Simulate a click on the active user to open the context menu.
|
||||
// Coordinates are provided to position the menu correctly.
|
||||
if (!this.activeUser) {
|
||||
if (!activeUser.value || !userlist.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const el = userlist.value.querySelector(".active");
|
||||
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
const el = this.$refs.userlist.querySelector(".active");
|
||||
const rect = el.getBoundingClientRect();
|
||||
const ev = new MouseEvent("click", {
|
||||
view: window,
|
||||
|
@ -153,38 +165,58 @@ export default {
|
|||
clientY: rect.top + rect.height,
|
||||
});
|
||||
el.dispatchEvent(ev);
|
||||
},
|
||||
hoverUser(user) {
|
||||
this.activeUser = user;
|
||||
},
|
||||
removeHoverUser() {
|
||||
this.activeUser = null;
|
||||
},
|
||||
navigateUserList(event, direction) {
|
||||
};
|
||||
|
||||
const hoverUser = (user: UserInMessage) => {
|
||||
activeUser.value = user;
|
||||
};
|
||||
|
||||
const removeHoverUser = () => {
|
||||
activeUser.value = null;
|
||||
};
|
||||
|
||||
const scrollToActiveUser = () => {
|
||||
// Scroll the list if needed after the active class is applied
|
||||
void nextTick(() => {
|
||||
const el = userlist.value?.querySelector(".active");
|
||||
el?.scrollIntoView({block: "nearest", inline: "nearest"});
|
||||
});
|
||||
};
|
||||
|
||||
const navigateUserList = (event: Event, direction: number) => {
|
||||
// Prevent propagation to stop global keybind handler from capturing pagedown/pageup
|
||||
// and redirecting it to the message list container for scrolling
|
||||
event.stopImmediatePropagation();
|
||||
event.preventDefault();
|
||||
|
||||
let users = this.channel.users;
|
||||
let users = props.channel.users;
|
||||
|
||||
// Only using filteredUsers when we have to avoids filtering when it's not needed
|
||||
if (this.userSearchInput) {
|
||||
users = this.filteredUsers.map((result) => result.original);
|
||||
if (userSearchInput.value && filteredUsers.value) {
|
||||
users = filteredUsers.value.map((result) => result.original);
|
||||
}
|
||||
|
||||
// Bail out if there's no users to select
|
||||
if (!users.length) {
|
||||
this.activeUser = null;
|
||||
activeUser.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let currentIndex = users.indexOf(this.activeUser);
|
||||
const abort = () => {
|
||||
activeUser.value = direction ? users[0] : users[users.length - 1];
|
||||
scrollToActiveUser();
|
||||
};
|
||||
|
||||
// If there's no active user select the first or last one depending on direction
|
||||
if (!this.activeUser || currentIndex === -1) {
|
||||
this.activeUser = direction ? users[0] : users[users.length - 1];
|
||||
this.scrollToActiveUser();
|
||||
if (!activeUser.value) {
|
||||
abort();
|
||||
return;
|
||||
}
|
||||
|
||||
let currentIndex = users.indexOf(activeUser.value as ClientUser);
|
||||
|
||||
if (currentIndex === -1) {
|
||||
abort();
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -200,16 +232,24 @@ export default {
|
|||
currentIndex -= users.length;
|
||||
}
|
||||
|
||||
this.activeUser = users[currentIndex];
|
||||
this.scrollToActiveUser();
|
||||
activeUser.value = users[currentIndex];
|
||||
scrollToActiveUser();
|
||||
};
|
||||
|
||||
return {
|
||||
filteredUsers,
|
||||
groupedUsers,
|
||||
userSearchInput,
|
||||
activeUser,
|
||||
userlist,
|
||||
|
||||
setUserSearchInput,
|
||||
getModeClass,
|
||||
selectUser,
|
||||
hoverUser,
|
||||
removeHoverUser,
|
||||
navigateUserList,
|
||||
};
|
||||
},
|
||||
scrollToActiveUser() {
|
||||
// Scroll the list if needed after the active class is applied
|
||||
this.$nextTick(() => {
|
||||
const el = this.$refs.userlist.querySelector(".active");
|
||||
el.scrollIntoView({block: "nearest", inline: "nearest"});
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
<template>
|
||||
<div id="confirm-dialog-overlay" :class="{opened: data !== null}">
|
||||
<div id="confirm-dialog-overlay" :class="{opened: !!data}">
|
||||
<div v-if="data !== null" id="confirm-dialog">
|
||||
<div class="confirm-text">
|
||||
<div class="confirm-text-title">{{ data.title }}</div>
|
||||
<p>{{ data.text }}</p>
|
||||
<div class="confirm-text-title">{{ data?.title }}</div>
|
||||
<p>{{ data?.text }}</p>
|
||||
</div>
|
||||
<div class="confirm-buttons">
|
||||
<button class="btn btn-cancel" @click="close(false)">Cancel</button>
|
||||
<button class="btn btn-danger" @click="close(true)">{{ data.button }}</button>
|
||||
<button class="btn btn-danger" @click="close(true)">{{ data?.button }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -50,37 +50,53 @@
|
|||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import eventbus from "../js/eventbus";
|
||||
import {defineComponent, onMounted, onUnmounted, ref} from "vue";
|
||||
|
||||
export default {
|
||||
type ConfirmDialogData = {
|
||||
title: string;
|
||||
text: string;
|
||||
button: string;
|
||||
};
|
||||
|
||||
type ConfirmDialogCallback = {
|
||||
(confirmed: boolean): void;
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: "ConfirmDialog",
|
||||
data() {
|
||||
setup() {
|
||||
const data = ref<ConfirmDialogData>();
|
||||
const callback = ref<ConfirmDialogCallback>();
|
||||
|
||||
const open = (incoming: ConfirmDialogData, cb: ConfirmDialogCallback) => {
|
||||
data.value = incoming;
|
||||
callback.value = cb;
|
||||
};
|
||||
|
||||
const close = (result: boolean) => {
|
||||
data.value = undefined;
|
||||
|
||||
if (callback.value) {
|
||||
callback.value(!!result);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
eventbus.on("escapekey", close);
|
||||
eventbus.on("confirm-dialog", open);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
eventbus.off("escapekey", close);
|
||||
eventbus.off("confirm-dialog", open);
|
||||
});
|
||||
|
||||
return {
|
||||
data: null,
|
||||
callback: null,
|
||||
data,
|
||||
close,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
eventbus.on("escapekey", this.close);
|
||||
eventbus.on("confirm-dialog", this.open);
|
||||
},
|
||||
destroyed() {
|
||||
eventbus.off("escapekey", this.close);
|
||||
eventbus.off("confirm-dialog", this.open);
|
||||
},
|
||||
methods: {
|
||||
open(data, callback) {
|
||||
this.data = data;
|
||||
this.callback = callback;
|
||||
},
|
||||
close(result) {
|
||||
this.data = null;
|
||||
|
||||
if (this.callback) {
|
||||
this.callback(!!result);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -14,14 +14,17 @@
|
|||
id="context-menu"
|
||||
ref="contextMenu"
|
||||
role="menu"
|
||||
:style="style"
|
||||
:style="{
|
||||
top: style.top + 'px',
|
||||
left: style.left + 'px',
|
||||
}"
|
||||
tabindex="-1"
|
||||
@mouseleave="activeItem = -1"
|
||||
@keydown.enter.prevent="clickActiveItem"
|
||||
>
|
||||
<template v-for="(item, id) of items">
|
||||
<!-- TODO: type -->
|
||||
<template v-for="(item, id) of (items as any)" :key="item.name">
|
||||
<li
|
||||
:key="item.name"
|
||||
:class="[
|
||||
'context-menu-' + item.type,
|
||||
item.class ? 'context-menu-' + item.class : null,
|
||||
|
@ -38,164 +41,77 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import {
|
||||
generateUserContextMenu,
|
||||
generateChannelContextMenu,
|
||||
generateInlineChannelContextMenu,
|
||||
} from "../js/helpers/contextMenu.js";
|
||||
ContextMenuItem,
|
||||
} from "../js/helpers/contextMenu";
|
||||
import eventbus from "../js/eventbus";
|
||||
import {defineComponent, nextTick, onMounted, onUnmounted, PropType, ref} from "vue";
|
||||
import {ClientChan, ClientMessage, ClientNetwork, ClientUser} from "../js/types";
|
||||
import {useStore} from "../js/store";
|
||||
import {useRouter} from "vue-router";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "ContextMenu",
|
||||
props: {
|
||||
message: Object,
|
||||
message: {
|
||||
required: false,
|
||||
type: Object as PropType<ClientMessage>,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isOpen: false,
|
||||
passthrough: false,
|
||||
previousActiveElement: null,
|
||||
items: [],
|
||||
activeItem: -1,
|
||||
style: {
|
||||
left: 0,
|
||||
},
|
||||
setup() {
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
|
||||
const isOpen = ref(false);
|
||||
const passthrough = ref(false);
|
||||
|
||||
const contextMenu = ref<HTMLUListElement | null>();
|
||||
const previousActiveElement = ref<HTMLElement | null>();
|
||||
const items = ref<ContextMenuItem[]>([]);
|
||||
const activeItem = ref(-1);
|
||||
const style = ref({
|
||||
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();
|
||||
},
|
||||
methods: {
|
||||
enablePointerEvents() {
|
||||
this.passthrough = false;
|
||||
document.body.removeEventListener("pointerup", this.enablePointerEvents, {
|
||||
passive: true,
|
||||
left: 0,
|
||||
});
|
||||
},
|
||||
openChannelContextMenu(data) {
|
||||
if (data.event.type === "contextmenu") {
|
||||
// Pass through all pointer events to allow the network list's
|
||||
// dragging events to continue triggering.
|
||||
this.passthrough = true;
|
||||
document.body.addEventListener("pointerup", this.enablePointerEvents, {
|
||||
passive: true,
|
||||
});
|
||||
}
|
||||
|
||||
const items = generateChannelContextMenu(this.$root, data.channel, data.network);
|
||||
this.open(data.event, items);
|
||||
},
|
||||
openInlineChannelContextMenu(data) {
|
||||
const {network} = this.$store.state.activeChannel;
|
||||
const items = generateInlineChannelContextMenu(this.$root, data.channel, network);
|
||||
this.open(data.event, items);
|
||||
},
|
||||
openUserContextMenu(data) {
|
||||
const {network, channel} = this.$store.state.activeChannel;
|
||||
|
||||
const items = generateUserContextMenu(
|
||||
this.$root,
|
||||
channel,
|
||||
network,
|
||||
channel.users.find((u) => u.nick === data.user.nick) || {
|
||||
nick: data.user.nick,
|
||||
modes: [],
|
||||
}
|
||||
);
|
||||
this.open(data.event, items);
|
||||
},
|
||||
open(event, items) {
|
||||
event.preventDefault();
|
||||
|
||||
this.previousActiveElement = document.activeElement;
|
||||
this.items = items;
|
||||
this.activeItem = 0;
|
||||
this.isOpen = true;
|
||||
|
||||
// Position the menu and set the focus on the first item after it's size has updated
|
||||
this.$nextTick(() => {
|
||||
const pos = this.positionContextMenu(event);
|
||||
this.style.left = pos.left + "px";
|
||||
this.style.top = pos.top + "px";
|
||||
this.$refs.contextMenu.focus();
|
||||
});
|
||||
},
|
||||
close() {
|
||||
if (!this.isOpen) {
|
||||
const close = () => {
|
||||
if (!isOpen.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isOpen = false;
|
||||
this.items = [];
|
||||
isOpen.value = false;
|
||||
items.value = [];
|
||||
|
||||
if (this.previousActiveElement) {
|
||||
this.previousActiveElement.focus();
|
||||
this.previousActiveElement = null;
|
||||
if (previousActiveElement.value) {
|
||||
previousActiveElement.value.focus();
|
||||
previousActiveElement.value = null;
|
||||
}
|
||||
},
|
||||
hoverItem(id) {
|
||||
this.activeItem = id;
|
||||
},
|
||||
clickItem(item) {
|
||||
this.close();
|
||||
};
|
||||
|
||||
if (item.action) {
|
||||
item.action();
|
||||
} else if (item.link) {
|
||||
this.$router.push(item.link);
|
||||
}
|
||||
},
|
||||
clickActiveItem() {
|
||||
if (this.items[this.activeItem]) {
|
||||
this.clickItem(this.items[this.activeItem]);
|
||||
}
|
||||
},
|
||||
navigateMenu(direction) {
|
||||
let currentIndex = this.activeItem;
|
||||
const enablePointerEvents = () => {
|
||||
passthrough.value = false;
|
||||
document.body.removeEventListener("pointerup", enablePointerEvents);
|
||||
};
|
||||
|
||||
currentIndex += direction;
|
||||
|
||||
const nextItem = this.items[currentIndex];
|
||||
|
||||
// If the next item we would select is a divider, skip over it
|
||||
if (nextItem && nextItem.type === "divider") {
|
||||
currentIndex += direction;
|
||||
}
|
||||
|
||||
if (currentIndex < 0) {
|
||||
currentIndex += this.items.length;
|
||||
}
|
||||
|
||||
if (currentIndex > this.items.length - 1) {
|
||||
currentIndex -= this.items.length;
|
||||
}
|
||||
|
||||
this.activeItem = currentIndex;
|
||||
},
|
||||
containerClick(event) {
|
||||
const containerClick = (event: MouseEvent) => {
|
||||
if (event.currentTarget === event.target) {
|
||||
this.close();
|
||||
close();
|
||||
}
|
||||
},
|
||||
positionContextMenu(event) {
|
||||
const element = event.target;
|
||||
const menuWidth = this.$refs.contextMenu.offsetWidth;
|
||||
const menuHeight = this.$refs.contextMenu.offsetHeight;
|
||||
};
|
||||
|
||||
const positionContextMenu = (event: MouseEvent) => {
|
||||
const element = event.target as HTMLElement;
|
||||
|
||||
if (!contextMenu.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const menuWidth = contextMenu.value?.offsetWidth;
|
||||
const menuHeight = contextMenu.value?.offsetHeight;
|
||||
|
||||
if (element && element.classList.contains("menu")) {
|
||||
return {
|
||||
|
@ -215,7 +131,154 @@ export default {
|
|||
}
|
||||
|
||||
return offset;
|
||||
};
|
||||
|
||||
const hoverItem = (id: number) => {
|
||||
activeItem.value = id;
|
||||
};
|
||||
|
||||
const clickItem = (item: ContextMenuItem) => {
|
||||
close();
|
||||
|
||||
if ("action" in item && item.action) {
|
||||
item.action();
|
||||
} else if ("link" in item && item.link) {
|
||||
router.push(item.link).catch(() => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to navigate to", item.link);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const clickActiveItem = () => {
|
||||
if (items.value[activeItem.value]) {
|
||||
clickItem(items.value[activeItem.value]);
|
||||
}
|
||||
};
|
||||
|
||||
const open = (event: MouseEvent, newItems: ContextMenuItem[]) => {
|
||||
event.preventDefault();
|
||||
|
||||
previousActiveElement.value = document.activeElement as HTMLElement;
|
||||
items.value = newItems;
|
||||
activeItem.value = 0;
|
||||
isOpen.value = true;
|
||||
|
||||
// Position the menu and set the focus on the first item after it's size has updated
|
||||
nextTick(() => {
|
||||
const pos = positionContextMenu(event);
|
||||
|
||||
if (!pos) {
|
||||
return;
|
||||
}
|
||||
|
||||
style.value.left = pos.left;
|
||||
style.value.top = pos.top;
|
||||
contextMenu.value?.focus();
|
||||
}).catch((e) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(e);
|
||||
});
|
||||
};
|
||||
|
||||
const openChannelContextMenu = (data: {
|
||||
event: MouseEvent;
|
||||
channel: ClientChan;
|
||||
network: ClientNetwork;
|
||||
}) => {
|
||||
if (data.event.type === "contextmenu") {
|
||||
// Pass through all pointer events to allow the network list's
|
||||
// dragging events to continue triggering.
|
||||
passthrough.value = true;
|
||||
document.body.addEventListener("pointerup", enablePointerEvents, {
|
||||
passive: true,
|
||||
});
|
||||
}
|
||||
|
||||
const newItems = generateChannelContextMenu(data.channel, data.network);
|
||||
open(data.event, newItems);
|
||||
};
|
||||
|
||||
const openInlineChannelContextMenu = (data: {channel: string; event: MouseEvent}) => {
|
||||
const {network} = store.state.activeChannel;
|
||||
const newItems = generateInlineChannelContextMenu(store, data.channel, network);
|
||||
|
||||
open(data.event, newItems);
|
||||
};
|
||||
|
||||
const openUserContextMenu = (data: {
|
||||
user: Pick<ClientUser, "nick" | "modes">;
|
||||
event: MouseEvent;
|
||||
}) => {
|
||||
const {network, channel} = store.state.activeChannel;
|
||||
|
||||
const newItems = generateUserContextMenu(
|
||||
store,
|
||||
channel,
|
||||
network,
|
||||
channel.users.find((u) => u.nick === data.user.nick) || {
|
||||
nick: data.user.nick,
|
||||
modes: [],
|
||||
}
|
||||
);
|
||||
open(data.event, newItems);
|
||||
};
|
||||
|
||||
const navigateMenu = (direction: number) => {
|
||||
let currentIndex = activeItem.value;
|
||||
|
||||
currentIndex += direction;
|
||||
|
||||
const nextItem = items.value[currentIndex];
|
||||
|
||||
// If the next item we would select is a divider, skip over it
|
||||
if (nextItem && "type" in nextItem && nextItem.type === "divider") {
|
||||
currentIndex += direction;
|
||||
}
|
||||
|
||||
if (currentIndex < 0) {
|
||||
currentIndex += items.value.length;
|
||||
}
|
||||
|
||||
if (currentIndex > items.value.length - 1) {
|
||||
currentIndex -= items.value.length;
|
||||
}
|
||||
|
||||
activeItem.value = currentIndex;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
eventbus.on("escapekey", close);
|
||||
eventbus.on("contextmenu:cancel", close);
|
||||
eventbus.on("contextmenu:user", openUserContextMenu);
|
||||
eventbus.on("contextmenu:channel", openChannelContextMenu);
|
||||
eventbus.on("contextmenu:inline-channel", openInlineChannelContextMenu);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
eventbus.off("escapekey", close);
|
||||
eventbus.off("contextmenu:cancel", close);
|
||||
eventbus.off("contextmenu:user", openUserContextMenu);
|
||||
eventbus.off("contextmenu:channel", openChannelContextMenu);
|
||||
eventbus.off("contextmenu:inline-channel", openInlineChannelContextMenu);
|
||||
|
||||
close();
|
||||
});
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
items,
|
||||
activeItem,
|
||||
style,
|
||||
contextMenu,
|
||||
passthrough,
|
||||
close,
|
||||
containerClick,
|
||||
navigateMenu,
|
||||
hoverItem,
|
||||
clickItem,
|
||||
clickActiveItem,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -6,52 +6,61 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import dayjs from "dayjs";
|
||||
import calendar from "dayjs/plugin/calendar";
|
||||
import {computed, defineComponent, onBeforeUnmount, onMounted, PropType} from "vue";
|
||||
import eventbus from "../js/eventbus";
|
||||
import type {ClientMessage} from "../js/types";
|
||||
|
||||
dayjs.extend(calendar);
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "DateMarker",
|
||||
props: {
|
||||
message: Object,
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
focused: Boolean,
|
||||
},
|
||||
computed: {
|
||||
localeDate() {
|
||||
return dayjs(this.message.time).format("D MMMM YYYY");
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (this.hoursPassed() < 48) {
|
||||
eventbus.on("daychange", this.dayChange);
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
eventbus.off("daychange", this.dayChange);
|
||||
},
|
||||
methods: {
|
||||
hoursPassed() {
|
||||
return (Date.now() - Date.parse(this.message.time)) / 3600000;
|
||||
},
|
||||
dayChange() {
|
||||
this.$forceUpdate();
|
||||
setup(props) {
|
||||
const localeDate = computed(() => dayjs(props.message.time).format("D MMMM YYYY"));
|
||||
|
||||
if (this.hoursPassed() >= 48) {
|
||||
eventbus.off("daychange", this.dayChange);
|
||||
const hoursPassed = () => {
|
||||
return (Date.now() - Date.parse(props.message.time.toString())) / 3600000;
|
||||
};
|
||||
|
||||
const dayChange = () => {
|
||||
if (hoursPassed() >= 48) {
|
||||
eventbus.off("daychange", dayChange);
|
||||
}
|
||||
},
|
||||
friendlyDate() {
|
||||
};
|
||||
|
||||
const friendlyDate = () => {
|
||||
// See http://momentjs.com/docs/#/displaying/calendar-time/
|
||||
return dayjs(this.message.time).calendar(null, {
|
||||
return dayjs(props.message.time).calendar(null, {
|
||||
sameDay: "[Today]",
|
||||
lastDay: "[Yesterday]",
|
||||
lastWeek: "D MMMM YYYY",
|
||||
sameElse: "D MMMM YYYY",
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (hoursPassed() < 48) {
|
||||
eventbus.on("daychange", dayChange);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
eventbus.off("daychange", dayChange);
|
||||
});
|
||||
|
||||
return {
|
||||
localeDate,
|
||||
friendlyDate,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
120
client/components/Draggable.vue
Normal file
120
client/components/Draggable.vue
Normal 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>
|
|
@ -38,121 +38,125 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import Mousetrap from "mousetrap";
|
||||
import {computed, defineComponent, ref, watch} from "vue";
|
||||
import {onBeforeRouteLeave, onBeforeRouteUpdate} from "vue-router";
|
||||
import eventbus from "../js/eventbus";
|
||||
import {ClientChan, ClientMessage, ClientLinkPreview} from "../js/types";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "ImageViewer",
|
||||
data() {
|
||||
return {
|
||||
link: null,
|
||||
previousImage: null,
|
||||
nextImage: null,
|
||||
channel: null,
|
||||
setup() {
|
||||
const viewer = ref<HTMLDivElement>();
|
||||
const image = ref<HTMLImageElement>();
|
||||
|
||||
position: {
|
||||
const link = ref<ClientLinkPreview | null>(null);
|
||||
const previousImage = ref<ClientLinkPreview | null>();
|
||||
const nextImage = ref<ClientLinkPreview | null>();
|
||||
const channel = ref<ClientChan | null>();
|
||||
|
||||
const position = ref<{
|
||||
x: number;
|
||||
y: number;
|
||||
}>({
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
transform: {
|
||||
});
|
||||
|
||||
const transform = ref<{
|
||||
scale: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}>({
|
||||
scale: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 0,
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
computeImageStyles() {
|
||||
});
|
||||
|
||||
const computeImageStyles = computed(() => {
|
||||
// Sub pixels may cause the image to blur in certain browsers
|
||||
// round it down to prevent that
|
||||
const transformX = Math.floor(this.transform.x);
|
||||
const transformY = Math.floor(this.transform.y);
|
||||
const transformX = Math.floor(transform.value.x);
|
||||
const transformY = Math.floor(transform.value.y);
|
||||
|
||||
return {
|
||||
left: `${this.position.x}px`,
|
||||
top: `${this.position.y}px`,
|
||||
transform: `translate3d(${transformX}px, ${transformY}px, 0) scale3d(${this.transform.scale}, ${this.transform.scale}, 1)`,
|
||||
left: `${position.value.x}px`,
|
||||
top: `${position.value.y}px`,
|
||||
transform: `translate3d(${transformX}px, ${transformY}px, 0) scale3d(${transform.value.scale}, ${transform.value.scale}, 1)`,
|
||||
};
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
link(newLink, oldLink) {
|
||||
// TODO: history.pushState
|
||||
if (newLink === null) {
|
||||
eventbus.off("escapekey", this.closeViewer);
|
||||
eventbus.off("resize", this.correctPosition);
|
||||
Mousetrap.unbind("left", this.previous);
|
||||
Mousetrap.unbind("right", this.next);
|
||||
});
|
||||
|
||||
const closeViewer = () => {
|
||||
if (link.value === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setPrevNextImages();
|
||||
channel.value = null;
|
||||
previousImage.value = null;
|
||||
nextImage.value = null;
|
||||
link.value = null;
|
||||
};
|
||||
|
||||
if (!oldLink) {
|
||||
eventbus.on("escapekey", this.closeViewer);
|
||||
eventbus.on("resize", this.correctPosition);
|
||||
Mousetrap.bind("left", this.previous);
|
||||
Mousetrap.bind("right", this.next);
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
closeViewer() {
|
||||
if (this.link === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.channel = null;
|
||||
this.previousImage = null;
|
||||
this.nextImage = null;
|
||||
this.link = null;
|
||||
},
|
||||
setPrevNextImages() {
|
||||
if (!this.channel) {
|
||||
const setPrevNextImages = () => {
|
||||
if (!channel.value || !link.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const links = this.channel.messages
|
||||
const links = channel.value.messages
|
||||
.map((msg) => msg.previews)
|
||||
.flat()
|
||||
.filter((preview) => preview.thumb);
|
||||
|
||||
const currentIndex = links.indexOf(this.link);
|
||||
const currentIndex = links.indexOf(link.value);
|
||||
|
||||
this.previousImage = links[currentIndex - 1] || null;
|
||||
this.nextImage = links[currentIndex + 1] || null;
|
||||
},
|
||||
previous() {
|
||||
if (this.previousImage) {
|
||||
this.link = this.previousImage;
|
||||
}
|
||||
},
|
||||
next() {
|
||||
if (this.nextImage) {
|
||||
this.link = this.nextImage;
|
||||
}
|
||||
},
|
||||
onImageLoad() {
|
||||
this.prepareImage();
|
||||
},
|
||||
prepareImage() {
|
||||
const viewer = this.$refs.viewer;
|
||||
const image = this.$refs.image;
|
||||
const width = viewer.offsetWidth;
|
||||
const height = viewer.offsetHeight;
|
||||
const scale = Math.min(1, width / image.width, height / image.height);
|
||||
previousImage.value = links[currentIndex - 1] || null;
|
||||
nextImage.value = links[currentIndex + 1] || null;
|
||||
};
|
||||
|
||||
this.position.x = Math.floor(-image.naturalWidth / 2);
|
||||
this.position.y = Math.floor(-image.naturalHeight / 2);
|
||||
this.transform.scale = Math.max(scale, 0.1);
|
||||
this.transform.x = width / 2;
|
||||
this.transform.y = height / 2;
|
||||
},
|
||||
calculateZoomShift(newScale, x, y, oldScale) {
|
||||
const imageWidth = this.$refs.image.width;
|
||||
const centerX = this.$refs.viewer.offsetWidth / 2;
|
||||
const centerY = this.$refs.viewer.offsetHeight / 2;
|
||||
const previous = () => {
|
||||
if (previousImage.value) {
|
||||
link.value = previousImage.value;
|
||||
}
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
if (nextImage.value) {
|
||||
link.value = nextImage.value;
|
||||
}
|
||||
};
|
||||
|
||||
const prepareImage = () => {
|
||||
const viewerEl = viewer.value;
|
||||
const imageEl = image.value;
|
||||
|
||||
if (!viewerEl || !imageEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const width = viewerEl.offsetWidth;
|
||||
const height = viewerEl.offsetHeight;
|
||||
const scale = Math.min(1, width / imageEl.width, height / imageEl.height);
|
||||
|
||||
position.value.x = Math.floor(-image.value!.naturalWidth / 2);
|
||||
position.value.y = Math.floor(-image.value!.naturalHeight / 2);
|
||||
transform.value.scale = Math.max(scale, 0.1);
|
||||
transform.value.x = width / 2;
|
||||
transform.value.y = height / 2;
|
||||
};
|
||||
|
||||
const onImageLoad = () => {
|
||||
prepareImage();
|
||||
};
|
||||
|
||||
const calculateZoomShift = (newScale: number, x: number, y: number, oldScale: number) => {
|
||||
if (!image.value || !viewer.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageWidth = image.value.width;
|
||||
const centerX = viewer.value.offsetWidth / 2;
|
||||
const centerY = viewer.value.offsetHeight / 2;
|
||||
|
||||
return {
|
||||
x:
|
||||
|
@ -164,32 +168,40 @@ export default {
|
|||
((centerY - (oldScale - (imageWidth * x) / 2)) / x) * newScale +
|
||||
(imageWidth * newScale) / 2,
|
||||
};
|
||||
},
|
||||
correctPosition() {
|
||||
const image = this.$refs.image;
|
||||
const widthScaled = image.width * this.transform.scale;
|
||||
const heightScaled = image.height * this.transform.scale;
|
||||
const containerWidth = this.$refs.viewer.offsetWidth;
|
||||
const containerHeight = this.$refs.viewer.offsetHeight;
|
||||
};
|
||||
|
||||
const correctPosition = () => {
|
||||
const imageEl = image.value;
|
||||
const viewerEl = viewer.value;
|
||||
|
||||
if (!imageEl || !viewerEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const widthScaled = imageEl.width * transform.value.scale;
|
||||
const heightScaled = imageEl.height * transform.value.scale;
|
||||
const containerWidth = viewerEl.offsetWidth;
|
||||
const containerHeight = viewerEl.offsetHeight;
|
||||
|
||||
if (widthScaled < containerWidth) {
|
||||
this.transform.x = containerWidth / 2;
|
||||
} else if (this.transform.x - widthScaled / 2 > 0) {
|
||||
this.transform.x = widthScaled / 2;
|
||||
} else if (this.transform.x + widthScaled / 2 < containerWidth) {
|
||||
this.transform.x = containerWidth - widthScaled / 2;
|
||||
transform.value.x = containerWidth / 2;
|
||||
} else if (transform.value.x - widthScaled / 2 > 0) {
|
||||
transform.value.x = widthScaled / 2;
|
||||
} else if (transform.value.x + widthScaled / 2 < containerWidth) {
|
||||
transform.value.x = containerWidth - widthScaled / 2;
|
||||
}
|
||||
|
||||
if (heightScaled < containerHeight) {
|
||||
this.transform.y = containerHeight / 2;
|
||||
} else if (this.transform.y - heightScaled / 2 > 0) {
|
||||
this.transform.y = heightScaled / 2;
|
||||
} else if (this.transform.y + heightScaled / 2 < containerHeight) {
|
||||
this.transform.y = containerHeight - heightScaled / 2;
|
||||
transform.value.y = containerHeight / 2;
|
||||
} else if (transform.value.y - heightScaled / 2 > 0) {
|
||||
transform.value.y = heightScaled / 2;
|
||||
} else if (transform.value.y + heightScaled / 2 < containerHeight) {
|
||||
transform.value.y = containerHeight - heightScaled / 2;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Reduce multiple touch points into a single x/y/scale
|
||||
reduceTouches(touches) {
|
||||
const reduceTouches = (touches: TouchList) => {
|
||||
let totalX = 0;
|
||||
let totalY = 0;
|
||||
let totalScale = 0;
|
||||
|
@ -219,17 +231,19 @@ export default {
|
|||
y: totalY / touches.length,
|
||||
scale: totalScale / touches.length,
|
||||
};
|
||||
},
|
||||
onTouchStart(e) {
|
||||
};
|
||||
|
||||
const onTouchStart = (e: TouchEvent) => {
|
||||
// prevent sidebar touchstart event, we don't want to interact with sidebar while in image viewer
|
||||
e.stopImmediatePropagation();
|
||||
},
|
||||
};
|
||||
|
||||
// Touch image manipulation:
|
||||
// 1. Move around by dragging it with one finger
|
||||
// 2. Change image scale by using two fingers
|
||||
onImageTouchStart(e) {
|
||||
const image = this.$refs.image;
|
||||
let touch = this.reduceTouches(e.touches);
|
||||
const onImageTouchStart = (e: TouchEvent) => {
|
||||
const img = image.value;
|
||||
let touch = reduceTouches(e.touches);
|
||||
let currentTouches = e.touches;
|
||||
let touchEndFingers = 0;
|
||||
|
||||
|
@ -240,21 +254,21 @@ export default {
|
|||
};
|
||||
|
||||
const startTransform = {
|
||||
x: this.transform.x,
|
||||
y: this.transform.y,
|
||||
scale: this.transform.scale,
|
||||
x: transform.value.x,
|
||||
y: transform.value.y,
|
||||
scale: transform.value.scale,
|
||||
};
|
||||
|
||||
const touchMove = (moveEvent) => {
|
||||
touch = this.reduceTouches(moveEvent.touches);
|
||||
touch = reduceTouches(moveEvent.touches);
|
||||
|
||||
if (currentTouches.length !== moveEvent.touches.length) {
|
||||
currentTransform.x = touch.x;
|
||||
currentTransform.y = touch.y;
|
||||
currentTransform.scale = touch.scale;
|
||||
startTransform.x = this.transform.x;
|
||||
startTransform.y = this.transform.y;
|
||||
startTransform.scale = this.transform.scale;
|
||||
startTransform.x = transform.value.x;
|
||||
startTransform.y = transform.value.y;
|
||||
startTransform.scale = transform.value.scale;
|
||||
}
|
||||
|
||||
const deltaX = touch.x - currentTransform.x;
|
||||
|
@ -264,20 +278,25 @@ export default {
|
|||
touchEndFingers = 0;
|
||||
|
||||
const newScale = Math.min(3, Math.max(0.1, startTransform.scale * deltaScale));
|
||||
const fixedPosition = this.calculateZoomShift(
|
||||
|
||||
const fixedPosition = calculateZoomShift(
|
||||
newScale,
|
||||
startTransform.scale,
|
||||
startTransform.x,
|
||||
startTransform.y
|
||||
);
|
||||
|
||||
this.transform.x = fixedPosition.x + deltaX;
|
||||
this.transform.y = fixedPosition.y + deltaY;
|
||||
this.transform.scale = newScale;
|
||||
this.correctPosition();
|
||||
if (!fixedPosition) {
|
||||
return;
|
||||
}
|
||||
|
||||
transform.value.x = fixedPosition.x + deltaX;
|
||||
transform.value.y = fixedPosition.y + deltaY;
|
||||
transform.value.scale = newScale;
|
||||
correctPosition();
|
||||
};
|
||||
|
||||
const touchEnd = (endEvent) => {
|
||||
const touchEnd = (endEvent: TouchEvent) => {
|
||||
const changedTouches = endEvent.changedTouches.length;
|
||||
|
||||
if (currentTouches.length > changedTouches + touchEndFingers) {
|
||||
|
@ -287,27 +306,30 @@ export default {
|
|||
|
||||
// todo: this is swipe to close, but it's not working very well due to unfinished delta calculation
|
||||
/* if (
|
||||
this.transform.scale <= 1 &&
|
||||
transform.value.scale <= 1 &&
|
||||
endEvent.changedTouches[0].clientY - startTransform.y <= -70
|
||||
) {
|
||||
return this.closeViewer();
|
||||
}*/
|
||||
|
||||
this.correctPosition();
|
||||
correctPosition();
|
||||
|
||||
image.removeEventListener("touchmove", touchMove, {passive: true});
|
||||
image.removeEventListener("touchend", touchEnd, {passive: true});
|
||||
img?.removeEventListener("touchmove", touchMove);
|
||||
img?.removeEventListener("touchend", touchEnd);
|
||||
};
|
||||
|
||||
img?.addEventListener("touchmove", touchMove, {passive: true});
|
||||
img?.addEventListener("touchend", touchEnd, {passive: true});
|
||||
};
|
||||
|
||||
image.addEventListener("touchmove", touchMove, {passive: true});
|
||||
image.addEventListener("touchend", touchEnd, {passive: true});
|
||||
},
|
||||
// Image mouse manipulation:
|
||||
// 1. Mouse wheel scrolling will zoom in and out
|
||||
// 2. If image is zoomed in, simply dragging it will move it around
|
||||
onImageMouseDown(e) {
|
||||
const onImageMouseDown = (e: MouseEvent) => {
|
||||
// todo: ignore if in touch event currently?
|
||||
|
||||
// only left mouse
|
||||
// TODO: e.buttons?
|
||||
if (e.which !== 1) {
|
||||
return;
|
||||
}
|
||||
|
@ -315,22 +337,26 @@ export default {
|
|||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
const viewer = this.$refs.viewer;
|
||||
const image = this.$refs.image;
|
||||
const viewerEl = viewer.value;
|
||||
const imageEl = image.value;
|
||||
|
||||
if (!viewerEl || !imageEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const startTransformX = this.transform.x;
|
||||
const startTransformY = this.transform.y;
|
||||
const widthScaled = image.width * this.transform.scale;
|
||||
const heightScaled = image.height * this.transform.scale;
|
||||
const containerWidth = viewer.offsetWidth;
|
||||
const containerHeight = viewer.offsetHeight;
|
||||
const centerX = this.transform.x - widthScaled / 2;
|
||||
const centerY = this.transform.y - heightScaled / 2;
|
||||
const startTransformX = transform.value.x;
|
||||
const startTransformY = transform.value.y;
|
||||
const widthScaled = imageEl.width * transform.value.scale;
|
||||
const heightScaled = imageEl.height * transform.value.scale;
|
||||
const containerWidth = viewerEl.offsetWidth;
|
||||
const containerHeight = viewerEl.offsetHeight;
|
||||
const centerX = transform.value.x - widthScaled / 2;
|
||||
const centerY = transform.value.y - heightScaled / 2;
|
||||
let movedDistance = 0;
|
||||
|
||||
const mouseMove = (moveEvent) => {
|
||||
const mouseMove = (moveEvent: MouseEvent) => {
|
||||
moveEvent.stopPropagation();
|
||||
moveEvent.preventDefault();
|
||||
|
||||
|
@ -340,66 +366,112 @@ export default {
|
|||
movedDistance = Math.max(movedDistance, Math.abs(newX), Math.abs(newY));
|
||||
|
||||
if (centerX < 0 || widthScaled + centerX > containerWidth) {
|
||||
this.transform.x = startTransformX + newX;
|
||||
transform.value.x = startTransformX + newX;
|
||||
}
|
||||
|
||||
if (centerY < 0 || heightScaled + centerY > containerHeight) {
|
||||
this.transform.y = startTransformY + newY;
|
||||
transform.value.y = startTransformY + newY;
|
||||
}
|
||||
|
||||
this.correctPosition();
|
||||
correctPosition();
|
||||
};
|
||||
|
||||
const mouseUp = (upEvent) => {
|
||||
this.correctPosition();
|
||||
const mouseUp = (upEvent: MouseEvent) => {
|
||||
correctPosition();
|
||||
|
||||
if (movedDistance < 2 && upEvent.button === 0) {
|
||||
this.closeViewer();
|
||||
closeViewer();
|
||||
}
|
||||
|
||||
image.removeEventListener("mousemove", mouseMove);
|
||||
image.removeEventListener("mouseup", mouseUp);
|
||||
image.value?.removeEventListener("mousemove", mouseMove);
|
||||
image.value?.removeEventListener("mouseup", mouseUp);
|
||||
};
|
||||
|
||||
image.value?.addEventListener("mousemove", mouseMove);
|
||||
image.value?.addEventListener("mouseup", mouseUp);
|
||||
};
|
||||
|
||||
image.addEventListener("mousemove", mouseMove);
|
||||
image.addEventListener("mouseup", mouseUp);
|
||||
},
|
||||
// If image is zoomed in, holding ctrl while scrolling will move the image up and down
|
||||
onMouseWheel(e) {
|
||||
const onMouseWheel = (e: WheelEvent) => {
|
||||
// if image viewer is closing (css animation), you can still trigger mousewheel
|
||||
// TODO: Figure out a better fix for this
|
||||
if (this.link === null) {
|
||||
if (link.value === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault(); // TODO: Can this be passive?
|
||||
|
||||
if (e.ctrlKey) {
|
||||
this.transform.y += e.deltaY;
|
||||
transform.value.y += e.deltaY;
|
||||
} else {
|
||||
const delta = e.deltaY > 0 ? 0.1 : -0.1;
|
||||
const newScale = Math.min(3, Math.max(0.1, this.transform.scale + delta));
|
||||
const fixedPosition = this.calculateZoomShift(
|
||||
const newScale = Math.min(3, Math.max(0.1, transform.value.scale + delta));
|
||||
const fixedPosition = calculateZoomShift(
|
||||
newScale,
|
||||
this.transform.scale,
|
||||
this.transform.x,
|
||||
this.transform.y
|
||||
transform.value.scale,
|
||||
transform.value.x,
|
||||
transform.value.y
|
||||
);
|
||||
this.transform.scale = newScale;
|
||||
this.transform.x = fixedPosition.x;
|
||||
this.transform.y = fixedPosition.y;
|
||||
}
|
||||
|
||||
this.correctPosition();
|
||||
},
|
||||
onClick(e) {
|
||||
// If click triggers on the image, ignore it
|
||||
if (e.target === this.$refs.image) {
|
||||
if (!fixedPosition) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.closeViewer();
|
||||
transform.value.scale = newScale;
|
||||
transform.value.x = fixedPosition.x;
|
||||
transform.value.y = fixedPosition.y;
|
||||
}
|
||||
|
||||
correctPosition();
|
||||
};
|
||||
|
||||
const onClick = (e: Event) => {
|
||||
// If click triggers on the image, ignore it
|
||||
if (e.target === image.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
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>
|
||||
|
|
|
@ -10,21 +10,26 @@
|
|||
></span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import {defineComponent} from "vue";
|
||||
import eventbus from "../js/eventbus";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "InlineChannel",
|
||||
props: {
|
||||
channel: String,
|
||||
},
|
||||
methods: {
|
||||
openContextMenu(event) {
|
||||
setup(props) {
|
||||
const openContextMenu = (event) => {
|
||||
eventbus.emit("contextmenu:inline-channel", {
|
||||
event: event,
|
||||
channel: this.channel,
|
||||
channel: props.channel,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
openContextMenu,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -35,54 +35,59 @@
|
|||
</form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import {defineComponent, PropType, ref} from "vue";
|
||||
import {switchToChannel} from "../js/router";
|
||||
import socket from "../js/socket";
|
||||
import {useStore} from "../js/store";
|
||||
import {ClientNetwork, ClientChan} from "../js/types";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "JoinChannel",
|
||||
directives: {
|
||||
focus: {
|
||||
inserted(el) {
|
||||
el.focus();
|
||||
},
|
||||
mounted: (el: HTMLFormElement) => el.focus(),
|
||||
},
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
channel: Object,
|
||||
network: {type: Object as PropType<ClientNetwork>, required: true},
|
||||
channel: {type: Object as PropType<ClientChan>, required: true},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
inputChannel: "",
|
||||
inputPassword: "",
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
onSubmit() {
|
||||
const existingChannel = this.$store.getters.findChannelOnCurrentNetwork(
|
||||
this.inputChannel
|
||||
);
|
||||
emits: ["toggle-join-channel"],
|
||||
setup(props, {emit}) {
|
||||
const store = useStore();
|
||||
const inputChannel = ref("");
|
||||
const inputPassword = ref("");
|
||||
|
||||
const onSubmit = () => {
|
||||
const existingChannel = store.getters.findChannelOnCurrentNetwork(inputChannel.value);
|
||||
|
||||
if (existingChannel) {
|
||||
this.$root.switchToChannel(existingChannel);
|
||||
switchToChannel(existingChannel);
|
||||
} else {
|
||||
const chanTypes = this.network.serverOptions.CHANTYPES;
|
||||
let channel = this.inputChannel;
|
||||
const chanTypes = props.network.serverOptions.CHANTYPES;
|
||||
let channel = inputChannel.value;
|
||||
|
||||
if (chanTypes && chanTypes.length > 0 && !chanTypes.includes(channel[0])) {
|
||||
channel = chanTypes[0] + channel;
|
||||
}
|
||||
|
||||
socket.emit("input", {
|
||||
text: `/join ${channel} ${this.inputPassword}`,
|
||||
target: this.channel.id,
|
||||
text: `/join ${channel} ${inputPassword.value}`,
|
||||
target: props.channel.id,
|
||||
});
|
||||
}
|
||||
|
||||
this.inputChannel = "";
|
||||
this.inputPassword = "";
|
||||
this.$emit("toggle-join-channel");
|
||||
inputChannel.value = "";
|
||||
inputPassword.value = "";
|
||||
emit("toggle-join-channel");
|
||||
};
|
||||
|
||||
return {
|
||||
inputChannel,
|
||||
inputPassword,
|
||||
onSubmit,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -129,137 +129,201 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import {
|
||||
computed,
|
||||
defineComponent,
|
||||
inject,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
PropType,
|
||||
ref,
|
||||
watch,
|
||||
} from "vue";
|
||||
import {onBeforeRouteUpdate} from "vue-router";
|
||||
import eventbus from "../js/eventbus";
|
||||
import friendlysize from "../js/helpers/friendlysize";
|
||||
import {useStore} from "../js/store";
|
||||
import type {ClientChan, ClientLinkPreview} from "../js/types";
|
||||
import {imageViewerKey} from "./App.vue";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "LinkPreview",
|
||||
props: {
|
||||
link: Object,
|
||||
keepScrollPosition: Function,
|
||||
channel: Object,
|
||||
link: {
|
||||
type: Object as PropType<ClientLinkPreview>,
|
||||
required: true,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showMoreButton: false,
|
||||
isContentShown: false,
|
||||
};
|
||||
keepScrollPosition: {
|
||||
type: Function as PropType<() => void>,
|
||||
required: true,
|
||||
},
|
||||
computed: {
|
||||
moreButtonLabel() {
|
||||
return this.isContentShown ? "Less" : "More";
|
||||
channel: {type: Object as PropType<ClientChan>, required: true},
|
||||
},
|
||||
imageMaxSize() {
|
||||
if (!this.link.maxSize) {
|
||||
setup(props) {
|
||||
const store = useStore();
|
||||
|
||||
const showMoreButton = ref(false);
|
||||
const isContentShown = ref(false);
|
||||
const imageViewer = inject(imageViewerKey);
|
||||
|
||||
onBeforeRouteUpdate((to, from, next) => {
|
||||
// cancel the navigation if the user is trying to close the image viewer
|
||||
if (imageViewer?.value?.link) {
|
||||
imageViewer.value.closeViewer();
|
||||
return next(false);
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
const content = ref<HTMLDivElement | null>(null);
|
||||
const container = ref<HTMLDivElement | null>(null);
|
||||
|
||||
const moreButtonLabel = computed(() => {
|
||||
return isContentShown.value ? "Less" : "More";
|
||||
});
|
||||
|
||||
const imageMaxSize = computed(() => {
|
||||
if (!props.link.maxSize) {
|
||||
return;
|
||||
}
|
||||
|
||||
return friendlysize(this.link.maxSize);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
"link.type"() {
|
||||
this.updateShownState();
|
||||
this.onPreviewUpdate();
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.updateShownState();
|
||||
},
|
||||
mounted() {
|
||||
eventbus.on("resize", this.handleResize);
|
||||
return friendlysize(props.link.maxSize);
|
||||
});
|
||||
|
||||
this.onPreviewUpdate();
|
||||
},
|
||||
beforeDestroy() {
|
||||
eventbus.off("resize", this.handleResize);
|
||||
},
|
||||
destroyed() {
|
||||
// Let this preview go through load/canplay events again,
|
||||
// Otherwise the browser can cause a resize on video elements
|
||||
this.link.sourceLoaded = false;
|
||||
},
|
||||
methods: {
|
||||
onPreviewUpdate() {
|
||||
const handleResize = () => {
|
||||
nextTick(() => {
|
||||
if (!content.value || !container.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
showMoreButton.value = content.value.offsetWidth >= container.value.offsetWidth;
|
||||
}).catch((e) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Error in LinkPreview.handleResize", e);
|
||||
});
|
||||
};
|
||||
|
||||
const onPreviewReady = () => {
|
||||
props.link.sourceLoaded = true;
|
||||
|
||||
props.keepScrollPosition();
|
||||
|
||||
if (props.link.type === "link") {
|
||||
handleResize();
|
||||
}
|
||||
};
|
||||
|
||||
const onPreviewUpdate = () => {
|
||||
// Don't display previews while they are loading on the server
|
||||
if (this.link.type === "loading") {
|
||||
if (props.link.type === "loading") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Error does not have any media to render
|
||||
if (this.link.type === "error") {
|
||||
this.onPreviewReady();
|
||||
if (props.link.type === "error") {
|
||||
onPreviewReady();
|
||||
}
|
||||
|
||||
// If link doesn't have a thumbnail, render it
|
||||
if (this.link.type === "link") {
|
||||
this.handleResize();
|
||||
this.keepScrollPosition();
|
||||
if (props.link.type === "link") {
|
||||
handleResize();
|
||||
props.keepScrollPosition();
|
||||
}
|
||||
},
|
||||
onPreviewReady() {
|
||||
this.$set(this.link, "sourceLoaded", true);
|
||||
};
|
||||
|
||||
this.keepScrollPosition();
|
||||
|
||||
if (this.link.type === "link") {
|
||||
this.handleResize();
|
||||
}
|
||||
},
|
||||
onThumbnailError() {
|
||||
const onThumbnailError = () => {
|
||||
// If thumbnail fails to load, hide it and show the preview without it
|
||||
this.link.thumb = "";
|
||||
this.onPreviewReady();
|
||||
},
|
||||
onThumbnailClick(e) {
|
||||
props.link.thumb = "";
|
||||
onPreviewReady();
|
||||
};
|
||||
|
||||
const onThumbnailClick = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const imageViewer = this.$root.$refs.app.$refs.imageViewer;
|
||||
imageViewer.channel = this.channel;
|
||||
imageViewer.link = this.link;
|
||||
},
|
||||
onMoreClick() {
|
||||
this.isContentShown = !this.isContentShown;
|
||||
this.keepScrollPosition();
|
||||
},
|
||||
handleResize() {
|
||||
this.$nextTick(() => {
|
||||
if (!this.$refs.content) {
|
||||
if (!imageViewer?.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.showMoreButton =
|
||||
this.$refs.content.offsetWidth >= this.$refs.container.offsetWidth;
|
||||
});
|
||||
},
|
||||
updateShownState() {
|
||||
imageViewer.value.channel = props.channel;
|
||||
imageViewer.value.link = props.link;
|
||||
};
|
||||
|
||||
const onMoreClick = () => {
|
||||
isContentShown.value = !isContentShown.value;
|
||||
props.keepScrollPosition();
|
||||
};
|
||||
|
||||
const updateShownState = () => {
|
||||
// User has manually toggled the preview, do not apply default
|
||||
if (this.link.shown !== null) {
|
||||
if (props.link.shown !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let defaultState = false;
|
||||
|
||||
switch (this.link.type) {
|
||||
switch (props.link.type) {
|
||||
case "error":
|
||||
// Collapse all errors by default unless its a message about image being too big
|
||||
if (this.link.error === "image-too-big") {
|
||||
defaultState = this.$store.state.settings.media;
|
||||
if (props.link.error === "image-too-big") {
|
||||
defaultState = store.state.settings.media;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case "link":
|
||||
defaultState = this.$store.state.settings.links;
|
||||
defaultState = store.state.settings.links;
|
||||
break;
|
||||
|
||||
default:
|
||||
defaultState = this.$store.state.settings.media;
|
||||
defaultState = store.state.settings.media;
|
||||
}
|
||||
|
||||
this.link.shown = defaultState;
|
||||
props.link.shown = defaultState;
|
||||
};
|
||||
|
||||
updateShownState();
|
||||
|
||||
watch(
|
||||
() => props.link.type,
|
||||
() => {
|
||||
updateShownState();
|
||||
onPreviewUpdate();
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
eventbus.on("resize", handleResize);
|
||||
|
||||
onPreviewUpdate();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
eventbus.off("resize", handleResize);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
// Let this preview go through load/canplay events again,
|
||||
// Otherwise the browser can cause a resize on video elements
|
||||
props.link.sourceLoaded = false;
|
||||
});
|
||||
|
||||
return {
|
||||
moreButtonLabel,
|
||||
imageMaxSize,
|
||||
onThumbnailClick,
|
||||
onThumbnailError,
|
||||
onMoreClick,
|
||||
onPreviewReady,
|
||||
onPreviewUpdate,
|
||||
showMoreButton,
|
||||
isContentShown,
|
||||
content,
|
||||
container,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -2,18 +2,21 @@
|
|||
<span class="preview-size">({{ previewSize }})</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import {defineComponent} from "vue";
|
||||
import friendlysize from "../js/helpers/friendlysize";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "LinkPreviewFileSize",
|
||||
props: {
|
||||
size: Number,
|
||||
size: {type: Number, required: true},
|
||||
},
|
||||
computed: {
|
||||
previewSize() {
|
||||
return friendlysize(this.size);
|
||||
setup(props) {
|
||||
const previewSize = friendlysize(props.size);
|
||||
|
||||
return {
|
||||
previewSize,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -7,23 +7,31 @@
|
|||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
<script lang="ts">
|
||||
import {computed, defineComponent, PropType} from "vue";
|
||||
import {ClientMessage, ClientLinkPreview} from "../js/types";
|
||||
|
||||
export default defineComponent({
|
||||
name: "LinkPreviewToggle",
|
||||
props: {
|
||||
link: Object,
|
||||
link: {type: Object as PropType<ClientLinkPreview>, required: true},
|
||||
message: {type: Object as PropType<ClientMessage>, required: true},
|
||||
},
|
||||
computed: {
|
||||
ariaLabel() {
|
||||
return this.link.shown ? "Collapse preview" : "Expand preview";
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onClick() {
|
||||
this.link.shown = !this.link.shown;
|
||||
emits: ["toggle-link-preview"],
|
||||
setup(props, {emit}) {
|
||||
const ariaLabel = computed(() => {
|
||||
return props.link.shown ? "Collapse preview" : "Expand preview";
|
||||
});
|
||||
|
||||
this.$parent.$emit("toggle-link-preview", this.link, this.$parent.message);
|
||||
const onClick = () => {
|
||||
props.link.shown = !props.link.shown;
|
||||
emit("toggle-link-preview", props.link, props.message);
|
||||
};
|
||||
|
||||
return {
|
||||
ariaLabel,
|
||||
onClick,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -20,20 +20,20 @@
|
|||
<p v-if="isLoading">Loading…</p>
|
||||
<p v-else>You have no recent mentions.</p>
|
||||
</template>
|
||||
<template v-for="message in resolvedMessages" v-else>
|
||||
<div :key="message.msgId" :class="['msg', message.type]">
|
||||
<template v-for="message in resolvedMessages" v-else :key="message.msgId">
|
||||
<div :class="['msg', message.type]">
|
||||
<div class="mentions-info">
|
||||
<div>
|
||||
<span class="from">
|
||||
<Username :user="message.from" />
|
||||
<Username :user="(message.from as any)" />
|
||||
<template v-if="message.channel">
|
||||
in {{ message.channel.channel.name }} on
|
||||
{{ message.channel.network.name }}
|
||||
</template>
|
||||
<template v-else> in unknown channel </template>
|
||||
</span>
|
||||
<template v-else> in unknown channel </template> </span
|
||||
>{{ ` ` }}
|
||||
<span :title="message.localetime" class="time">
|
||||
{{ messageTime(message.time) }}
|
||||
{{ messageTime(message.time.toString()) }}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
|
@ -50,7 +50,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="content" dir="auto">
|
||||
<ParsedMessage :network="null" :message="message" />
|
||||
<ParsedMessage :message="(message as any)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -144,7 +144,7 @@
|
|||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import Username from "./Username.vue";
|
||||
import ParsedMessage from "./ParsedMessage.vue";
|
||||
import socket from "../js/socket";
|
||||
|
@ -152,78 +152,96 @@ import eventbus from "../js/eventbus";
|
|||
import localetime from "../js/helpers/localetime";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import {computed, watch, defineComponent, ref, onMounted, onUnmounted} from "vue";
|
||||
import {useStore} from "../js/store";
|
||||
import {ClientMention} from "../js/types";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "Mentions",
|
||||
components: {
|
||||
Username,
|
||||
ParsedMessage,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isOpen: false,
|
||||
isLoading: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
resolvedMessages() {
|
||||
const messages = this.$store.state.mentions.slice().reverse();
|
||||
setup() {
|
||||
const store = useStore();
|
||||
const isOpen = ref(false);
|
||||
const isLoading = ref(false);
|
||||
const resolvedMessages = computed(() => {
|
||||
const messages = store.state.mentions.slice().reverse();
|
||||
|
||||
for (const message of messages) {
|
||||
message.localetime = localetime(message.time);
|
||||
message.channel = this.$store.getters.findChannel(message.chanId);
|
||||
message.channel = store.getters.findChannel(message.chanId);
|
||||
}
|
||||
|
||||
return messages.filter((message) => !message.channel.channel.muted);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
"$store.state.mentions"() {
|
||||
this.isLoading = false;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
eventbus.on("mentions:toggle", this.togglePopup);
|
||||
eventbus.on("escapekey", this.closePopup);
|
||||
},
|
||||
destroyed() {
|
||||
eventbus.off("mentions:toggle", this.togglePopup);
|
||||
eventbus.off("escapekey", this.closePopup);
|
||||
},
|
||||
methods: {
|
||||
messageTime(time) {
|
||||
return messages.filter((message) => !message.channel?.channel.muted);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => store.state.mentions,
|
||||
() => {
|
||||
isLoading.value = false;
|
||||
}
|
||||
);
|
||||
|
||||
const messageTime = (time: string) => {
|
||||
return dayjs(time).fromNow();
|
||||
},
|
||||
dismissMention(message) {
|
||||
this.$store.state.mentions.splice(
|
||||
this.$store.state.mentions.findIndex((m) => m.msgId === message.msgId),
|
||||
};
|
||||
|
||||
const dismissMention = (message: ClientMention) => {
|
||||
store.state.mentions.splice(
|
||||
store.state.mentions.findIndex((m) => m.msgId === message.msgId),
|
||||
1
|
||||
);
|
||||
|
||||
socket.emit("mentions:dismiss", message.msgId);
|
||||
},
|
||||
dismissAllMentions() {
|
||||
this.$store.state.mentions = [];
|
||||
socket.emit("mentions:dismiss_all");
|
||||
},
|
||||
containerClick(event) {
|
||||
if (event.currentTarget === event.target) {
|
||||
this.isOpen = false;
|
||||
}
|
||||
},
|
||||
togglePopup() {
|
||||
this.isOpen = !this.isOpen;
|
||||
};
|
||||
|
||||
if (this.isOpen) {
|
||||
this.isLoading = true;
|
||||
const dismissAllMentions = () => {
|
||||
store.state.mentions = [];
|
||||
socket.emit("mentions:dismiss_all");
|
||||
};
|
||||
|
||||
const containerClick = (event: Event) => {
|
||||
if (event.currentTarget === event.target) {
|
||||
isOpen.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const togglePopup = () => {
|
||||
isOpen.value = !isOpen.value;
|
||||
|
||||
if (isOpen.value) {
|
||||
isLoading.value = true;
|
||||
socket.emit("mentions:get");
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
},
|
||||
closePopup() {
|
||||
this.isOpen = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -17,12 +17,14 @@
|
|||
aria-hidden="true"
|
||||
:aria-label="messageTimeLocale"
|
||||
class="time tooltipped tooltipped-e"
|
||||
>{{ messageTime }}
|
||||
>{{ `${messageTime} ` }}
|
||||
</span>
|
||||
<template v-if="message.type === 'unhandled'">
|
||||
<span class="from">[{{ message.command }}]</span>
|
||||
<span class="content">
|
||||
<span v-for="(param, id) in message.params" :key="id">{{ param }} </span>
|
||||
<span v-for="(param, id) in message.params" :key="id">{{
|
||||
` ${param} `
|
||||
}}</span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="isAction()">
|
||||
|
@ -95,56 +97,73 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const constants = require("../js/constants");
|
||||
import localetime from "../js/helpers/localetime";
|
||||
<script lang="ts">
|
||||
import {computed, defineComponent, PropType} from "vue";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import constants from "../js/constants";
|
||||
import localetime from "../js/helpers/localetime";
|
||||
import Username from "./Username.vue";
|
||||
import LinkPreview from "./LinkPreview.vue";
|
||||
import ParsedMessage from "./ParsedMessage.vue";
|
||||
import MessageTypes from "./MessageTypes";
|
||||
|
||||
import type {ClientChan, ClientMessage, ClientNetwork} from "../js/types";
|
||||
import {useStore} from "../js/store";
|
||||
|
||||
MessageTypes.ParsedMessage = ParsedMessage;
|
||||
MessageTypes.LinkPreview = LinkPreview;
|
||||
MessageTypes.Username = Username;
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "Message",
|
||||
components: MessageTypes,
|
||||
props: {
|
||||
message: Object,
|
||||
channel: Object,
|
||||
network: Object,
|
||||
keepScrollPosition: Function,
|
||||
message: {type: Object as PropType<ClientMessage>, required: true},
|
||||
channel: {type: Object as PropType<ClientChan>, required: false},
|
||||
network: {type: Object as PropType<ClientNetwork>, required: true},
|
||||
keepScrollPosition: Function as PropType<() => void>,
|
||||
isPreviousSource: Boolean,
|
||||
focused: Boolean,
|
||||
},
|
||||
computed: {
|
||||
timeFormat() {
|
||||
let format;
|
||||
setup(props) {
|
||||
const store = useStore();
|
||||
|
||||
if (this.$store.state.settings.use12hClock) {
|
||||
format = this.$store.state.settings.showSeconds ? "msg12hWithSeconds" : "msg12h";
|
||||
const timeFormat = computed(() => {
|
||||
let format: keyof typeof constants.timeFormats;
|
||||
|
||||
if (store.state.settings.use12hClock) {
|
||||
format = store.state.settings.showSeconds ? "msg12hWithSeconds" : "msg12h";
|
||||
} else {
|
||||
format = this.$store.state.settings.showSeconds ? "msgWithSeconds" : "msgDefault";
|
||||
format = store.state.settings.showSeconds ? "msgWithSeconds" : "msgDefault";
|
||||
}
|
||||
|
||||
return constants.timeFormats[format];
|
||||
});
|
||||
|
||||
const messageTime = computed(() => {
|
||||
return dayjs(props.message.time).format(timeFormat.value);
|
||||
});
|
||||
|
||||
const messageTimeLocale = computed(() => {
|
||||
return localetime(props.message.time);
|
||||
});
|
||||
|
||||
const messageComponent = computed(() => {
|
||||
return "message-" + props.message.type;
|
||||
});
|
||||
|
||||
const isAction = () => {
|
||||
return typeof MessageTypes["message-" + props.message.type] !== "undefined";
|
||||
};
|
||||
|
||||
return {
|
||||
timeFormat,
|
||||
messageTime,
|
||||
messageTimeLocale,
|
||||
messageComponent,
|
||||
isAction,
|
||||
};
|
||||
},
|
||||
messageTime() {
|
||||
return dayjs(this.message.time).format(this.timeFormat);
|
||||
},
|
||||
messageTimeLocale() {
|
||||
return localetime(this.message.time);
|
||||
},
|
||||
messageComponent() {
|
||||
return "message-" + this.message.type;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
isAction() {
|
||||
return typeof MessageTypes["message-" + this.message.type] !== "undefined";
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -17,35 +17,45 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const constants = require("../js/constants");
|
||||
<script lang="ts">
|
||||
import {computed, defineComponent, PropType, ref} from "vue";
|
||||
import constants from "../js/constants";
|
||||
import {ClientMessage, ClientNetwork} from "../js/types";
|
||||
import Message from "./Message.vue";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "MessageCondensed",
|
||||
components: {
|
||||
Message,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
messages: Array,
|
||||
keepScrollPosition: Function,
|
||||
network: {type: Object as PropType<ClientNetwork>, required: true},
|
||||
messages: {
|
||||
type: Array as PropType<ClientMessage[]>,
|
||||
required: true,
|
||||
},
|
||||
keepScrollPosition: {
|
||||
type: Function as PropType<() => void>,
|
||||
required: true,
|
||||
},
|
||||
focused: Boolean,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isCollapsed: true,
|
||||
setup(props) {
|
||||
const isCollapsed = ref(true);
|
||||
|
||||
const onCollapseClick = () => {
|
||||
isCollapsed.value = !isCollapsed.value;
|
||||
props.keepScrollPosition();
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
condensedText() {
|
||||
const obj = {};
|
||||
|
||||
const condensedText = computed(() => {
|
||||
const obj: Record<string, number> = {};
|
||||
|
||||
constants.condensedTypes.forEach((type) => {
|
||||
obj[type] = 0;
|
||||
});
|
||||
|
||||
for (const message of this.messages) {
|
||||
for (const message of props.messages) {
|
||||
// special case since one MODE message can change multiple modes
|
||||
if (message.type === "mode") {
|
||||
// syntax: +vv-t maybe-some targets
|
||||
|
@ -64,13 +74,13 @@ export default {
|
|||
// Count quits as parts in condensed messages to reduce information density
|
||||
obj.part += obj.quit;
|
||||
|
||||
const strings = [];
|
||||
const strings: string[] = [];
|
||||
constants.condensedTypes.forEach((type) => {
|
||||
if (obj[type]) {
|
||||
switch (type) {
|
||||
case "chghost":
|
||||
strings.push(
|
||||
obj[type] +
|
||||
String(obj[type]) +
|
||||
(obj[type] > 1
|
||||
? " users have changed hostname"
|
||||
: " user has changed hostname")
|
||||
|
@ -78,18 +88,19 @@ export default {
|
|||
break;
|
||||
case "join":
|
||||
strings.push(
|
||||
obj[type] +
|
||||
String(obj[type]) +
|
||||
(obj[type] > 1 ? " users have joined" : " user has joined")
|
||||
);
|
||||
break;
|
||||
case "part":
|
||||
strings.push(
|
||||
obj[type] + (obj[type] > 1 ? " users have left" : " user has left")
|
||||
String(obj[type]) +
|
||||
(obj[type] > 1 ? " users have left" : " user has left")
|
||||
);
|
||||
break;
|
||||
case "nick":
|
||||
strings.push(
|
||||
obj[type] +
|
||||
String(obj[type]) +
|
||||
(obj[type] > 1
|
||||
? " users have changed nick"
|
||||
: " user has changed nick")
|
||||
|
@ -97,33 +108,38 @@ export default {
|
|||
break;
|
||||
case "kick":
|
||||
strings.push(
|
||||
obj[type] +
|
||||
String(obj[type]) +
|
||||
(obj[type] > 1 ? " users were kicked" : " user was kicked")
|
||||
);
|
||||
break;
|
||||
case "mode":
|
||||
strings.push(
|
||||
obj[type] + (obj[type] > 1 ? " modes were set" : " mode was set")
|
||||
String(obj[type]) +
|
||||
(obj[type] > 1 ? " modes were set" : " mode was set")
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (strings.length) {
|
||||
let text = strings.pop();
|
||||
|
||||
if (strings.length) {
|
||||
text = strings.join(", ") + ", and " + text;
|
||||
text = strings.join(", ") + ", and " + text!;
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
return "";
|
||||
});
|
||||
|
||||
return {
|
||||
isCollapsed,
|
||||
condensedText,
|
||||
onCollapseClick,
|
||||
};
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onCollapseClick() {
|
||||
this.isCollapsed = !this.isCollapsed;
|
||||
this.keepScrollPosition();
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
<div v-show="channel.moreHistoryAvailable" class="show-more">
|
||||
<button
|
||||
ref="loadMoreButton"
|
||||
:disabled="channel.historyLoading || !$store.state.isConnected"
|
||||
:disabled="channel.historyLoading || !store.state.isConnected"
|
||||
class="btn"
|
||||
@click="onShowMoreClick"
|
||||
>
|
||||
|
@ -22,11 +22,11 @@
|
|||
<DateMarker
|
||||
v-if="shouldDisplayDateMarker(message, id)"
|
||||
:key="message.id + '-date'"
|
||||
:message="message"
|
||||
:focused="message.id == focused"
|
||||
:message="message as any"
|
||||
:focused="message.id === focused"
|
||||
/>
|
||||
<div
|
||||
v-if="shouldDisplayUnreadMarker(message.id)"
|
||||
v-if="shouldDisplayUnreadMarker(Number(message.id))"
|
||||
:key="message.id + '-unread'"
|
||||
class="unread-marker"
|
||||
>
|
||||
|
@ -39,7 +39,7 @@
|
|||
:network="network"
|
||||
:keep-scroll-position="keepScrollPosition"
|
||||
:messages="message.messages"
|
||||
:focused="message.id == focused"
|
||||
:focused="message.id === focused"
|
||||
/>
|
||||
<Message
|
||||
v-else
|
||||
|
@ -49,7 +49,7 @@
|
|||
:message="message"
|
||||
:keep-scroll-position="keepScrollPosition"
|
||||
:is-previous-source="isPreviousSource(message, id)"
|
||||
:focused="message.id == focused"
|
||||
:focused="message.id === focused"
|
||||
@toggle-link-preview="onLinkPreviewToggle"
|
||||
/>
|
||||
</template>
|
||||
|
@ -57,18 +57,41 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const constants = require("../js/constants");
|
||||
<script lang="ts">
|
||||
import constants from "../js/constants";
|
||||
import eventbus from "../js/eventbus";
|
||||
import clipboard from "../js/clipboard";
|
||||
import socket from "../js/socket";
|
||||
import Message from "./Message.vue";
|
||||
import MessageCondensed from "./MessageCondensed.vue";
|
||||
import DateMarker from "./DateMarker.vue";
|
||||
import {
|
||||
computed,
|
||||
defineComponent,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onBeforeUpdate,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
PropType,
|
||||
ref,
|
||||
watch,
|
||||
} from "vue";
|
||||
import {useStore} from "../js/store";
|
||||
import {ClientChan, ClientMessage, ClientNetwork, ClientLinkPreview} from "../js/types";
|
||||
import Msg from "../../server/models/msg";
|
||||
|
||||
type CondensedMessageContainer = {
|
||||
type: "condensed";
|
||||
time: Date;
|
||||
messages: ClientMessage[];
|
||||
id?: number;
|
||||
};
|
||||
|
||||
// TODO; move into component
|
||||
let unreadMarkerShown = false;
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "MessageList",
|
||||
components: {
|
||||
Message,
|
||||
|
@ -76,32 +99,105 @@ export default {
|
|||
DateMarker,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
channel: Object,
|
||||
focused: String,
|
||||
network: {type: Object as PropType<ClientNetwork>, required: true},
|
||||
channel: {type: Object as PropType<ClientChan>, required: true},
|
||||
focused: Number,
|
||||
},
|
||||
computed: {
|
||||
condensedMessages() {
|
||||
if (this.channel.type !== "channel") {
|
||||
return this.channel.messages;
|
||||
setup(props, {emit}) {
|
||||
const store = useStore();
|
||||
|
||||
const chat = ref<HTMLDivElement | null>(null);
|
||||
const loadMoreButton = ref<HTMLButtonElement | null>(null);
|
||||
const historyObserver = ref<IntersectionObserver | null>(null);
|
||||
const skipNextScrollEvent = ref(false);
|
||||
|
||||
const isWaitingForNextTick = ref(false);
|
||||
|
||||
const jumpToBottom = () => {
|
||||
skipNextScrollEvent.value = true;
|
||||
props.channel.scrolledToBottom = true;
|
||||
|
||||
const el = chat.value;
|
||||
|
||||
if (el) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
const onShowMoreClick = () => {
|
||||
if (!store.state.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
let lastMessage = -1;
|
||||
|
||||
// Find the id of first message that isn't showInActive
|
||||
// If showInActive is set, this message is actually in another channel
|
||||
for (const message of props.channel.messages) {
|
||||
if (!message.showInActive) {
|
||||
lastMessage = message.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
props.channel.historyLoading = true;
|
||||
|
||||
socket.emit("more", {
|
||||
target: props.channel.id,
|
||||
lastId: lastMessage,
|
||||
condensed: store.state.settings.statusMessages !== "shown",
|
||||
});
|
||||
};
|
||||
|
||||
const onLoadButtonObserved = (entries: IntersectionObserverEntry[]) => {
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.isIntersecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
onShowMoreClick();
|
||||
});
|
||||
};
|
||||
|
||||
nextTick(() => {
|
||||
if (!chat.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.IntersectionObserver) {
|
||||
historyObserver.value = new window.IntersectionObserver(onLoadButtonObserved, {
|
||||
root: chat.value,
|
||||
});
|
||||
}
|
||||
|
||||
jumpToBottom();
|
||||
}).catch((e) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Error in new IntersectionObserver", e);
|
||||
});
|
||||
|
||||
const condensedMessages = computed(() => {
|
||||
if (props.channel.type !== "channel") {
|
||||
return props.channel.messages;
|
||||
}
|
||||
|
||||
// If actions are hidden, just return a message list with them excluded
|
||||
if (this.$store.state.settings.statusMessages === "hidden") {
|
||||
return this.channel.messages.filter(
|
||||
if (store.state.settings.statusMessages === "hidden") {
|
||||
return props.channel.messages.filter(
|
||||
(message) => !constants.condensedTypes.has(message.type)
|
||||
);
|
||||
}
|
||||
|
||||
// If actions are not condensed, just return raw message list
|
||||
if (this.$store.state.settings.statusMessages !== "condensed") {
|
||||
return this.channel.messages;
|
||||
if (store.state.settings.statusMessages !== "condensed") {
|
||||
return props.channel.messages;
|
||||
}
|
||||
|
||||
const condensed = [];
|
||||
let lastCondensedContainer = null;
|
||||
let lastCondensedContainer: CondensedMessageContainer | null = null;
|
||||
|
||||
for (const message of this.channel.messages) {
|
||||
const condensed: (ClientMessage | CondensedMessageContainer)[] = [];
|
||||
|
||||
for (const message of props.channel.messages) {
|
||||
// If this message is not condensable, or its an action affecting our user,
|
||||
// then just append the message to container and be done with it
|
||||
if (
|
||||
|
@ -116,7 +212,7 @@ export default {
|
|||
continue;
|
||||
}
|
||||
|
||||
if (lastCondensedContainer === null) {
|
||||
if (!lastCondensedContainer) {
|
||||
lastCondensedContainer = {
|
||||
time: message.time,
|
||||
type: "condensed",
|
||||
|
@ -126,14 +222,14 @@ export default {
|
|||
condensed.push(lastCondensedContainer);
|
||||
}
|
||||
|
||||
lastCondensedContainer.messages.push(message);
|
||||
lastCondensedContainer!.messages.push(message);
|
||||
|
||||
// Set id of the condensed container to last message id,
|
||||
// which is required for the unread marker to work correctly
|
||||
lastCondensedContainer.id = message.id;
|
||||
lastCondensedContainer!.id = message.id;
|
||||
|
||||
// If this message is the unread boundary, create a split condensed container
|
||||
if (message.id === this.channel.firstUnread) {
|
||||
if (message.id === props.channel.firstUnread) {
|
||||
lastCondensedContainer = null;
|
||||
}
|
||||
}
|
||||
|
@ -147,70 +243,13 @@ export default {
|
|||
|
||||
return message;
|
||||
});
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
"channel.id"() {
|
||||
this.channel.scrolledToBottom = true;
|
||||
|
||||
// Re-add the intersection observer to trigger the check again on channel switch
|
||||
// Otherwise if last channel had the button visible, switching to a new channel won't trigger the history
|
||||
if (this.historyObserver) {
|
||||
this.historyObserver.unobserve(this.$refs.loadMoreButton);
|
||||
this.historyObserver.observe(this.$refs.loadMoreButton);
|
||||
}
|
||||
},
|
||||
"channel.messages"() {
|
||||
this.keepScrollPosition();
|
||||
},
|
||||
"channel.pendingMessage"() {
|
||||
this.$nextTick(() => {
|
||||
// Keep the scroll stuck when input gets resized while typing
|
||||
this.keepScrollPosition();
|
||||
});
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.$nextTick(() => {
|
||||
if (!this.$refs.chat) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.IntersectionObserver) {
|
||||
this.historyObserver = new window.IntersectionObserver(this.onLoadButtonObserved, {
|
||||
root: this.$refs.chat,
|
||||
});
|
||||
}
|
||||
|
||||
this.jumpToBottom();
|
||||
});
|
||||
},
|
||||
mounted() {
|
||||
this.$refs.chat.addEventListener("scroll", this.handleScroll, {passive: true});
|
||||
|
||||
eventbus.on("resize", this.handleResize);
|
||||
|
||||
this.$nextTick(() => {
|
||||
if (this.historyObserver) {
|
||||
this.historyObserver.observe(this.$refs.loadMoreButton);
|
||||
}
|
||||
});
|
||||
},
|
||||
beforeUpdate() {
|
||||
unreadMarkerShown = false;
|
||||
},
|
||||
beforeDestroy() {
|
||||
eventbus.off("resize", this.handleResize);
|
||||
this.$refs.chat.removeEventListener("scroll", this.handleScroll);
|
||||
},
|
||||
destroyed() {
|
||||
if (this.historyObserver) {
|
||||
this.historyObserver.disconnect();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
shouldDisplayDateMarker(message, id) {
|
||||
const previousMessage = this.condensedMessages[id - 1];
|
||||
const shouldDisplayDateMarker = (
|
||||
message: Msg | ClientMessage | CondensedMessageContainer,
|
||||
id: number
|
||||
) => {
|
||||
const previousMessage = condensedMessages.value[id - 1];
|
||||
|
||||
if (!previousMessage) {
|
||||
return true;
|
||||
|
@ -224,135 +263,180 @@ export default {
|
|||
oldDate.getMonth() !== newDate.getMonth() ||
|
||||
oldDate.getFullYear() !== newDate.getFullYear()
|
||||
);
|
||||
},
|
||||
shouldDisplayUnreadMarker(id) {
|
||||
if (!unreadMarkerShown && id > this.channel.firstUnread) {
|
||||
};
|
||||
|
||||
const shouldDisplayUnreadMarker = (id: number) => {
|
||||
if (!unreadMarkerShown && id > props.channel.firstUnread) {
|
||||
unreadMarkerShown = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
isPreviousSource(currentMessage, id) {
|
||||
const previousMessage = this.condensedMessages[id - 1];
|
||||
return (
|
||||
};
|
||||
|
||||
const isPreviousSource = (currentMessage: ClientMessage | Msg, id: number) => {
|
||||
const previousMessage = condensedMessages[id - 1];
|
||||
return !!(
|
||||
previousMessage &&
|
||||
currentMessage.type === "message" &&
|
||||
previousMessage.type === "message" &&
|
||||
previousMessage.from &&
|
||||
currentMessage.from.nick === previousMessage.from.nick
|
||||
);
|
||||
},
|
||||
onCopy() {
|
||||
clipboard(this.$el);
|
||||
},
|
||||
onLinkPreviewToggle(preview, message) {
|
||||
this.keepScrollPosition();
|
||||
};
|
||||
|
||||
const onCopy = () => {
|
||||
if (chat.value) {
|
||||
clipboard(chat.value);
|
||||
}
|
||||
};
|
||||
|
||||
const keepScrollPosition = async () => {
|
||||
// If we are already waiting for the next tick to force scroll position,
|
||||
// we have no reason to perform more checks and set it again in the next tick
|
||||
if (isWaitingForNextTick.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const el = chat.value;
|
||||
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!props.channel.scrolledToBottom) {
|
||||
if (props.channel.historyLoading) {
|
||||
const heightOld = el.scrollHeight - el.scrollTop;
|
||||
|
||||
isWaitingForNextTick.value = true;
|
||||
|
||||
await nextTick();
|
||||
|
||||
isWaitingForNextTick.value = false;
|
||||
skipNextScrollEvent.value = true;
|
||||
|
||||
el.scrollTop = el.scrollHeight - heightOld;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
isWaitingForNextTick.value = true;
|
||||
await nextTick();
|
||||
isWaitingForNextTick.value = false;
|
||||
|
||||
jumpToBottom();
|
||||
};
|
||||
|
||||
const onLinkPreviewToggle = async (preview: ClientLinkPreview, message: ClientMessage) => {
|
||||
await keepScrollPosition();
|
||||
|
||||
// Tell the server we're toggling so it remembers at page reload
|
||||
socket.emit("msg:preview:toggle", {
|
||||
target: this.channel.id,
|
||||
target: props.channel.id,
|
||||
msgId: message.id,
|
||||
link: preview.link,
|
||||
shown: preview.shown,
|
||||
});
|
||||
},
|
||||
onShowMoreClick() {
|
||||
if (!this.$store.state.isConnected) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let lastMessage = -1;
|
||||
|
||||
// Find the id of first message that isn't showInActive
|
||||
// If showInActive is set, this message is actually in another channel
|
||||
for (const message of this.channel.messages) {
|
||||
if (!message.showInActive) {
|
||||
lastMessage = message.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.channel.historyLoading = true;
|
||||
|
||||
socket.emit("more", {
|
||||
target: this.channel.id,
|
||||
lastId: lastMessage,
|
||||
condensed: this.$store.state.settings.statusMessages !== "shown",
|
||||
});
|
||||
},
|
||||
onLoadButtonObserved(entries) {
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.isIntersecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.onShowMoreClick();
|
||||
});
|
||||
},
|
||||
keepScrollPosition() {
|
||||
// If we are already waiting for the next tick to force scroll position,
|
||||
// we have no reason to perform more checks and set it again in the next tick
|
||||
if (this.isWaitingForNextTick) {
|
||||
return;
|
||||
}
|
||||
|
||||
const el = this.$refs.chat;
|
||||
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.channel.scrolledToBottom) {
|
||||
if (this.channel.historyLoading) {
|
||||
const heightOld = el.scrollHeight - el.scrollTop;
|
||||
|
||||
this.isWaitingForNextTick = true;
|
||||
this.$nextTick(() => {
|
||||
this.isWaitingForNextTick = false;
|
||||
this.skipNextScrollEvent = true;
|
||||
el.scrollTop = el.scrollHeight - heightOld;
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.isWaitingForNextTick = true;
|
||||
this.$nextTick(() => {
|
||||
this.isWaitingForNextTick = false;
|
||||
this.jumpToBottom();
|
||||
});
|
||||
},
|
||||
handleScroll() {
|
||||
const handleScroll = () => {
|
||||
// Setting scrollTop also triggers scroll event
|
||||
// We don't want to perform calculations for that
|
||||
if (this.skipNextScrollEvent) {
|
||||
this.skipNextScrollEvent = false;
|
||||
if (skipNextScrollEvent.value) {
|
||||
skipNextScrollEvent.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const el = this.$refs.chat;
|
||||
const el = chat.value;
|
||||
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.channel.scrolledToBottom = el.scrollHeight - el.scrollTop - el.offsetHeight <= 30;
|
||||
},
|
||||
handleResize() {
|
||||
// Keep message list scrolled to bottom on resize
|
||||
if (this.channel.scrolledToBottom) {
|
||||
this.jumpToBottom();
|
||||
}
|
||||
},
|
||||
jumpToBottom() {
|
||||
this.skipNextScrollEvent = true;
|
||||
this.channel.scrolledToBottom = true;
|
||||
props.channel.scrolledToBottom = el.scrollHeight - el.scrollTop - el.offsetHeight <= 30;
|
||||
};
|
||||
|
||||
const el = this.$refs.chat;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
const handleResize = () => {
|
||||
// Keep message list scrolled to bottom on resize
|
||||
if (props.channel.scrolledToBottom) {
|
||||
jumpToBottom();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
chat.value?.addEventListener("scroll", handleScroll, {passive: true});
|
||||
|
||||
eventbus.on("resize", handleResize);
|
||||
|
||||
void nextTick(() => {
|
||||
if (historyObserver.value && loadMoreButton.value) {
|
||||
historyObserver.value.observe(loadMoreButton.value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.channel.id,
|
||||
() => {
|
||||
props.channel.scrolledToBottom = true;
|
||||
|
||||
// Re-add the intersection observer to trigger the check again on channel switch
|
||||
// Otherwise if last channel had the button visible, switching to a new channel won't trigger the history
|
||||
if (historyObserver.value && loadMoreButton.value) {
|
||||
historyObserver.value.unobserve(loadMoreButton.value);
|
||||
historyObserver.value.observe(loadMoreButton.value);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.channel.messages,
|
||||
async () => {
|
||||
await keepScrollPosition();
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.channel.pendingMessage,
|
||||
async () => {
|
||||
// Keep the scroll stuck when input gets resized while typing
|
||||
await keepScrollPosition();
|
||||
}
|
||||
);
|
||||
|
||||
onBeforeUpdate(() => {
|
||||
unreadMarkerShown = false;
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
eventbus.off("resize", handleResize);
|
||||
chat.value?.removeEventListener("scroll", handleScroll);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (historyObserver.value) {
|
||||
historyObserver.value.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
chat,
|
||||
store,
|
||||
onShowMoreClick,
|
||||
loadMoreButton,
|
||||
onCopy,
|
||||
condensedMessages,
|
||||
shouldDisplayDateMarker,
|
||||
shouldDisplayUnreadMarker,
|
||||
keepScrollPosition,
|
||||
isPreviousSource,
|
||||
jumpToBottom,
|
||||
onLinkPreviewToggle,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -80,77 +80,96 @@ form.message-search.opened .input-wrapper {
|
|||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
<script lang="ts">
|
||||
import {computed, defineComponent, onMounted, PropType, ref, watch} from "vue";
|
||||
import {useRoute, useRouter} from "vue-router";
|
||||
import eventbus from "../js/eventbus";
|
||||
import {ClientNetwork, ClientChan} from "../js/types";
|
||||
|
||||
export default defineComponent({
|
||||
name: "MessageSearchForm",
|
||||
props: {
|
||||
network: Object,
|
||||
channel: Object,
|
||||
network: {type: Object as PropType<ClientNetwork>, required: true},
|
||||
channel: {type: Object as PropType<ClientChan>, required: true},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
searchOpened: false,
|
||||
searchInput: "",
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
onSearchPage() {
|
||||
return this.$route.name === "SearchResults";
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
"$route.query.q"() {
|
||||
this.searchInput = this.$route.query.q;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.searchInput = this.$route.query.q;
|
||||
this.searchOpened = this.onSearchPage;
|
||||
setup(props) {
|
||||
const searchOpened = ref(false);
|
||||
const searchInput = ref("");
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
if (!this.searchInput && this.searchOpened) {
|
||||
this.$refs.searchInputField.focus();
|
||||
const searchInputField = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const onSearchPage = computed(() => {
|
||||
return route.name === "SearchResults";
|
||||
});
|
||||
|
||||
watch(route, (newValue) => {
|
||||
if (newValue.query.q) {
|
||||
searchInput.value = String(newValue.query.q);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
closeSearch() {
|
||||
if (!this.onSearchPage) {
|
||||
this.searchInput = "";
|
||||
this.searchOpened = false;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
searchInput.value = String(route.query.q || "");
|
||||
searchOpened.value = onSearchPage.value;
|
||||
|
||||
if (searchInputField.value && !searchInput.value && searchOpened.value) {
|
||||
searchInputField.value.focus();
|
||||
}
|
||||
},
|
||||
toggleSearch() {
|
||||
if (this.searchOpened) {
|
||||
this.$refs.searchInputField.blur();
|
||||
});
|
||||
|
||||
const closeSearch = () => {
|
||||
if (!onSearchPage.value) {
|
||||
searchInput.value = "";
|
||||
searchOpened.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSearch = () => {
|
||||
if (searchOpened.value) {
|
||||
searchInputField.value?.blur();
|
||||
return;
|
||||
}
|
||||
|
||||
this.searchOpened = true;
|
||||
this.$refs.searchInputField.focus();
|
||||
},
|
||||
searchMessages(event) {
|
||||
searchOpened.value = true;
|
||||
searchInputField.value?.focus();
|
||||
};
|
||||
|
||||
const searchMessages = (event: Event) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!this.searchInput) {
|
||||
if (!searchInput.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$router
|
||||
router
|
||||
.push({
|
||||
name: "SearchResults",
|
||||
params: {
|
||||
id: this.channel.id,
|
||||
id: props.channel.id,
|
||||
},
|
||||
query: {
|
||||
q: this.searchInput,
|
||||
q: searchInput.value,
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name === "NavigationDuplicated") {
|
||||
// Search for the same query again
|
||||
this.$root.$emit("re-search");
|
||||
eventbus.emit("re-search");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
searchOpened,
|
||||
searchInput,
|
||||
searchInputField,
|
||||
closeSearch,
|
||||
toggleSearch,
|
||||
searchMessages,
|
||||
onSearchPage,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -9,19 +9,27 @@
|
|||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import type {ClientNetwork, ClientMessage} from "../../js/types";
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "MessageTypeAway",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -8,19 +8,27 @@
|
|||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "MessageTypeBack",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -12,19 +12,27 @@
|
|||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "MessageTypeChangeHost",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -1,23 +1,31 @@
|
|||
<template>
|
||||
<span class="content">
|
||||
<Username :user="message.from" /> 
|
||||
<span class="ctcp-message"><ParsedMessage :text="message.ctcpMessage" /></span>
|
||||
<Username :user="message.from" />
|
||||
{{ ` ` }}<span class="ctcp-message"><ParsedMessage :text="message.ctcpMessage" /></span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "MessageTypeCTCP",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -6,19 +6,27 @@
|
|||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "MessageTypeRequestCTCP",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -4,55 +4,67 @@
|
|||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import {computed, defineComponent, PropType} from "vue";
|
||||
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "MessageTypeError",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
computed: {
|
||||
errorMessage() {
|
||||
switch (this.message.error) {
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const errorMessage = computed(() => {
|
||||
switch (props.message.error) {
|
||||
case "bad_channel_key":
|
||||
return `Cannot join ${this.message.channel} - Bad channel key.`;
|
||||
return `Cannot join ${props.message.channel} - Bad channel key.`;
|
||||
case "banned_from_channel":
|
||||
return `Cannot join ${this.message.channel} - You have been banned from the channel.`;
|
||||
return `Cannot join ${props.message.channel} - You have been banned from the channel.`;
|
||||
case "cannot_send_to_channel":
|
||||
return `Cannot send to channel ${this.message.channel}`;
|
||||
return `Cannot send to channel ${props.message.channel}`;
|
||||
case "channel_is_full":
|
||||
return `Cannot join ${this.message.channel} - Channel is full.`;
|
||||
return `Cannot join ${props.message.channel} - Channel is full.`;
|
||||
case "chanop_privs_needed":
|
||||
return "Cannot perform action: You're not a channel operator.";
|
||||
case "invite_only_channel":
|
||||
return `Cannot join ${this.message.channel} - Channel is invite only.`;
|
||||
return `Cannot join ${props.message.channel} - Channel is invite only.`;
|
||||
case "no_such_nick":
|
||||
return `User ${this.message.nick} hasn't logged in or does not exist.`;
|
||||
return `User ${props.message.nick} hasn't logged in or does not exist.`;
|
||||
case "not_on_channel":
|
||||
return "Cannot perform action: You're not on the channel.";
|
||||
case "password_mismatch":
|
||||
return "Password mismatch.";
|
||||
case "too_many_channels":
|
||||
return `Cannot join ${this.message.channel} - You've already reached the maximum number of channels allowed.`;
|
||||
return `Cannot join ${props.message.channel} - You've already reached the maximum number of channels allowed.`;
|
||||
case "unknown_command":
|
||||
return `Unknown command: ${this.message.command}`;
|
||||
return `Unknown command: ${props.message.command}`;
|
||||
case "user_not_in_channel":
|
||||
return `User ${this.message.nick} is not on the channel.`;
|
||||
return `User ${props.message.nick} is not on the channel.`;
|
||||
case "user_on_channel":
|
||||
return `User ${this.message.nick} is already on the channel.`;
|
||||
return `User ${props.message.nick} is already on the channel.`;
|
||||
default:
|
||||
if (this.message.reason) {
|
||||
return `${this.message.reason} (${this.message.error})`;
|
||||
if (props.message.reason) {
|
||||
return `${props.message.reason} (${props.message.error})`;
|
||||
}
|
||||
|
||||
return this.message.error;
|
||||
return props.message.error;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
errorMessage,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -1,12 +1,10 @@
|
|||
"use strict";
|
||||
|
||||
// This creates a version of `require()` in the context of the current
|
||||
// directory, so we iterate over its content, which is a map statically built by
|
||||
// Webpack.
|
||||
// Second argument says it's recursive, third makes sure we only load templates.
|
||||
const requireViews = require.context(".", false, /\.vue$/);
|
||||
|
||||
export default requireViews.keys().reduce((acc, path) => {
|
||||
export default requireViews.keys().reduce((acc: Record<string, any>, path) => {
|
||||
acc["message-" + path.substring(2, path.length - 4)] = requireViews(path).default;
|
||||
|
||||
return acc;
|
|
@ -8,19 +8,27 @@
|
|||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "MessageTypeInvite",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -1,30 +1,38 @@
|
|||
<template>
|
||||
<span class="content">
|
||||
<Username :user="message.from" />
|
||||
<i class="hostmask"> (<ParsedMessage :network="network" :text="message.hostmask" />)</i>
|
||||
<i class="hostmask"> (<ParsedMessage :network="network" :text="message.hostmask" />)</i>
|
||||
<template v-if="message.account">
|
||||
<i class="account"> [{{ message.account }}]</i>
|
||||
<i class="account"> [{{ message.account }}]</i>
|
||||
</template>
|
||||
<template v-if="message.gecos">
|
||||
<i class="realname"> {{ message.gecos }}</i>
|
||||
<i class="realname"> ({{ message.gecos }})</i>
|
||||
</template>
|
||||
has joined the channel
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "MessageTypeJoin",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -9,19 +9,27 @@
|
|||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "MessageTypeKick",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -6,19 +6,27 @@
|
|||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "MessageTypeMode",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -4,12 +4,21 @@
|
|||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
<script lang="ts">
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||
|
||||
export default defineComponent({
|
||||
name: "MessageChannelMode",
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -4,12 +4,21 @@
|
|||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
<script lang="ts">
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||
|
||||
export default defineComponent({
|
||||
name: "MessageChannelMode",
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -4,26 +4,34 @@
|
|||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import {computed, defineComponent, PropType} from "vue";
|
||||
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "MessageTypeMonospaceBlock",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
computed: {
|
||||
cleanText() {
|
||||
let lines = this.message.text.split("\n");
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const cleanText = computed(() => {
|
||||
let lines = props.message.text.split("\n");
|
||||
|
||||
// If all non-empty lines of the MOTD start with a hyphen (which is common
|
||||
// across MOTDs), remove all the leading hyphens.
|
||||
if (lines.every((line) => line === "" || line[0] === "-")) {
|
||||
lines = lines.map((line) => line.substr(2));
|
||||
lines = lines.map((line) => line.substring(2));
|
||||
}
|
||||
|
||||
// Remove empty lines around the MOTD (but not within it)
|
||||
|
@ -31,7 +39,11 @@ export default {
|
|||
.map((line) => line.replace(/\s*$/, ""))
|
||||
.join("\n")
|
||||
.replace(/^[\r\n]+|[\r\n]+$/g, "");
|
||||
});
|
||||
|
||||
return {
|
||||
cleanText,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -6,17 +6,25 @@
|
|||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "MessageTypeNick",
|
||||
components: {
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -9,19 +9,27 @@
|
|||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "MessageTypePart",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -9,19 +9,27 @@
|
|||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import type {ClientMessage, ClientNetwork} from "../../js/types";
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "MessageTypeQuit",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -2,12 +2,21 @@
|
|||
<span class="content">{{ message.text }}</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
<script lang="ts">
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||
|
||||
export default defineComponent({
|
||||
name: "MessageTypeRaw",
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -10,19 +10,27 @@
|
|||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import type {ClientMessage, ClientNetwork} from "../../js/types";
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "MessageTypeTopic",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -6,23 +6,33 @@
|
|||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import localetime from "../../js/helpers/localetime";
|
||||
import {computed, defineComponent, PropType} from "vue";
|
||||
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "MessageTypeTopicSetBy",
|
||||
components: {
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
computed: {
|
||||
messageTimeLocale() {
|
||||
return localetime(this.message.when);
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
setup(props) {
|
||||
const messageTimeLocale = computed(() => localetime(props.message.when));
|
||||
|
||||
return {
|
||||
messageTimeLocale,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -55,9 +55,9 @@
|
|||
</template>
|
||||
|
||||
<template v-if="message.whois.special">
|
||||
<template v-for="special in message.whois.special">
|
||||
<dt :key="special">Special:</dt>
|
||||
<dd :key="special">{{ special }}</dd>
|
||||
<template v-for="special in message.whois.special" :key="special">
|
||||
<dt>Special:</dt>
|
||||
<dd>{{ special }}</dd>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
|
@ -111,25 +111,33 @@
|
|||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import localetime from "../../js/helpers/localetime";
|
||||
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "MessageTypeWhois",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
message: Object,
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
methods: {
|
||||
localetime(date) {
|
||||
return localetime(date);
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
setup() {
|
||||
return {
|
||||
localetime: (date: Date) => localetime(date),
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -11,12 +11,14 @@
|
|||
</template>
|
||||
<template v-else>
|
||||
Connect
|
||||
<template v-if="config.lockNetwork && $store.state.serverConfiguration.public">
|
||||
<template
|
||||
v-if="config?.lockNetwork && store?.state.serverConfiguration?.public"
|
||||
>
|
||||
to {{ defaults.name }}
|
||||
</template>
|
||||
</template>
|
||||
</h1>
|
||||
<template v-if="!config.lockNetwork">
|
||||
<template v-if="!config?.lockNetwork">
|
||||
<h2>Network settings</h2>
|
||||
<template v-if="config.defaults.length > 0">
|
||||
<div class="connect-row">
|
||||
|
@ -36,7 +38,7 @@
|
|||
<label for="connect:name">Name</label>
|
||||
<input
|
||||
id="connect:name"
|
||||
v-model="defaults.name"
|
||||
v-model.trim="defaults.name"
|
||||
class="input"
|
||||
name="name"
|
||||
maxlength="100"
|
||||
|
@ -47,7 +49,7 @@
|
|||
<div class="input-wrap">
|
||||
<input
|
||||
id="connect:host"
|
||||
v-model="defaults.host"
|
||||
v-model.trim="defaults.host"
|
||||
class="input"
|
||||
name="host"
|
||||
aria-label="Server address"
|
||||
|
@ -134,7 +136,7 @@
|
|||
<div class="input-wrap">
|
||||
<input
|
||||
id="connect:proxyHost"
|
||||
v-model="defaults.proxyHost"
|
||||
v-model.trim="defaults.proxyHost"
|
||||
class="input"
|
||||
name="proxyHost"
|
||||
aria-label="Proxy host"
|
||||
|
@ -159,7 +161,7 @@
|
|||
<input
|
||||
id="connect:proxyUsername"
|
||||
ref="proxyUsernameInput"
|
||||
v-model="defaults.proxyUsername"
|
||||
v-model.trim="defaults.proxyUsername"
|
||||
class="input username"
|
||||
name="proxyUsername"
|
||||
maxlength="100"
|
||||
|
@ -245,13 +247,13 @@
|
|||
@input="onNickChanged"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="!config.useHexIp">
|
||||
<template v-if="!config?.useHexIp">
|
||||
<div class="connect-row">
|
||||
<label for="connect:username">Username</label>
|
||||
<input
|
||||
id="connect:username"
|
||||
ref="usernameInput"
|
||||
v-model="defaults.username"
|
||||
v-model.trim="defaults.username"
|
||||
class="input username"
|
||||
name="username"
|
||||
maxlength="100"
|
||||
|
@ -262,7 +264,7 @@
|
|||
<label for="connect:realname">Real name</label>
|
||||
<input
|
||||
id="connect:realname"
|
||||
v-model="defaults.realname"
|
||||
v-model.trim="defaults.realname"
|
||||
class="input"
|
||||
name="realname"
|
||||
maxlength="300"
|
||||
|
@ -272,14 +274,14 @@
|
|||
<label for="connect:leaveMessage">Leave message</label>
|
||||
<input
|
||||
id="connect:leaveMessage"
|
||||
v-model="defaults.leaveMessage"
|
||||
v-model.trim="defaults.leaveMessage"
|
||||
autocomplete="off"
|
||||
class="input"
|
||||
name="leaveMessage"
|
||||
placeholder="The Lounge - https://thelounge.chat"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="defaults.uuid && !$store.state.serverConfiguration.public">
|
||||
<template v-if="defaults.uuid && !store.state.serverConfiguration?.public">
|
||||
<div class="connect-row">
|
||||
<label for="connect:commands">
|
||||
Commands
|
||||
|
@ -308,15 +310,15 @@ the server tab on new connection"
|
|||
<label for="connect:channels">Channels</label>
|
||||
<input
|
||||
id="connect:channels"
|
||||
v-model="defaults.join"
|
||||
v-model.trim="defaults.join"
|
||||
class="input"
|
||||
name="join"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="$store.state.serverConfiguration.public">
|
||||
<template v-if="config.lockNetwork">
|
||||
<template v-if="store.state.serverConfiguration?.public">
|
||||
<template v-if="config?.lockNetwork">
|
||||
<div class="connect-row">
|
||||
<label></label>
|
||||
<div class="input-wrap">
|
||||
|
@ -370,7 +372,7 @@ the server tab on new connection"
|
|||
Username + password (SASL PLAIN)
|
||||
</label>
|
||||
<label
|
||||
v-if="!$store.state.serverConfiguration.public && defaults.tls"
|
||||
v-if="!store.state.serverConfiguration?.public && defaults.tls"
|
||||
class="opt"
|
||||
>
|
||||
<input
|
||||
|
@ -389,7 +391,7 @@ the server tab on new connection"
|
|||
<label for="connect:username">Account</label>
|
||||
<input
|
||||
id="connect:saslAccount"
|
||||
v-model="defaults.saslAccount"
|
||||
v-model.trim="defaults.saslAccount"
|
||||
class="input"
|
||||
name="saslAccount"
|
||||
maxlength="100"
|
||||
|
@ -462,98 +464,148 @@ the server tab on new connection"
|
|||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import RevealPassword from "./RevealPassword.vue";
|
||||
import SidebarToggle from "./SidebarToggle.vue";
|
||||
import {defineComponent, nextTick, PropType, ref, watch} from "vue";
|
||||
import {useStore} from "../js/store";
|
||||
import {ClientNetwork} from "../js/types";
|
||||
|
||||
export default {
|
||||
export type NetworkFormDefaults = Partial<ClientNetwork> & {
|
||||
join?: string;
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: "NetworkForm",
|
||||
components: {
|
||||
RevealPassword,
|
||||
SidebarToggle,
|
||||
},
|
||||
props: {
|
||||
handleSubmit: Function,
|
||||
defaults: Object,
|
||||
handleSubmit: {
|
||||
type: Function as PropType<(network: ClientNetwork) => void>,
|
||||
required: true,
|
||||
},
|
||||
defaults: {
|
||||
type: Object as PropType<NetworkFormDefaults>,
|
||||
required: true,
|
||||
},
|
||||
disabled: Boolean,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
config: this.$store.state.serverConfiguration,
|
||||
previousUsername: this.defaults.username,
|
||||
displayPasswordField: false,
|
||||
presetName: this.$store.state.serverConfiguration.defaults[0].name,
|
||||
setup(props) {
|
||||
const store = useStore();
|
||||
const config = ref(store.state.serverConfiguration);
|
||||
const previousUsername = ref(props.defaults?.username);
|
||||
const displayPasswordField = ref(false);
|
||||
const presetName = ref(store.state.serverConfiguration.defaults[0]?.name);
|
||||
|
||||
const publicPassword = ref<HTMLInputElement | null>(null);
|
||||
|
||||
watch(displayPasswordField, (newValue) => {
|
||||
if (newValue) {
|
||||
void nextTick(() => {
|
||||
publicPassword.value?.focus();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
watch(presetName, (newValue) => {
|
||||
const defaults = store.state.serverConfiguration.defaults.find(
|
||||
(def) => def.name === newValue
|
||||
);
|
||||
|
||||
if (!defaults) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.assign(props.defaults, defaults);
|
||||
});
|
||||
|
||||
const commandsInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const resizeCommandsInput = () => {
|
||||
if (!commandsInput.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset height first so it can down size
|
||||
commandsInput.value.style.height = "";
|
||||
|
||||
// 2 pixels to account for the border
|
||||
commandsInput.value.style.height = `${Math.ceil(
|
||||
commandsInput.value.scrollHeight + 2
|
||||
)}px`;
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
displayPasswordField(value) {
|
||||
if (value) {
|
||||
this.$nextTick(() => this.$refs.publicPassword.focus());
|
||||
|
||||
watch(
|
||||
() => props.defaults?.commands,
|
||||
() => {
|
||||
void nextTick(() => {
|
||||
resizeCommandsInput();
|
||||
});
|
||||
}
|
||||
},
|
||||
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) {
|
||||
);
|
||||
|
||||
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 (this.defaults.port === ports[newPort]) {
|
||||
this.defaults.port = ports[1 - newPort];
|
||||
if (props.defaults?.port === ports[newPort]) {
|
||||
props.defaults.port = ports[1 - newPort];
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
setSaslAuth(type) {
|
||||
this.defaults.sasl = type;
|
||||
},
|
||||
onNickChanged(event) {
|
||||
// Username input is not available when useHexIp is set
|
||||
if (!this.$refs.usernameInput) {
|
||||
}
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (
|
||||
!this.$refs.usernameInput.value ||
|
||||
this.$refs.usernameInput.value === this.previousUsername
|
||||
) {
|
||||
this.$refs.usernameInput.value = event.target.value;
|
||||
const usernameRef = usernameInput.value;
|
||||
|
||||
if (!usernameRef.value || usernameRef.value === previousUsername.value) {
|
||||
usernameRef.value = (event.target as HTMLInputElement)?.value;
|
||||
}
|
||||
|
||||
this.previousUsername = event.target.value;
|
||||
},
|
||||
onSubmit(event) {
|
||||
const formData = new FormData(event.target);
|
||||
const data = {};
|
||||
previousUsername.value = (event.target as HTMLInputElement)?.value;
|
||||
};
|
||||
|
||||
for (const item of formData.entries()) {
|
||||
data[item[0]] = item[1];
|
||||
}
|
||||
const onSubmit = (event: Event) => {
|
||||
const formData = new FormData(event.target as HTMLFormElement);
|
||||
const data: Partial<ClientNetwork> = {};
|
||||
|
||||
this.handleSubmit(data);
|
||||
},
|
||||
resizeCommandsInput() {
|
||||
if (!this.$refs.commandsInput) {
|
||||
return;
|
||||
}
|
||||
formData.forEach((value, key) => {
|
||||
data[key] = value;
|
||||
});
|
||||
|
||||
// Reset height first so it can down size
|
||||
this.$refs.commandsInput.style.height = "";
|
||||
props.handleSubmit(data as ClientNetwork);
|
||||
};
|
||||
|
||||
// 2 pixels to account for the border
|
||||
this.$refs.commandsInput.style.height =
|
||||
Math.ceil(this.$refs.commandsInput.scrollHeight + 2) + "px";
|
||||
return {
|
||||
store,
|
||||
config,
|
||||
displayPasswordField,
|
||||
presetName,
|
||||
publicPassword,
|
||||
commandsInput,
|
||||
resizeCommandsInput,
|
||||
setSaslAuth,
|
||||
usernameInput,
|
||||
onNickChanged,
|
||||
onSubmit,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div
|
||||
v-if="$store.state.networks.length === 0"
|
||||
v-if="store.state.networks.length === 0"
|
||||
class="empty"
|
||||
role="navigation"
|
||||
aria-label="Network and Channel list"
|
||||
|
@ -55,7 +55,7 @@
|
|||
</div>
|
||||
<Draggable
|
||||
v-else
|
||||
:list="$store.state.networks"
|
||||
:list="store.state.networks"
|
||||
:delay="LONG_TOUCH_DURATION"
|
||||
:delay-on-touch-only="true"
|
||||
:touch-start-threshold="10"
|
||||
|
@ -65,12 +65,13 @@
|
|||
drag-class="ui-sortable-dragging"
|
||||
group="networks"
|
||||
class="networks"
|
||||
item-key="uuid"
|
||||
@change="onNetworkSort"
|
||||
@choose="onDraggableChoose"
|
||||
@unchoose="onDraggableUnchoose"
|
||||
>
|
||||
<template v-slot:item="{element: network}">
|
||||
<div
|
||||
v-for="network in $store.state.networks"
|
||||
:id="'network-' + network.uuid"
|
||||
:key="network.uuid"
|
||||
:class="{
|
||||
|
@ -90,16 +91,20 @@
|
|||
:network="network"
|
||||
:is-join-channel-shown="network.isJoinChannelShown"
|
||||
:active="
|
||||
$store.state.activeChannel &&
|
||||
network.channels[0] === $store.state.activeChannel.channel
|
||||
store.state.activeChannel &&
|
||||
network.channels[0] === store.state.activeChannel.channel
|
||||
"
|
||||
@toggle-join-channel="
|
||||
network.isJoinChannelShown = !network.isJoinChannelShown
|
||||
"
|
||||
@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"
|
||||
@toggle-join-channel="
|
||||
network.isJoinChannelShown = !network.isJoinChannelShown
|
||||
"
|
||||
/>
|
||||
|
||||
<Draggable
|
||||
|
@ -112,24 +117,27 @@
|
|||
:delay-on-touch-only="true"
|
||||
:touch-start-threshold="10"
|
||||
class="channels"
|
||||
item-key="name"
|
||||
@change="onChannelSort"
|
||||
@choose="onDraggableChoose"
|
||||
@unchoose="onDraggableUnchoose"
|
||||
>
|
||||
<template v-for="(channel, index) in network.channels">
|
||||
<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
|
||||
store.state.activeChannel &&
|
||||
channel === store.state.activeChannel.channel
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</Draggable>
|
||||
</div>
|
||||
</template>
|
||||
</Draggable>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -195,21 +203,27 @@
|
|||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import {computed, watch, defineComponent, nextTick, onBeforeUnmount, onMounted, ref} from "vue";
|
||||
|
||||
import Mousetrap from "mousetrap";
|
||||
import Draggable from "vuedraggable";
|
||||
import Draggable from "./Draggable.vue";
|
||||
import {filter as fuzzyFilter} from "fuzzy";
|
||||
import NetworkLobby from "./NetworkLobby.vue";
|
||||
import Channel from "./Channel.vue";
|
||||
import JoinChannel from "./JoinChannel.vue";
|
||||
|
||||
import socket from "../js/socket";
|
||||
import collapseNetwork from "../js/helpers/collapseNetwork";
|
||||
import collapseNetworkHelper from "../js/helpers/collapseNetwork";
|
||||
import isIgnoredKeybind from "../js/helpers/isIgnoredKeybind";
|
||||
import distance from "../js/helpers/distance";
|
||||
import eventbus from "../js/eventbus";
|
||||
import {ClientChan, NetChan} from "../js/types";
|
||||
import {useStore} from "../js/store";
|
||||
import {switchToChannel} from "../js/router";
|
||||
import Sortable from "sortablejs";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "NetworkList",
|
||||
components: {
|
||||
JoinChannel,
|
||||
|
@ -217,120 +231,137 @@ export default {
|
|||
Channel,
|
||||
Draggable,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
searchText: "",
|
||||
activeSearchItem: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
items() {
|
||||
const items = [];
|
||||
setup() {
|
||||
const store = useStore();
|
||||
const searchText = ref("");
|
||||
const activeSearchItem = ref<ClientChan | null>();
|
||||
// Number of milliseconds a touch has to last to be considered long
|
||||
const LONG_TOUCH_DURATION = 500;
|
||||
|
||||
for (const network of this.$store.state.networks) {
|
||||
const startDrag = ref<[number, number] | null>();
|
||||
const searchInput = ref<HTMLInputElement | null>(null);
|
||||
const networklist = ref<HTMLDivElement | null>(null);
|
||||
|
||||
const sidebarWasClosed = ref(false);
|
||||
|
||||
const moveItemInArray = <T>(array: T[], from: number, to: number) => {
|
||||
const item = array.splice(from, 1)[0];
|
||||
array.splice(to, 0, item);
|
||||
};
|
||||
|
||||
const items = computed(() => {
|
||||
const newItems: NetChan[] = [];
|
||||
|
||||
for (const network of store.state.networks) {
|
||||
for (const channel of network.channels) {
|
||||
if (
|
||||
this.$store.state.activeChannel &&
|
||||
channel === this.$store.state.activeChannel.channel
|
||||
store.state.activeChannel &&
|
||||
channel === store.state.activeChannel.channel
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
items.push({network, channel});
|
||||
newItems.push({network, channel});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
},
|
||||
results() {
|
||||
const results = fuzzyFilter(this.searchText, this.items, {
|
||||
return newItems;
|
||||
});
|
||||
|
||||
const results = computed(() => {
|
||||
const newResults = fuzzyFilter(searchText.value, items.value, {
|
||||
extract: (item) => item.channel.name,
|
||||
}).map((item) => item.original);
|
||||
|
||||
return results;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
searchText() {
|
||||
this.setActiveSearchItem();
|
||||
},
|
||||
},
|
||||
created() {
|
||||
// Number of milliseconds a touch has to last to be considered long
|
||||
this.LONG_TOUCH_DURATION = 500;
|
||||
},
|
||||
mounted() {
|
||||
Mousetrap.bind("alt+shift+right", this.expandNetwork);
|
||||
Mousetrap.bind("alt+shift+left", this.collapseNetwork);
|
||||
Mousetrap.bind("alt+j", this.toggleSearch);
|
||||
},
|
||||
beforeDestroy() {
|
||||
Mousetrap.unbind("alt+shift+right", this.expandNetwork);
|
||||
Mousetrap.unbind("alt+shift+left", this.collapseNetwork);
|
||||
Mousetrap.unbind("alt+j", this.toggleSearch);
|
||||
},
|
||||
methods: {
|
||||
expandNetwork(event) {
|
||||
return newResults;
|
||||
});
|
||||
|
||||
const collapseNetwork = (event: Mousetrap.ExtendedKeyboardEvent) => {
|
||||
if (isIgnoredKeybind(event)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.$store.state.activeChannel) {
|
||||
collapseNetwork(this.$store.state.activeChannel.network, false);
|
||||
if (store.state.activeChannel) {
|
||||
collapseNetworkHelper(store.state.activeChannel.network, true);
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
collapseNetwork(event) {
|
||||
};
|
||||
|
||||
const expandNetwork = (event: Mousetrap.ExtendedKeyboardEvent) => {
|
||||
if (isIgnoredKeybind(event)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.$store.state.activeChannel) {
|
||||
collapseNetwork(this.$store.state.activeChannel.network, true);
|
||||
if (store.state.activeChannel) {
|
||||
collapseNetworkHelper(store.state.activeChannel.network, false);
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
onNetworkSort(e) {
|
||||
if (!e.moved) {
|
||||
};
|
||||
|
||||
const onNetworkSort = (e: Sortable.SortableEvent) => {
|
||||
const {oldIndex, newIndex} = e;
|
||||
|
||||
if (oldIndex === undefined || newIndex === undefined || oldIndex === newIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
moveItemInArray(store.state.networks, oldIndex, newIndex);
|
||||
|
||||
socket.emit("sort", {
|
||||
type: "networks",
|
||||
order: this.$store.state.networks.map((n) => n.uuid),
|
||||
order: store.state.networks.map((n) => n.uuid),
|
||||
});
|
||||
},
|
||||
onChannelSort(e) {
|
||||
if (!e.moved) {
|
||||
};
|
||||
|
||||
const onChannelSort = (e: Sortable.SortableEvent) => {
|
||||
let {oldIndex, newIndex} = e;
|
||||
|
||||
if (oldIndex === undefined || newIndex === undefined || oldIndex === newIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = this.$store.getters.findChannel(e.moved.element.id);
|
||||
// Indexes are offset by one due to the lobby
|
||||
oldIndex += 1;
|
||||
newIndex += 1;
|
||||
|
||||
if (!channel) {
|
||||
const unparsedId = e.item.getAttribute("data-item");
|
||||
|
||||
if (!unparsedId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = parseInt(unparsedId);
|
||||
const netChan = store.getters.findChannel(id);
|
||||
|
||||
if (!netChan) {
|
||||
return;
|
||||
}
|
||||
|
||||
moveItemInArray(netChan.network.channels, oldIndex, newIndex);
|
||||
|
||||
socket.emit("sort", {
|
||||
type: "channels",
|
||||
target: channel.network.uuid,
|
||||
order: channel.network.channels.map((c) => c.id),
|
||||
target: netChan.network.uuid,
|
||||
order: netChan.network.channels.map((c) => c.id),
|
||||
});
|
||||
},
|
||||
isTouchEvent(event) {
|
||||
};
|
||||
|
||||
const isTouchEvent = (event: any): boolean => {
|
||||
// This is the same way Sortable.js detects a touch event. See
|
||||
// SortableJS/Sortable@daaefeda:/src/Sortable.js#L465
|
||||
return (
|
||||
|
||||
return !!(
|
||||
(event.touches && event.touches[0]) ||
|
||||
(event.pointerType && event.pointerType === "touch")
|
||||
);
|
||||
},
|
||||
onDraggableChoose(event) {
|
||||
};
|
||||
|
||||
const onDraggableChoose = (event: any) => {
|
||||
const original = event.originalEvent;
|
||||
|
||||
if (this.isTouchEvent(original)) {
|
||||
if (isTouchEvent(original)) {
|
||||
// onDrag is only triggered when the user actually moves the
|
||||
// dragged object but onChoose is triggered as soon as the
|
||||
// item is eligible for dragging. This gives us an opportunity
|
||||
|
@ -338,120 +369,147 @@ export default {
|
|||
event.item.classList.add("ui-sortable-dragging-touch-cue");
|
||||
|
||||
if (original instanceof TouchEvent && original.touches.length > 0) {
|
||||
this.startDrag = [original.touches[0].clientX, original.touches[0].clientY];
|
||||
startDrag.value = [original.touches[0].clientX, original.touches[0].clientY];
|
||||
} else if (original instanceof PointerEvent) {
|
||||
this.startDrag = [original.clientX, original.clientY];
|
||||
startDrag.value = [original.clientX, original.clientY];
|
||||
}
|
||||
}
|
||||
},
|
||||
onDraggableUnchoose(event) {
|
||||
};
|
||||
|
||||
const onDraggableUnchoose = (event: any) => {
|
||||
event.item.classList.remove("ui-sortable-dragging-touch-cue");
|
||||
this.startDrag = null;
|
||||
},
|
||||
onDraggableTouchStart(event) {
|
||||
startDrag.value = null;
|
||||
};
|
||||
|
||||
const onDraggableTouchStart = (event: TouchEvent) => {
|
||||
if (event.touches.length === 1) {
|
||||
// This prevents an iOS long touch default behavior: selecting
|
||||
// the nearest selectable text.
|
||||
document.body.classList.add("force-no-select");
|
||||
}
|
||||
},
|
||||
onDraggableTouchMove(event) {
|
||||
if (this.startDrag && event.touches.length > 0) {
|
||||
};
|
||||
|
||||
const onDraggableTouchMove = (event: TouchEvent) => {
|
||||
if (startDrag.value && event.touches.length > 0) {
|
||||
const touch = event.touches[0];
|
||||
const currentPosition = [touch.clientX, touch.clientY];
|
||||
|
||||
if (distance(this.startDrag, currentPosition) > 10) {
|
||||
if (distance(startDrag.value, currentPosition as [number, number]) > 10) {
|
||||
// Context menu is shown on Android after long touch.
|
||||
// Dismiss it now that we're sure the user is dragging.
|
||||
eventbus.emit("contextmenu:cancel");
|
||||
}
|
||||
}
|
||||
},
|
||||
onDraggableTouchEnd(event) {
|
||||
};
|
||||
|
||||
const onDraggableTouchEnd = (event: TouchEvent) => {
|
||||
if (event.touches.length === 0) {
|
||||
document.body.classList.remove("force-no-select");
|
||||
}
|
||||
},
|
||||
toggleSearch(event) {
|
||||
};
|
||||
|
||||
const activateSearch = () => {
|
||||
if (searchInput.value === document.activeElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
sidebarWasClosed.value = store.state.sidebarOpen ? false : true;
|
||||
store.commit("sidebarOpen", true);
|
||||
|
||||
void nextTick(() => {
|
||||
searchInput.value?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
const deactivateSearch = () => {
|
||||
activeSearchItem.value = null;
|
||||
searchText.value = "";
|
||||
searchInput.value?.blur();
|
||||
|
||||
if (sidebarWasClosed.value) {
|
||||
store.commit("sidebarOpen", false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSearch = (event: Mousetrap.ExtendedKeyboardEvent) => {
|
||||
if (isIgnoredKeybind(event)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.$refs.searchInput === document.activeElement) {
|
||||
this.deactivateSearch();
|
||||
if (searchInput.value === document.activeElement) {
|
||||
deactivateSearch();
|
||||
return false;
|
||||
}
|
||||
|
||||
this.activateSearch();
|
||||
activateSearch();
|
||||
return false;
|
||||
},
|
||||
activateSearch() {
|
||||
if (this.$refs.searchInput === document.activeElement) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
this.sidebarWasClosed = this.$store.state.sidebarOpen ? false : true;
|
||||
this.$store.commit("sidebarOpen", true);
|
||||
this.$nextTick(() => {
|
||||
this.$refs.searchInput.focus();
|
||||
});
|
||||
},
|
||||
deactivateSearch() {
|
||||
this.activeSearchItem = null;
|
||||
this.searchText = "";
|
||||
this.$refs.searchInput.blur();
|
||||
const setSearchText = (e: Event) => {
|
||||
searchText.value = (e.target as HTMLInputElement).value;
|
||||
};
|
||||
|
||||
if (this.sidebarWasClosed) {
|
||||
this.$store.commit("sidebarOpen", false);
|
||||
}
|
||||
},
|
||||
setSearchText(e) {
|
||||
this.searchText = e.target.value;
|
||||
},
|
||||
setActiveSearchItem(channel) {
|
||||
if (!this.results.length) {
|
||||
const setActiveSearchItem = (channel?: ClientChan) => {
|
||||
if (!results.value.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!channel) {
|
||||
channel = this.results[0].channel;
|
||||
channel = results.value[0].channel;
|
||||
}
|
||||
|
||||
this.activeSearchItem = channel;
|
||||
},
|
||||
selectResult() {
|
||||
if (!this.searchText || !this.results.length) {
|
||||
activeSearchItem.value = channel;
|
||||
};
|
||||
|
||||
const scrollToActive = () => {
|
||||
// Scroll the list if needed after the active class is applied
|
||||
void nextTick(() => {
|
||||
const el = networklist.value?.querySelector(".channel-list-item.active");
|
||||
|
||||
if (el) {
|
||||
el.scrollIntoView({block: "nearest", inline: "nearest"});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const selectResult = () => {
|
||||
if (!searchText.value || !results.value.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$root.switchToChannel(this.activeSearchItem);
|
||||
this.deactivateSearch();
|
||||
this.scrollToActive();
|
||||
},
|
||||
navigateResults(event, direction) {
|
||||
if (activeSearchItem.value) {
|
||||
switchToChannel(activeSearchItem.value);
|
||||
deactivateSearch();
|
||||
scrollToActive();
|
||||
}
|
||||
};
|
||||
|
||||
const navigateResults = (event: Event, direction: number) => {
|
||||
// Prevent propagation to stop global keybind handler from capturing pagedown/pageup
|
||||
// and redirecting it to the message list container for scrolling
|
||||
event.stopImmediatePropagation();
|
||||
event.preventDefault();
|
||||
|
||||
if (!this.searchText) {
|
||||
if (!searchText.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const channels = this.results.map((r) => r.channel);
|
||||
const channels = results.value.map((r) => r.channel);
|
||||
|
||||
// Bail out if there's no channels to select
|
||||
if (!channels.length) {
|
||||
this.activeSearchItem = null;
|
||||
activeSearchItem.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let currentIndex = channels.indexOf(this.activeSearchItem);
|
||||
let currentIndex = activeSearchItem.value
|
||||
? channels.indexOf(activeSearchItem.value)
|
||||
: -1;
|
||||
|
||||
// If there's no active channel select the first or last one depending on direction
|
||||
if (!this.activeSearchItem || currentIndex === -1) {
|
||||
this.activeSearchItem = direction ? channels[0] : channels[channels.length - 1];
|
||||
this.scrollToActive();
|
||||
if (!activeSearchItem.value || currentIndex === -1) {
|
||||
activeSearchItem.value = direction ? channels[0] : channels[channels.length - 1];
|
||||
scrollToActive();
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -467,19 +525,54 @@ export default {
|
|||
currentIndex -= channels.length;
|
||||
}
|
||||
|
||||
this.activeSearchItem = channels[currentIndex];
|
||||
this.scrollToActive();
|
||||
},
|
||||
scrollToActive() {
|
||||
// Scroll the list if needed after the active class is applied
|
||||
this.$nextTick(() => {
|
||||
const el = this.$refs.networklist.querySelector(".channel-list-item.active");
|
||||
activeSearchItem.value = channels[currentIndex];
|
||||
scrollToActive();
|
||||
};
|
||||
|
||||
if (el) {
|
||||
el.scrollIntoView({block: "nearest", inline: "nearest"});
|
||||
}
|
||||
watch(searchText, () => {
|
||||
setActiveSearchItem();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
Mousetrap.bind("alt+shift+right", expandNetwork);
|
||||
Mousetrap.bind("alt+shift+left", collapseNetwork);
|
||||
Mousetrap.bind("alt+j", toggleSearch);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
Mousetrap.unbind("alt+shift+right");
|
||||
Mousetrap.unbind("alt+shift+left");
|
||||
Mousetrap.unbind("alt+j");
|
||||
});
|
||||
|
||||
const networkContainerRef = ref<HTMLDivElement>();
|
||||
const channelRefs = ref<{[key: string]: HTMLDivElement}>({});
|
||||
|
||||
return {
|
||||
store,
|
||||
networklist,
|
||||
searchInput,
|
||||
searchText,
|
||||
results,
|
||||
activeSearchItem,
|
||||
LONG_TOUCH_DURATION,
|
||||
|
||||
activateSearch,
|
||||
deactivateSearch,
|
||||
toggleSearch,
|
||||
setSearchText,
|
||||
setActiveSearchItem,
|
||||
scrollToActive,
|
||||
selectResult,
|
||||
navigateResults,
|
||||
onChannelSort,
|
||||
onNetworkSort,
|
||||
onDraggableTouchStart,
|
||||
onDraggableTouchMove,
|
||||
onDraggableTouchEnd,
|
||||
onDraggableChoose,
|
||||
onDraggableUnchoose,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -45,40 +45,57 @@
|
|||
</ChannelWrapper>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import {computed, defineComponent, PropType} from "vue";
|
||||
import collapseNetwork from "../js/helpers/collapseNetwork";
|
||||
import roundBadgeNumber from "../js/helpers/roundBadgeNumber";
|
||||
import ChannelWrapper from "./ChannelWrapper.vue";
|
||||
|
||||
export default {
|
||||
import type {ClientChan, ClientNetwork} from "../js/types";
|
||||
|
||||
export default defineComponent({
|
||||
name: "Channel",
|
||||
components: {
|
||||
ChannelWrapper,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
isJoinChannelShown: Boolean,
|
||||
active: Boolean,
|
||||
isFiltering: Boolean,
|
||||
},
|
||||
computed: {
|
||||
channel() {
|
||||
return this.network.channels[0];
|
||||
},
|
||||
joinChannelLabel() {
|
||||
return this.isJoinChannelShown ? "Cancel" : "Join a channel…";
|
||||
},
|
||||
unreadCount() {
|
||||
return roundBadgeNumber(this.channel.unread);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onCollapseClick() {
|
||||
collapseNetwork(this.network, !this.network.isCollapsed);
|
||||
},
|
||||
getExpandLabel(network) {
|
||||
emits: ["toggle-join-channel"],
|
||||
setup(props) {
|
||||
const channel = computed(() => {
|
||||
return props.network.channels[0];
|
||||
});
|
||||
|
||||
const joinChannelLabel = computed(() => {
|
||||
return props.isJoinChannelShown ? "Cancel" : "Join a channel…";
|
||||
});
|
||||
|
||||
const unreadCount = computed(() => {
|
||||
return roundBadgeNumber(channel.value.unread);
|
||||
});
|
||||
|
||||
const onCollapseClick = () => {
|
||||
collapseNetwork(props.network, !props.network.isCollapsed);
|
||||
};
|
||||
|
||||
const getExpandLabel = (network: ClientNetwork) => {
|
||||
return network.isCollapsed ? "Expand" : "Collapse";
|
||||
};
|
||||
|
||||
return {
|
||||
channel,
|
||||
joinChannelLabel,
|
||||
unreadCount,
|
||||
onCollapseClick,
|
||||
getExpandLabel,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -1,23 +1,22 @@
|
|||
<script>
|
||||
<script lang="ts">
|
||||
import {defineComponent, PropType, h} from "vue";
|
||||
import parse from "../js/helpers/parse";
|
||||
import type {ClientMessage, ClientNetwork} from "../js/types";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "ParsedMessage",
|
||||
functional: true,
|
||||
props: {
|
||||
text: String,
|
||||
message: Object,
|
||||
network: Object,
|
||||
message: {type: Object as PropType<ClientMessage | string>, required: false},
|
||||
network: {type: Object as PropType<ClientNetwork>, required: false},
|
||||
},
|
||||
render(createElement, context) {
|
||||
render(context) {
|
||||
return parse(
|
||||
createElement,
|
||||
typeof context.props.text !== "undefined"
|
||||
? context.props.text
|
||||
: context.props.message.text,
|
||||
context.props.message,
|
||||
context.props.network
|
||||
typeof context.text !== "undefined" ? context.text : context.message.text,
|
||||
context.message,
|
||||
context.network
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div>
|
||||
<slot :isVisible="isVisible" />
|
||||
<slot :is-visible="isVisible" />
|
||||
<span
|
||||
ref="revealButton"
|
||||
type="button"
|
||||
|
@ -16,18 +16,22 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
<script lang="ts">
|
||||
import {defineComponent, ref} from "vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "RevealPassword",
|
||||
data() {
|
||||
setup() {
|
||||
const isVisible = ref(false);
|
||||
|
||||
const onClick = () => {
|
||||
isVisible.value = !isVisible.value;
|
||||
};
|
||||
|
||||
return {
|
||||
isVisible: false,
|
||||
isVisible,
|
||||
onClick,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
onClick() {
|
||||
this.isVisible = !this.isVisible;
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -3,38 +3,64 @@
|
|||
v-if="activeChannel"
|
||||
:network="activeChannel.network"
|
||||
:channel="activeChannel.channel"
|
||||
:focused="$route.query.focused"
|
||||
:focused="parseInt(String(route.query.focused), 10)"
|
||||
@channel-changed="channelChanged"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import {watch, computed, defineComponent, onMounted} from "vue";
|
||||
import {useRoute} from "vue-router";
|
||||
import {useStore} from "../js/store";
|
||||
import {ClientChan} from "../js/types";
|
||||
|
||||
// Temporary component for routing channels and lobbies
|
||||
import Chat from "./Chat.vue";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "RoutedChat",
|
||||
components: {
|
||||
Chat,
|
||||
},
|
||||
computed: {
|
||||
activeChannel() {
|
||||
const chanId = parseInt(this.$route.params.id, 10);
|
||||
const channel = this.$store.getters.findChannel(chanId);
|
||||
setup() {
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
|
||||
const activeChannel = computed(() => {
|
||||
const chanId = parseInt(String(route.params.id || ""), 10);
|
||||
const channel = store.getters.findChannel(chanId);
|
||||
return channel;
|
||||
});
|
||||
|
||||
const setActiveChannel = () => {
|
||||
if (activeChannel.value) {
|
||||
store.commit("activeChannel", activeChannel.value);
|
||||
}
|
||||
};
|
||||
|
||||
watch(activeChannel, () => {
|
||||
setActiveChannel();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
setActiveChannel();
|
||||
});
|
||||
|
||||
const channelChanged = (channel: ClientChan) => {
|
||||
const chanId = channel.id;
|
||||
const chanInStore = store.getters.findChannel(chanId);
|
||||
|
||||
if (chanInStore?.channel) {
|
||||
chanInStore.channel.unread = 0;
|
||||
chanInStore.channel.highlight = 0;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
route,
|
||||
activeChannel,
|
||||
channelChanged,
|
||||
};
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
activeChannel() {
|
||||
this.setActiveChannel();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.setActiveChannel();
|
||||
},
|
||||
methods: {
|
||||
setActiveChannel() {
|
||||
this.$store.commit("activeChannel", this.activeChannel);
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -45,30 +45,39 @@
|
|||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import {computed, defineComponent, PropType} from "vue";
|
||||
import localetime from "../js/helpers/localetime";
|
||||
import Auth from "../js/auth";
|
||||
import socket from "../js/socket";
|
||||
import {ClientSession} from "../js/store";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "Session",
|
||||
props: {
|
||||
session: Object,
|
||||
},
|
||||
computed: {
|
||||
lastUse() {
|
||||
return localetime(this.session.lastUse);
|
||||
session: {
|
||||
type: Object as PropType<ClientSession>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
signOut() {
|
||||
if (!this.session.current) {
|
||||
socket.emit("sign-out", this.session.token);
|
||||
setup(props) {
|
||||
const lastUse = computed(() => {
|
||||
return localetime(props.session.lastUse);
|
||||
});
|
||||
|
||||
const signOut = () => {
|
||||
if (!props.session.current) {
|
||||
socket.emit("sign-out", props.session.token);
|
||||
} else {
|
||||
socket.emit("sign-out");
|
||||
Auth.signout();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
lastUse,
|
||||
signOut,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
195
client/components/Settings/Account.vue
Normal file
195
client/components/Settings/Account.vue
Normal 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>
|
179
client/components/Settings/Appearance.vue
Normal file
179
client/components/Settings/Appearance.vue
Normal 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>
|
175
client/components/Settings/General.vue
Normal file
175
client/components/Settings/General.vue
Normal 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>
|
103
client/components/Settings/Navigation.vue
Normal file
103
client/components/Settings/Navigation.vue
Normal 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>
|
199
client/components/Settings/Notifications.vue
Normal file
199
client/components/Settings/Notifications.vue
Normal 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>
|
43
client/components/Settings/SettingTabItem.vue
Normal file
43
client/components/Settings/SettingTabItem.vue
Normal 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>
|
|
@ -34,172 +34,234 @@
|
|||
class="tooltipped tooltipped-n tooltipped-no-touch"
|
||||
aria-label="Connect to network"
|
||||
><router-link
|
||||
v-slot:default="{navigate, isActive}"
|
||||
to="/connect"
|
||||
tag="button"
|
||||
active-class="active"
|
||||
:class="['icon', 'connect']"
|
||||
aria-label="Connect to network"
|
||||
role="tab"
|
||||
aria-controls="connect"
|
||||
:aria-selected="$route.name === 'Connect'"
|
||||
/></span>
|
||||
>
|
||||
<button
|
||||
:class="['icon', 'connect', {active: isActive}]"
|
||||
:aria-selected="isActive"
|
||||
@click="navigate"
|
||||
@keypress.enter="navigate"
|
||||
/> </router-link
|
||||
></span>
|
||||
<span class="tooltipped tooltipped-n tooltipped-no-touch" aria-label="Settings"
|
||||
><router-link
|
||||
v-slot:default="{navigate, isActive}"
|
||||
to="/settings"
|
||||
tag="button"
|
||||
active-class="active"
|
||||
:class="['icon', 'settings']"
|
||||
aria-label="Settings"
|
||||
role="tab"
|
||||
aria-controls="settings"
|
||||
:aria-selected="$route.name === 'Settings'"
|
||||
/></span>
|
||||
>
|
||||
<button
|
||||
:class="['icon', 'settings', {active: isActive}]"
|
||||
:aria-selected="isActive"
|
||||
@click="navigate"
|
||||
@keypress.enter="navigate"
|
||||
></button> </router-link
|
||||
></span>
|
||||
<span
|
||||
class="tooltipped tooltipped-n tooltipped-no-touch"
|
||||
:aria-label="
|
||||
$store.state.serverConfiguration.isUpdateAvailable
|
||||
store.state.serverConfiguration?.isUpdateAvailable
|
||||
? 'Help\n(update available)'
|
||||
: 'Help'
|
||||
"
|
||||
><router-link
|
||||
v-slot:default="{navigate, isActive}"
|
||||
to="/help"
|
||||
tag="button"
|
||||
active-class="active"
|
||||
role="tab"
|
||||
aria-controls="help"
|
||||
>
|
||||
<button
|
||||
:aria-selected="route.name === 'Help'"
|
||||
:class="[
|
||||
'icon',
|
||||
'help',
|
||||
{notified: $store.state.serverConfiguration.isUpdateAvailable},
|
||||
{notified: store.state.serverConfiguration?.isUpdateAvailable},
|
||||
{active: isActive},
|
||||
]"
|
||||
aria-label="Help"
|
||||
role="tab"
|
||||
aria-controls="help"
|
||||
:aria-selected="$route.name === 'Help'"
|
||||
/></span>
|
||||
@click="navigate"
|
||||
@keypress.enter="navigate"
|
||||
></button> </router-link
|
||||
></span>
|
||||
</footer>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import {defineComponent, onMounted, onUnmounted, PropType, ref} from "vue";
|
||||
import {useRoute} from "vue-router";
|
||||
import {useStore} from "../js/store";
|
||||
import NetworkList from "./NetworkList.vue";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "Sidebar",
|
||||
components: {
|
||||
NetworkList,
|
||||
},
|
||||
props: {
|
||||
overlay: HTMLElement,
|
||||
overlay: {type: Object as PropType<HTMLElement | null>, required: true},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isDevelopment: process.env.NODE_ENV !== "production",
|
||||
setup(props) {
|
||||
const isDevelopment = process.env.NODE_ENV !== "production";
|
||||
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
|
||||
const touchStartPos = ref<Touch | null>();
|
||||
const touchCurPos = ref<Touch | null>();
|
||||
const touchStartTime = ref<number>(0);
|
||||
const menuWidth = ref<number>(0);
|
||||
const menuIsMoving = ref<boolean>(false);
|
||||
const menuIsAbsolute = ref<boolean>(false);
|
||||
|
||||
const sidebar = ref<HTMLElement | null>(null);
|
||||
|
||||
const toggle = (state: boolean) => {
|
||||
store.commit("sidebarOpen", state);
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.touchStartPos = null;
|
||||
this.touchCurPos = null;
|
||||
this.touchStartTime = 0;
|
||||
this.menuWidth = 0;
|
||||
this.menuIsMoving = false;
|
||||
this.menuIsAbsolute = false;
|
||||
|
||||
this.onTouchStart = (e) => {
|
||||
this.touchStartPos = this.touchCurPos = e.touches.item(0);
|
||||
const onTouchMove = (e: TouchEvent) => {
|
||||
const touch = (touchCurPos.value = e.touches.item(0));
|
||||
|
||||
if (e.touches.length !== 1) {
|
||||
this.onTouchEnd();
|
||||
if (
|
||||
!touch ||
|
||||
!touchStartPos.value ||
|
||||
!touchStartPos.value.screenX ||
|
||||
!touchStartPos.value.screenY
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const styles = window.getComputedStyle(this.$refs.sidebar);
|
||||
let distX = touch.screenX - touchStartPos.value.screenX;
|
||||
const distY = touch.screenY - touchStartPos.value.screenY;
|
||||
|
||||
this.menuWidth = parseFloat(styles.width);
|
||||
this.menuIsAbsolute = styles.position === "absolute";
|
||||
|
||||
if (!this.$store.state.sidebarOpen || this.touchStartPos.screenX > this.menuWidth) {
|
||||
this.touchStartTime = Date.now();
|
||||
|
||||
document.body.addEventListener("touchmove", this.onTouchMove, {passive: true});
|
||||
document.body.addEventListener("touchend", this.onTouchEnd, {passive: true});
|
||||
}
|
||||
};
|
||||
|
||||
this.onTouchMove = (e) => {
|
||||
const touch = (this.touchCurPos = e.touches.item(0));
|
||||
let distX = touch.screenX - this.touchStartPos.screenX;
|
||||
const distY = touch.screenY - this.touchStartPos.screenY;
|
||||
|
||||
if (!this.menuIsMoving) {
|
||||
if (!menuIsMoving.value) {
|
||||
// tan(45°) is 1. Gestures in 0°-45° (< 1) are considered horizontal, so
|
||||
// menu must be open; gestures in 45°-90° (>1) are considered vertical, so
|
||||
// chat windows must be scrolled.
|
||||
if (Math.abs(distY / distX) >= 1) {
|
||||
this.onTouchEnd();
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
onTouchEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
const devicePixelRatio = window.devicePixelRatio || 2;
|
||||
|
||||
if (Math.abs(distX) > devicePixelRatio) {
|
||||
this.$store.commit("sidebarDragging", true);
|
||||
this.menuIsMoving = true;
|
||||
store.commit("sidebarDragging", true);
|
||||
menuIsMoving.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Do not animate the menu on desktop view
|
||||
if (!this.menuIsAbsolute) {
|
||||
if (!menuIsAbsolute.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.$store.state.sidebarOpen) {
|
||||
distX += this.menuWidth;
|
||||
if (store.state.sidebarOpen) {
|
||||
distX += menuWidth.value;
|
||||
}
|
||||
|
||||
if (distX > this.menuWidth) {
|
||||
distX = this.menuWidth;
|
||||
if (distX > menuWidth.value) {
|
||||
distX = menuWidth.value;
|
||||
} else if (distX < 0) {
|
||||
distX = 0;
|
||||
}
|
||||
|
||||
this.$refs.sidebar.style.transform = "translate3d(" + distX + "px, 0, 0)";
|
||||
this.overlay.style.opacity = distX / this.menuWidth;
|
||||
if (sidebar.value) {
|
||||
sidebar.value.style.transform = "translate3d(" + distX.toString() + "px, 0, 0)";
|
||||
}
|
||||
|
||||
if (props.overlay) {
|
||||
props.overlay.style.opacity = `${distX / menuWidth.value}`;
|
||||
}
|
||||
};
|
||||
|
||||
this.onTouchEnd = () => {
|
||||
const diff = this.touchCurPos.screenX - this.touchStartPos.screenX;
|
||||
const onTouchEnd = () => {
|
||||
if (!touchStartPos.value?.screenX || !touchCurPos.value?.screenX) {
|
||||
return;
|
||||
}
|
||||
|
||||
const diff = touchCurPos.value.screenX - touchStartPos.value.screenX;
|
||||
const absDiff = Math.abs(diff);
|
||||
|
||||
if (
|
||||
absDiff > this.menuWidth / 2 ||
|
||||
(Date.now() - this.touchStartTime < 180 && absDiff > 50)
|
||||
absDiff > menuWidth.value / 2 ||
|
||||
(Date.now() - touchStartTime.value < 180 && absDiff > 50)
|
||||
) {
|
||||
this.toggle(diff > 0);
|
||||
toggle(diff > 0);
|
||||
}
|
||||
|
||||
document.body.removeEventListener("touchmove", this.onTouchMove);
|
||||
document.body.removeEventListener("touchend", this.onTouchEnd);
|
||||
this.$store.commit("sidebarDragging", false);
|
||||
document.body.removeEventListener("touchmove", onTouchMove);
|
||||
document.body.removeEventListener("touchend", onTouchEnd);
|
||||
|
||||
this.$refs.sidebar.style.transform = null;
|
||||
this.overlay.style.opacity = null;
|
||||
store.commit("sidebarDragging", false);
|
||||
|
||||
this.touchStartPos = null;
|
||||
this.touchCurPos = null;
|
||||
this.touchStartTime = 0;
|
||||
this.menuIsMoving = false;
|
||||
if (sidebar.value) {
|
||||
sidebar.value.style.transform = "";
|
||||
}
|
||||
|
||||
if (props.overlay) {
|
||||
props.overlay.style.opacity = "";
|
||||
}
|
||||
|
||||
touchStartPos.value = null;
|
||||
touchCurPos.value = null;
|
||||
touchStartTime.value = 0;
|
||||
menuIsMoving.value = false;
|
||||
};
|
||||
|
||||
this.toggle = (state) => {
|
||||
this.$store.commit("sidebarOpen", state);
|
||||
const onTouchStart = (e: TouchEvent) => {
|
||||
if (!sidebar.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
touchStartPos.value = touchCurPos.value = e.touches.item(0);
|
||||
|
||||
if (e.touches.length !== 1) {
|
||||
onTouchEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
const styles = window.getComputedStyle(sidebar.value);
|
||||
|
||||
menuWidth.value = parseFloat(styles.width);
|
||||
menuIsAbsolute.value = styles.position === "absolute";
|
||||
|
||||
if (
|
||||
!store.state.sidebarOpen ||
|
||||
(touchStartPos.value?.screenX && touchStartPos.value.screenX > menuWidth.value)
|
||||
) {
|
||||
touchStartTime.value = Date.now();
|
||||
|
||||
document.body.addEventListener("touchmove", onTouchMove, {passive: true});
|
||||
document.body.addEventListener("touchend", onTouchEnd, {passive: true});
|
||||
}
|
||||
};
|
||||
|
||||
document.body.addEventListener("touchstart", this.onTouchStart, {passive: true});
|
||||
onMounted(() => {
|
||||
document.body.addEventListener("touchstart", onTouchStart, {passive: true});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.body.removeEventListener("touchstart", onTouchStart);
|
||||
});
|
||||
|
||||
const isPublic = () => document.body.classList.contains("public");
|
||||
|
||||
return {
|
||||
isDevelopment,
|
||||
store,
|
||||
route,
|
||||
sidebar,
|
||||
toggle,
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
isPublic,
|
||||
};
|
||||
},
|
||||
destroyed() {
|
||||
document.body.removeEventListener("touchstart", this.onTouchStart, {passive: true});
|
||||
},
|
||||
methods: {
|
||||
isPublic: () => document.body.classList.contains("public"),
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -1,9 +1,19 @@
|
|||
<template>
|
||||
<button class="lt" aria-label="Toggle channel list" @click="$store.commit('toggleSidebar')" />
|
||||
<button class="lt" aria-label="Toggle channel list" @click="store.commit('toggleSidebar')" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
<script lang="ts">
|
||||
import {defineComponent} from "vue";
|
||||
import {useStore} from "../js/store";
|
||||
|
||||
export default defineComponent({
|
||||
name: "SidebarToggle",
|
||||
};
|
||||
setup() {
|
||||
const store = useStore();
|
||||
|
||||
return {
|
||||
store,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -17,23 +17,29 @@
|
|||
</table>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import localetime from "../../js/helpers/localetime";
|
||||
import localeTime from "../../js/helpers/localetime";
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import type {ClientNetwork, ClientChan} from "../../js/types";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "ListBans",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
channel: Object,
|
||||
network: {type: Object as PropType<ClientNetwork>, required: true},
|
||||
channel: {type: Object as PropType<ClientChan>, required: true},
|
||||
},
|
||||
methods: {
|
||||
localetime(date) {
|
||||
return localetime(date);
|
||||
setup() {
|
||||
const localetime = (date: number | Date) => {
|
||||
return localeTime(date);
|
||||
};
|
||||
|
||||
return {
|
||||
localetime,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -18,17 +18,19 @@
|
|||
</table>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import {ClientChan, ClientNetwork} from "../../js/types";
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "ListChannels",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
channel: Object,
|
||||
network: {type: Object as PropType<ClientNetwork>, required: true},
|
||||
channel: {type: Object as PropType<ClientChan>, required: true},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -15,23 +15,25 @@
|
|||
</table>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import localetime from "../../js/helpers/localetime";
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import {ClientNetwork, ClientChan} from "../../js/types";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "ListIgnored",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
channel: Object,
|
||||
network: {type: Object as PropType<ClientNetwork>, required: true},
|
||||
channel: {type: Object as PropType<ClientChan>, required: true},
|
||||
},
|
||||
methods: {
|
||||
localetime(date) {
|
||||
return localetime(date);
|
||||
setup() {
|
||||
return {
|
||||
localetime,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -19,23 +19,25 @@
|
|||
</table>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import localetime from "../../js/helpers/localetime";
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import {ClientNetwork, ClientChan} from "../../js/types";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "ListInvites",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
},
|
||||
props: {
|
||||
network: Object,
|
||||
channel: Object,
|
||||
network: {type: Object as PropType<ClientNetwork>, required: true},
|
||||
channel: {type: Object as PropType<ClientChan>, required: true},
|
||||
},
|
||||
methods: {
|
||||
localetime(date) {
|
||||
return localetime(date);
|
||||
setup() {
|
||||
return {
|
||||
localetime: (date: Date) => localetime(date),
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -10,44 +10,71 @@
|
|||
>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import {computed, defineComponent, PropType} from "vue";
|
||||
import {UserInMessage} from "../../server/models/msg";
|
||||
import eventbus from "../js/eventbus";
|
||||
import colorClass from "../js/helpers/colorClass";
|
||||
import type {ClientChan, ClientNetwork, ClientUser} from "../js/types";
|
||||
|
||||
export default {
|
||||
type UsernameUser = Partial<UserInMessage> & {
|
||||
mode?: string;
|
||||
nick: string;
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: "Username",
|
||||
props: {
|
||||
user: Object,
|
||||
active: Boolean,
|
||||
onHover: Function,
|
||||
channel: Object,
|
||||
network: Object,
|
||||
user: {
|
||||
// TODO: UserInMessage shouldn't be necessary here.
|
||||
type: Object as PropType<UsernameUser | UserInMessage>,
|
||||
required: true,
|
||||
},
|
||||
computed: {
|
||||
mode() {
|
||||
active: Boolean,
|
||||
onHover: {
|
||||
type: Function as PropType<(user: UserInMessage) => void>,
|
||||
required: false,
|
||||
},
|
||||
channel: {type: Object as PropType<ClientChan>, required: false},
|
||||
network: {type: Object as PropType<ClientNetwork>, required: false},
|
||||
},
|
||||
setup(props) {
|
||||
const mode = computed(() => {
|
||||
// Message objects have a singular mode, but user objects have modes array
|
||||
if (this.user.modes) {
|
||||
return this.user.modes[0];
|
||||
if (props.user.modes) {
|
||||
return props.user.modes[0];
|
||||
}
|
||||
|
||||
return this.user.mode;
|
||||
},
|
||||
nickColor() {
|
||||
return colorClass(this.user.nick);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
hover() {
|
||||
return this.onHover(this.user);
|
||||
},
|
||||
openContextMenu(event) {
|
||||
return props.user.mode;
|
||||
});
|
||||
|
||||
// TODO: Nick must be ! because our user prop union includes UserInMessage
|
||||
const nickColor = computed(() => colorClass(props.user.nick!));
|
||||
|
||||
const hover = () => {
|
||||
if (props.onHover) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return props.onHover(props.user as UserInMessage);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const openContextMenu = (event: Event) => {
|
||||
eventbus.emit("contextmenu:user", {
|
||||
event: event,
|
||||
user: this.user,
|
||||
network: this.network,
|
||||
channel: this.channel,
|
||||
user: props.user,
|
||||
network: props.network,
|
||||
channel: props.channel,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
mode,
|
||||
nickColor,
|
||||
hover,
|
||||
openContextMenu,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -1,25 +1,25 @@
|
|||
<template>
|
||||
<div id="version-checker" :class="[$store.state.versionStatus]">
|
||||
<p v-if="$store.state.versionStatus === 'loading'">Checking for updates…</p>
|
||||
<p v-if="$store.state.versionStatus === 'new-version'">
|
||||
The Lounge <b>{{ $store.state.versionData.latest.version }}</b>
|
||||
<template v-if="$store.state.versionData.latest.prerelease"> (pre-release) </template>
|
||||
<div id="version-checker" :class="[store.state.versionStatus]">
|
||||
<p v-if="store.state.versionStatus === 'loading'">Checking for updates…</p>
|
||||
<p v-if="store.state.versionStatus === 'new-version'">
|
||||
The Lounge <b>{{ store.state.versionData?.latest.version }}</b>
|
||||
<template v-if="store.state.versionData?.latest.prerelease"> (pre-release) </template>
|
||||
is now available.
|
||||
<br />
|
||||
|
||||
<a :href="$store.state.versionData.latest.url" target="_blank" rel="noopener">
|
||||
<a :href="store.state.versionData?.latest.url" target="_blank" rel="noopener">
|
||||
Read more on GitHub
|
||||
</a>
|
||||
</p>
|
||||
<p v-if="$store.state.versionStatus === 'new-packages'">
|
||||
<p v-if="store.state.versionStatus === 'new-packages'">
|
||||
The Lounge is up to date, but there are out of date packages Run
|
||||
<code>thelounge upgrade</code> on the server to upgrade packages.
|
||||
</p>
|
||||
<template v-if="$store.state.versionStatus === 'up-to-date'">
|
||||
<template v-if="store.state.versionStatus === 'up-to-date'">
|
||||
<p>The Lounge is up to date!</p>
|
||||
|
||||
<button
|
||||
v-if="$store.state.versionDataExpired"
|
||||
v-if="store.state.versionDataExpired"
|
||||
id="check-now"
|
||||
class="btn btn-small"
|
||||
@click="checkNow"
|
||||
|
@ -27,7 +27,7 @@
|
|||
Check now
|
||||
</button>
|
||||
</template>
|
||||
<template v-if="$store.state.versionStatus === 'error'">
|
||||
<template v-if="store.state.versionStatus === 'error'">
|
||||
<p>Information about latest release could not be retrieved.</p>
|
||||
|
||||
<button id="check-now" class="btn btn-small" @click="checkNow">Try again</button>
|
||||
|
@ -35,22 +35,32 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import {defineComponent, onMounted} from "vue";
|
||||
import socket from "../js/socket";
|
||||
import {useStore} from "../js/store";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "VersionChecker",
|
||||
mounted() {
|
||||
if (!this.$store.state.versionData) {
|
||||
this.checkNow();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
checkNow() {
|
||||
this.$store.commit("versionData", null);
|
||||
this.$store.commit("versionStatus", "loading");
|
||||
setup() {
|
||||
const store = useStore();
|
||||
|
||||
const checkNow = () => {
|
||||
store.commit("versionData", null);
|
||||
store.commit("versionStatus", "loading");
|
||||
socket.emit("changelog");
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (!store.state.versionData) {
|
||||
checkNow();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
store,
|
||||
checkNow,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -7,29 +7,26 @@
|
|||
<router-link id="back-to-help" to="/help">« Help</router-link>
|
||||
|
||||
<template
|
||||
v-if="
|
||||
$store.state.versionData &&
|
||||
$store.state.versionData.current &&
|
||||
$store.state.versionData.current.version
|
||||
"
|
||||
v-if="store.state.versionData?.current && store.state.versionData?.current.version"
|
||||
>
|
||||
<h1 class="title">
|
||||
Release notes for {{ $store.state.versionData.current.version }}
|
||||
Release notes for {{ store.state.versionData.current.version }}
|
||||
</h1>
|
||||
|
||||
<template v-if="$store.state.versionData.current.changelog">
|
||||
<template v-if="store.state.versionData.current.changelog">
|
||||
<h3>Introduction</h3>
|
||||
<div
|
||||
ref="changelog"
|
||||
class="changelog-text"
|
||||
v-html="$store.state.versionData.current.changelog"
|
||||
v-html="store.state.versionData.current.changelog"
|
||||
></div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p>Unable to retrieve changelog for current release from GitHub.</p>
|
||||
<p>
|
||||
<a
|
||||
:href="`https://github.com/thelounge/thelounge/releases/tag/v${$store.state.serverConfiguration.version}`"
|
||||
v-if="store.state.serverConfiguration?.version"
|
||||
:href="`https://github.com/thelounge/thelounge/releases/tag/v${store.state.serverConfiguration?.version}`"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>View release notes for this version on GitHub</a
|
||||
|
@ -42,34 +39,29 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import {defineComponent, onMounted, onUpdated, ref} from "vue";
|
||||
import socket from "../../js/socket";
|
||||
import {useStore} from "../../js/store";
|
||||
import SidebarToggle from "../SidebarToggle.vue";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "Changelog",
|
||||
components: {
|
||||
SidebarToggle,
|
||||
},
|
||||
mounted() {
|
||||
if (!this.$store.state.versionData) {
|
||||
socket.emit("changelog");
|
||||
}
|
||||
setup() {
|
||||
const store = useStore();
|
||||
const changelog = ref<HTMLDivElement | null>(null);
|
||||
|
||||
this.patchChangelog();
|
||||
},
|
||||
updated() {
|
||||
this.patchChangelog();
|
||||
},
|
||||
methods: {
|
||||
patchChangelog() {
|
||||
if (!this.$refs.changelog) {
|
||||
const patchChangelog = () => {
|
||||
if (!changelog.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const links = this.$refs.changelog.querySelectorAll("a");
|
||||
const links = changelog.value.querySelectorAll("a");
|
||||
|
||||
for (const link of links) {
|
||||
links.forEach((link) => {
|
||||
// Make sure all links will open a new tab instead of exiting the application
|
||||
link.setAttribute("target", "_blank");
|
||||
link.setAttribute("rel", "noopener");
|
||||
|
@ -78,8 +70,24 @@ export default {
|
|||
// Add required metadata to image links, to support built-in image viewer
|
||||
link.classList.add("toggle-thumbnail");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (!store.state.versionData) {
|
||||
socket.emit("changelog");
|
||||
}
|
||||
|
||||
patchChangelog();
|
||||
});
|
||||
|
||||
onUpdated(() => {
|
||||
patchChangelog();
|
||||
});
|
||||
|
||||
return {
|
||||
store,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -2,11 +2,14 @@
|
|||
<NetworkForm :handle-submit="handleSubmit" :defaults="defaults" :disabled="disabled" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import socket from "../../js/socket";
|
||||
import NetworkForm from "../NetworkForm.vue";
|
||||
<script lang="ts">
|
||||
import {defineComponent, ref} from "vue";
|
||||
|
||||
export default {
|
||||
import socket from "../../js/socket";
|
||||
import {useStore} from "../../js/store";
|
||||
import NetworkForm, {NetworkFormDefaults} from "../NetworkForm.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "Connect",
|
||||
components: {
|
||||
NetworkForm,
|
||||
|
@ -14,26 +17,22 @@ export default {
|
|||
props: {
|
||||
queryParams: Object,
|
||||
},
|
||||
data() {
|
||||
// Merge settings from url params into default settings
|
||||
const defaults = Object.assign(
|
||||
{},
|
||||
this.$store.state.serverConfiguration.defaults[0],
|
||||
this.parseOverrideParams(this.queryParams)
|
||||
);
|
||||
return {
|
||||
disabled: false,
|
||||
defaultNetworks: this.$store.state.serverConfiguration,
|
||||
defaults,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
handleSubmit(data) {
|
||||
this.disabled = true;
|
||||
setup(props) {
|
||||
const store = useStore();
|
||||
|
||||
const disabled = ref(false);
|
||||
|
||||
const handleSubmit = (data: Record<string, any>) => {
|
||||
disabled.value = true;
|
||||
socket.emit("network:new", data);
|
||||
},
|
||||
parseOverrideParams(params) {
|
||||
const parsedParams = {};
|
||||
};
|
||||
|
||||
const parseOverrideParams = (params?: Record<string, string>) => {
|
||||
if (!params) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const parsedParams: Record<string, any> = {};
|
||||
|
||||
for (let key of Object.keys(params)) {
|
||||
let value = params[key];
|
||||
|
@ -50,7 +49,7 @@ export default {
|
|||
|
||||
if (
|
||||
!Object.prototype.hasOwnProperty.call(
|
||||
this.$store.state.serverConfiguration.defaults[0],
|
||||
store.state.serverConfiguration?.defaults[0],
|
||||
key
|
||||
)
|
||||
) {
|
||||
|
@ -58,16 +57,13 @@ export default {
|
|||
}
|
||||
|
||||
// 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)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Network name is only disabled if there is a single network
|
||||
if (
|
||||
this.$store.state.serverConfiguration.defaults.length < 2 &&
|
||||
key === "name"
|
||||
) {
|
||||
if (key === "name" && store.state.serverConfiguration?.defaults.length < 2) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
@ -86,7 +82,7 @@ export default {
|
|||
}
|
||||
|
||||
// Override server provided defaults with parameters passed in the URL if they match the data type
|
||||
switch (typeof this.$store.state.serverConfiguration.defaults[0][key]) {
|
||||
switch (typeof store.state.serverConfiguration?.defaults[0][key]) {
|
||||
case "boolean":
|
||||
if (value === "0" || value === "false") {
|
||||
parsedParams[key] = false;
|
||||
|
@ -105,7 +101,21 @@ export default {
|
|||
}
|
||||
|
||||
return parsedParams;
|
||||
};
|
||||
|
||||
const defaults = ref<Partial<NetworkFormDefaults>>(
|
||||
Object.assign(
|
||||
{},
|
||||
store.state.serverConfiguration?.defaults[0],
|
||||
parseOverrideParams(props.queryParams)
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
defaults,
|
||||
disabled,
|
||||
handleSubmit,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
<h2 class="help-version-title">
|
||||
<span>About The Lounge</span>
|
||||
<small>
|
||||
v{{ $store.state.serverConfiguration.version }} (<router-link
|
||||
v{{ store.state.serverConfiguration?.version }} (<router-link
|
||||
id="view-changelog"
|
||||
to="/changelog"
|
||||
>release notes</router-link
|
||||
|
@ -20,13 +20,13 @@
|
|||
<div class="about">
|
||||
<VersionChecker />
|
||||
|
||||
<template v-if="$store.state.serverConfiguration.gitCommit">
|
||||
<template v-if="store.state.serverConfiguration?.gitCommit">
|
||||
<p>
|
||||
The Lounge is running from source (<a
|
||||
:href="`https://github.com/thelounge/thelounge/tree/${$store.state.serverConfiguration.gitCommit}`"
|
||||
:href="`https://github.com/thelounge/thelounge/tree/${store.state.serverConfiguration?.gitCommit}`"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>commit <code>{{ $store.state.serverConfiguration.gitCommit }}</code></a
|
||||
>commit <code>{{ store.state.serverConfiguration?.gitCommit }}</code></a
|
||||
>).
|
||||
</p>
|
||||
|
||||
|
@ -34,11 +34,11 @@
|
|||
<li>
|
||||
Compare
|
||||
<a
|
||||
:href="`https://github.com/thelounge/thelounge/compare/${$store.state.serverConfiguration.gitCommit}...master`"
|
||||
:href="`https://github.com/thelounge/thelounge/compare/${store.state.serverConfiguration?.gitCommit}...master`"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>between
|
||||
<code>{{ $store.state.serverConfiguration.gitCommit }}</code> and
|
||||
<code>{{ store.state.serverConfiguration?.gitCommit }}</code> and
|
||||
<code>master</code></a
|
||||
>
|
||||
to see what you are missing
|
||||
|
@ -46,12 +46,12 @@
|
|||
<li>
|
||||
Compare
|
||||
<a
|
||||
:href="`https://github.com/thelounge/thelounge/compare/${$store.state.serverConfiguration.version}...${$store.state.serverConfiguration.gitCommit}`"
|
||||
:href="`https://github.com/thelounge/thelounge/compare/${store.state.serverConfiguration?.version}...${store.state.serverConfiguration?.gitCommit}`"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>between
|
||||
<code>{{ $store.state.serverConfiguration.version }}</code> and
|
||||
<code>{{ $store.state.serverConfiguration.gitCommit }}</code></a
|
||||
<code>{{ store.state.serverConfiguration?.version }}</code> and
|
||||
<code>{{ store.state.serverConfiguration?.gitCommit }}</code></a
|
||||
>
|
||||
to see your local changes
|
||||
</li>
|
||||
|
@ -749,7 +749,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="$store.state.settings.searchEnabled" class="help-item">
|
||||
<div v-if="store.state.settings.searchEnabled" class="help-item">
|
||||
<div class="subject">
|
||||
<code>/search query</code>
|
||||
</div>
|
||||
|
@ -829,21 +829,28 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import {defineComponent, ref} from "vue";
|
||||
import {useStore} from "../../js/store";
|
||||
import SidebarToggle from "../SidebarToggle.vue";
|
||||
import VersionChecker from "../VersionChecker.vue";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "Help",
|
||||
components: {
|
||||
SidebarToggle,
|
||||
VersionChecker,
|
||||
},
|
||||
data() {
|
||||
setup() {
|
||||
const store = useStore();
|
||||
const isApple = navigator.platform.match(/(Mac|iPhone|iPod|iPad)/i) || false;
|
||||
const isTouch = navigator.maxTouchPoints > 0;
|
||||
|
||||
return {
|
||||
isApple: navigator.platform.match(/(Mac|iPhone|iPod|iPad)/i) || false,
|
||||
isTouch: navigator.maxTouchPoints > 0,
|
||||
isApple,
|
||||
isTouch,
|
||||
store,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -7,44 +7,61 @@
|
|||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import {defineComponent, onMounted, ref, watch} from "vue";
|
||||
import {useRoute} from "vue-router";
|
||||
import {switchToChannel} from "../../js/router";
|
||||
import socket from "../../js/socket";
|
||||
import NetworkForm from "../NetworkForm.vue";
|
||||
import {useStore} from "../../js/store";
|
||||
import NetworkForm, {NetworkFormDefaults} from "../NetworkForm.vue";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "NetworkEdit",
|
||||
components: {
|
||||
NetworkForm,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
disabled: false,
|
||||
networkData: null,
|
||||
setup() {
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
|
||||
const disabled = ref(false);
|
||||
const networkData = ref<NetworkFormDefaults | null>(null);
|
||||
|
||||
const setNetworkData = () => {
|
||||
socket.emit("network:get", String(route.params.uuid || ""));
|
||||
networkData.value = store.getters.findNetwork(String(route.params.uuid || ""));
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
"$route.params.uuid"() {
|
||||
this.setNetworkData();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.setNetworkData();
|
||||
},
|
||||
methods: {
|
||||
setNetworkData() {
|
||||
socket.emit("network:get", this.$route.params.uuid);
|
||||
this.networkData = this.$store.getters.findNetwork(this.$route.params.uuid);
|
||||
},
|
||||
handleSubmit(data) {
|
||||
this.disabled = true;
|
||||
|
||||
const handleSubmit = (data: {uuid: string; name: string}) => {
|
||||
disabled.value = true;
|
||||
socket.emit("network:edit", data);
|
||||
|
||||
// TODO: move networks to vuex and update state when the network info comes in
|
||||
const network = this.$store.getters.findNetwork(data.uuid);
|
||||
const network = store.getters.findNetwork(data.uuid);
|
||||
|
||||
if (network) {
|
||||
network.name = network.channels[0].name = data.name;
|
||||
|
||||
this.$root.switchToChannel(network.channels[0]);
|
||||
switchToChannel(network.channels[0]);
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => route.params.uuid,
|
||||
(newValue) => {
|
||||
setNetworkData();
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
setNetworkData();
|
||||
});
|
||||
|
||||
return {
|
||||
disabled,
|
||||
networkData,
|
||||
handleSubmit,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -3,9 +3,9 @@
|
|||
<div
|
||||
id="chat"
|
||||
:class="{
|
||||
'colored-nicks': $store.state.settings.coloredNicks,
|
||||
'time-seconds': $store.state.settings.showSeconds,
|
||||
'time-12h': $store.state.settings.use12hClock,
|
||||
'colored-nicks': store.state.settings.coloredNicks,
|
||||
'time-seconds': store.state.settings.showSeconds,
|
||||
'time-12h': store.state.settings.use12hClock,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
|
@ -14,12 +14,12 @@
|
|||
aria-label="Search results"
|
||||
role="tabpanel"
|
||||
>
|
||||
<div class="header">
|
||||
<div v-if="network && channel" class="header">
|
||||
<SidebarToggle />
|
||||
<span class="title"
|
||||
>Searching in <span class="channel-name">{{ channel.name }}</span> for</span
|
||||
>
|
||||
<span class="topic">{{ $route.query.q }}</span>
|
||||
<span class="topic">{{ route.query.q }}</span>
|
||||
<MessageSearchForm :network="network" :channel="channel" />
|
||||
<button
|
||||
class="close"
|
||||
|
@ -28,25 +28,24 @@
|
|||
@click="closeSearch"
|
||||
/>
|
||||
</div>
|
||||
<div class="chat-content">
|
||||
<div v-if="network && channel" class="chat-content">
|
||||
<div ref="chat" class="chat" tabindex="-1">
|
||||
<div v-show="moreResultsAvailable" class="show-more">
|
||||
<button
|
||||
ref="loadMoreButton"
|
||||
:disabled="
|
||||
$store.state.messageSearchInProgress ||
|
||||
!$store.state.isConnected
|
||||
store.state.messageSearchInProgress || !store.state.isConnected
|
||||
"
|
||||
class="btn"
|
||||
@click="onShowMoreClick"
|
||||
>
|
||||
<span v-if="$store.state.messageSearchInProgress">Loading…</span>
|
||||
<span v-if="store.state.messageSearchInProgress">Loading…</span>
|
||||
<span v-else>Show older messages</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="$store.state.messageSearchInProgress && !offset"
|
||||
v-if="store.state.messageSearchInProgress && !offset"
|
||||
class="search-status"
|
||||
>
|
||||
Searching…
|
||||
|
@ -55,17 +54,20 @@
|
|||
No results found.
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="messages"
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
aria-relevant="additions"
|
||||
>
|
||||
<template v-for="(message, id) in messages">
|
||||
<div :key="message.id" class="result" @:click="jump(message, id)">
|
||||
<div
|
||||
v-for="(message, id) in messages"
|
||||
:key="message.id"
|
||||
class="result"
|
||||
@click="jump(message, id)"
|
||||
>
|
||||
<DateMarker
|
||||
v-if="shouldDisplayDateMarker(message, id)"
|
||||
:key="message.date"
|
||||
:key="message.id + '-date'"
|
||||
:message="message"
|
||||
/>
|
||||
<Message
|
||||
|
@ -76,7 +78,6 @@
|
|||
:data-id="message.id"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -91,7 +92,7 @@
|
|||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import socket from "../../js/socket";
|
||||
import eventbus from "../../js/eventbus";
|
||||
|
||||
|
@ -99,8 +100,14 @@ import SidebarToggle from "../SidebarToggle.vue";
|
|||
import Message from "../Message.vue";
|
||||
import MessageSearchForm from "../MessageSearchForm.vue";
|
||||
import DateMarker from "../DateMarker.vue";
|
||||
import {watch, computed, defineComponent, nextTick, ref, onMounted, onUnmounted} from "vue";
|
||||
import type {ClientMessage} from "../../js/types";
|
||||
|
||||
export default {
|
||||
import {useStore} from "../../js/store";
|
||||
import {useRoute, useRouter} from "vue-router";
|
||||
import {switchToChannel} from "../../js/router";
|
||||
|
||||
export default defineComponent({
|
||||
name: "SearchResults",
|
||||
components: {
|
||||
SidebarToggle,
|
||||
|
@ -108,145 +115,212 @@ export default {
|
|||
DateMarker,
|
||||
MessageSearchForm,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
offset: 0,
|
||||
moreResultsAvailable: false,
|
||||
oldScrollTop: 0,
|
||||
oldChatHeight: 0,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
search() {
|
||||
return this.$store.state.messageSearchResults;
|
||||
},
|
||||
messages() {
|
||||
if (!this.search) {
|
||||
setup() {
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const chat = ref<HTMLDivElement>();
|
||||
|
||||
const loadMoreButton = ref<HTMLButtonElement>();
|
||||
|
||||
const offset = ref(0);
|
||||
const moreResultsAvailable = ref(false);
|
||||
const oldScrollTop = ref(0);
|
||||
const oldChatHeight = ref(0);
|
||||
|
||||
const search = computed(() => store.state.messageSearchResults);
|
||||
const messages = computed(() => {
|
||||
if (!search.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.search.results;
|
||||
},
|
||||
chan() {
|
||||
const chanId = parseInt(this.$route.params.id, 10);
|
||||
return this.$store.getters.findChannel(chanId);
|
||||
},
|
||||
network() {
|
||||
if (!this.chan) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.chan.network;
|
||||
},
|
||||
channel() {
|
||||
if (!this.chan) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.chan.channel;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
"$route.params.id"() {
|
||||
this.doSearch();
|
||||
this.setActiveChannel();
|
||||
},
|
||||
"$route.query.q"() {
|
||||
this.doSearch();
|
||||
this.setActiveChannel();
|
||||
},
|
||||
messages() {
|
||||
this.moreResultsAvailable = this.messages.length && !(this.messages.length % 100);
|
||||
|
||||
if (!this.offset) {
|
||||
this.jumpToBottom();
|
||||
} else {
|
||||
this.$nextTick(() => {
|
||||
const currentChatHeight = this.$refs.chat.scrollHeight;
|
||||
this.$refs.chat.scrollTop =
|
||||
this.oldScrollTop + currentChatHeight - this.oldChatHeight;
|
||||
return search.value.results;
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.setActiveChannel();
|
||||
this.doSearch();
|
||||
|
||||
eventbus.on("escapekey", this.closeSearch);
|
||||
this.$root.$on("re-search", this.doSearch); // Enable MessageSearchForm to search for the same query again
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$root.$off("re-search");
|
||||
},
|
||||
destroyed() {
|
||||
eventbus.off("escapekey", this.closeSearch);
|
||||
},
|
||||
methods: {
|
||||
setActiveChannel() {
|
||||
this.$store.commit("activeChannel", this.chan);
|
||||
},
|
||||
closeSearch() {
|
||||
this.$root.switchToChannel(this.channel);
|
||||
},
|
||||
shouldDisplayDateMarker(message, id) {
|
||||
const previousMessage = this.messages[id - 1];
|
||||
const chan = computed(() => {
|
||||
const chanId = parseInt(String(route.params.id || ""), 10);
|
||||
return store.getters.findChannel(chanId);
|
||||
});
|
||||
|
||||
const network = computed(() => {
|
||||
if (!chan.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return chan.value.network;
|
||||
});
|
||||
|
||||
const channel = computed(() => {
|
||||
if (!chan.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return chan.value.channel;
|
||||
});
|
||||
|
||||
const setActiveChannel = () => {
|
||||
if (!chan.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
store.commit("activeChannel", chan.value);
|
||||
};
|
||||
|
||||
const closeSearch = () => {
|
||||
if (!channel.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
switchToChannel(channel.value);
|
||||
};
|
||||
|
||||
const shouldDisplayDateMarker = (message: ClientMessage, id: number) => {
|
||||
const previousMessage = messages.value[id - 1];
|
||||
|
||||
if (!previousMessage) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return new Date(previousMessage.time).getDay() !== new Date(message.time).getDay();
|
||||
},
|
||||
doSearch() {
|
||||
this.offset = 0;
|
||||
this.$store.commit("messageSearchInProgress", true);
|
||||
};
|
||||
|
||||
if (!this.offset) {
|
||||
this.$store.commit("messageSearchResults", null); // Only reset if not getting offset
|
||||
const doSearch = () => {
|
||||
offset.value = 0;
|
||||
store.commit("messageSearchInProgress", true);
|
||||
|
||||
if (!offset.value) {
|
||||
store.commit("messageSearchInProgress", undefined); // Only reset if not getting offset
|
||||
}
|
||||
|
||||
socket.emit("search", {
|
||||
networkUuid: this.network.uuid,
|
||||
channelName: this.channel.name,
|
||||
searchTerm: this.$route.query.q,
|
||||
offset: this.offset,
|
||||
networkUuid: network.value?.uuid,
|
||||
channelName: channel.value?.name,
|
||||
searchTerm: String(route.query.q || ""),
|
||||
offset: offset.value,
|
||||
});
|
||||
},
|
||||
onShowMoreClick() {
|
||||
this.offset += 100;
|
||||
this.$store.commit("messageSearchInProgress", true);
|
||||
};
|
||||
|
||||
this.oldScrollTop = this.$refs.chat.scrollTop;
|
||||
this.oldChatHeight = this.$refs.chat.scrollHeight;
|
||||
const onShowMoreClick = () => {
|
||||
if (!chat.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
offset.value += 100;
|
||||
store.commit("messageSearchInProgress", true);
|
||||
|
||||
oldScrollTop.value = chat.value.scrollTop;
|
||||
oldChatHeight.value = chat.value.scrollHeight;
|
||||
|
||||
socket.emit("search", {
|
||||
networkUuid: this.network.uuid,
|
||||
channelName: this.channel.name,
|
||||
searchTerm: this.$route.query.q,
|
||||
offset: this.offset + 1,
|
||||
networkUuid: network.value?.uuid,
|
||||
channelName: channel.value?.name,
|
||||
searchTerm: String(route.query.q || ""),
|
||||
offset: offset.value + 1,
|
||||
});
|
||||
},
|
||||
jumpToBottom() {
|
||||
this.$nextTick(() => {
|
||||
const el = this.$refs.chat;
|
||||
};
|
||||
|
||||
const jumpToBottom = async () => {
|
||||
await nextTick();
|
||||
|
||||
const el = chat.value;
|
||||
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
},
|
||||
jump(message, id) {
|
||||
};
|
||||
|
||||
const jump = (message: ClientMessage, id: number) => {
|
||||
// TODO: Implement jumping to messages!
|
||||
// This is difficult because it means client will need to handle a potentially nonlinear message set
|
||||
// (loading IntersectionObserver both before AND after the messages)
|
||||
this.$router.push({
|
||||
router
|
||||
.push({
|
||||
name: "MessageList",
|
||||
params: {
|
||||
id: this.chan.id,
|
||||
id: channel.value?.id,
|
||||
},
|
||||
query: {
|
||||
focused: id,
|
||||
},
|
||||
})
|
||||
.catch((e) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Failed to navigate to message ${id}`, e);
|
||||
});
|
||||
};
|
||||
|
||||
watch(
|
||||
() => route.params.id,
|
||||
() => {
|
||||
doSearch();
|
||||
setActiveChannel();
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => route.query,
|
||||
() => {
|
||||
doSearch();
|
||||
setActiveChannel();
|
||||
}
|
||||
);
|
||||
|
||||
watch(messages, async () => {
|
||||
moreResultsAvailable.value = !!(
|
||||
messages.value.length && !(messages.value.length % 100)
|
||||
);
|
||||
|
||||
if (!offset.value) {
|
||||
await jumpToBottom();
|
||||
} else {
|
||||
await nextTick();
|
||||
|
||||
const el = chat.value;
|
||||
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentChatHeight = el.scrollHeight;
|
||||
el.scrollTop = oldScrollTop.value + currentChatHeight - oldChatHeight.value;
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
setActiveChannel();
|
||||
doSearch();
|
||||
|
||||
eventbus.on("escapekey", closeSearch);
|
||||
eventbus.on("re-search", doSearch);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
eventbus.off("escapekey", closeSearch);
|
||||
eventbus.off("re-search", doSearch);
|
||||
});
|
||||
|
||||
return {
|
||||
chat,
|
||||
loadMoreButton,
|
||||
messages,
|
||||
moreResultsAvailable,
|
||||
search,
|
||||
network,
|
||||
channel,
|
||||
route,
|
||||
offset,
|
||||
store,
|
||||
setActiveChannel,
|
||||
closeSearch,
|
||||
shouldDisplayDateMarker,
|
||||
doSearch,
|
||||
onShowMoreClick,
|
||||
jumpToBottom,
|
||||
jump,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -3,674 +3,56 @@
|
|||
<div class="header">
|
||||
<SidebarToggle />
|
||||
</div>
|
||||
<form
|
||||
ref="settingsForm"
|
||||
class="container"
|
||||
autocomplete="off"
|
||||
@change="onChange"
|
||||
@submit.prevent
|
||||
>
|
||||
<h1 class="title">Settings</h1>
|
||||
<Navigation />
|
||||
|
||||
<div>
|
||||
<label class="opt">
|
||||
<input
|
||||
:checked="$store.state.settings.advanced"
|
||||
type="checkbox"
|
||||
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>
|
||||
<div class="container">
|
||||
<form ref="settingsForm" autocomplete="off" @change="onChange" @submit.prevent>
|
||||
<router-view :settings-form="settingsForm"></router-view>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
textarea#user-specified-css-input {
|
||||
height: 100px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import socket from "../../js/socket";
|
||||
import webpush from "../../js/webpush";
|
||||
import RevealPassword from "../RevealPassword.vue";
|
||||
import Session from "../Session.vue";
|
||||
<script lang="ts">
|
||||
import {defineComponent, ref} from "vue";
|
||||
import SidebarToggle from "../SidebarToggle.vue";
|
||||
import Navigation from "../Settings/Navigation.vue";
|
||||
import {useStore} from "../../js/store";
|
||||
|
||||
let installPromptEvent = null;
|
||||
|
||||
window.addEventListener("beforeinstallprompt", (e) => {
|
||||
e.preventDefault();
|
||||
installPromptEvent = e;
|
||||
});
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "Settings",
|
||||
components: {
|
||||
RevealPassword,
|
||||
Session,
|
||||
SidebarToggle,
|
||||
Navigation,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
canRegisterProtocol: false,
|
||||
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");
|
||||
setup() {
|
||||
const store = useStore();
|
||||
const settingsForm = ref<HTMLFormElement>();
|
||||
|
||||
// Enable protocol handler registration if supported,
|
||||
// and the network configuration is not locked
|
||||
this.canRegisterProtocol =
|
||||
window.navigator.registerProtocolHandler &&
|
||||
!this.$store.state.serverConfiguration.lockNetwork;
|
||||
},
|
||||
methods: {
|
||||
onChange(event) {
|
||||
const onChange = (event: Event) => {
|
||||
const ignore = ["old_password", "new_password", "verify_password"];
|
||||
|
||||
const name = event.target.name;
|
||||
const name = (event.target as HTMLInputElement).name;
|
||||
|
||||
if (ignore.includes(name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let value;
|
||||
let value: boolean | string;
|
||||
|
||||
if (event.target.type === "checkbox") {
|
||||
value = event.target.checked;
|
||||
if ((event.target as HTMLInputElement).type === "checkbox") {
|
||||
value = (event.target as HTMLInputElement).checked;
|
||||
} else {
|
||||
value = event.target.value;
|
||||
value = (event.target as HTMLInputElement).value;
|
||||
}
|
||||
|
||||
this.$store.dispatch("settings/update", {name, value, sync: true});
|
||||
},
|
||||
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"),
|
||||
void store.dispatch("settings/update", {name, value, sync: true});
|
||||
};
|
||||
|
||||
if (!data.old_password || !data.new_password || !data.verify_password) {
|
||||
this.passwordChangeStatus = {
|
||||
success: false,
|
||||
error: "missing_fields",
|
||||
return {
|
||||
onChange,
|
||||
settingsForm,
|
||||
};
|
||||
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>
|
||||
|
|
|
@ -55,51 +55,69 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import storage from "../../js/localStorage";
|
||||
import socket from "../../js/socket";
|
||||
import RevealPassword from "../RevealPassword.vue";
|
||||
import {defineComponent, onBeforeUnmount, onMounted, ref} from "vue";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "SignIn",
|
||||
components: {
|
||||
RevealPassword,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
inFlight: false,
|
||||
errorShown: false,
|
||||
setup() {
|
||||
const inFlight = ref(false);
|
||||
const errorShown = ref(false);
|
||||
|
||||
const username = ref<HTMLInputElement | null>(null);
|
||||
const password = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const onAuthFailed = () => {
|
||||
inFlight.value = false;
|
||||
errorShown.value = true;
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
socket.on("auth:failed", this.onAuthFailed);
|
||||
},
|
||||
beforeDestroy() {
|
||||
socket.off("auth:failed", this.onAuthFailed);
|
||||
},
|
||||
methods: {
|
||||
onAuthFailed() {
|
||||
this.inFlight = false;
|
||||
this.errorShown = true;
|
||||
},
|
||||
onSubmit(event) {
|
||||
|
||||
const onSubmit = (event: Event) => {
|
||||
event.preventDefault();
|
||||
|
||||
this.inFlight = true;
|
||||
this.errorShown = false;
|
||||
if (!username.value || !password.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
inFlight.value = true;
|
||||
errorShown.value = false;
|
||||
|
||||
const values = {
|
||||
user: this.$refs.username.value,
|
||||
password: this.$refs.password.value,
|
||||
user: username.value?.value,
|
||||
password: password.value?.value,
|
||||
};
|
||||
|
||||
storage.set("user", values.user);
|
||||
|
||||
socket.emit("auth:perform", values);
|
||||
},
|
||||
getStoredUser() {
|
||||
};
|
||||
|
||||
const getStoredUser = () => {
|
||||
return storage.get("user");
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
socket.on("auth:failed", onAuthFailed);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
socket.off("auth:failed", onAuthFailed);
|
||||
});
|
||||
|
||||
return {
|
||||
inFlight,
|
||||
errorShown,
|
||||
username,
|
||||
password,
|
||||
onSubmit,
|
||||
getStoredUser,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -298,6 +298,7 @@ p {
|
|||
#connect .extra-help,
|
||||
#settings .extra-help,
|
||||
#settings #play::before,
|
||||
#settings .settings-menu .icon::before,
|
||||
#form #upload::before,
|
||||
#form #submit::before,
|
||||
#chat .msg[data-type="away"] .from::before,
|
||||
|
@ -397,7 +398,9 @@ p {
|
|||
}
|
||||
|
||||
#footer .connect::before { content: "\f067"; /* http://fontawesome.io/icon/plus/ */ }
|
||||
|
||||
#footer .settings::before { content: "\f013"; /* http://fontawesome.io/icon/cog/ */ }
|
||||
|
||||
#footer .help::before { content: "\f059"; /* http://fontawesome.io/icon/question/ */ }
|
||||
|
||||
#form #upload::before { content: "\f0c6"; /* https://fontawesome.com/icons/paperclip?style=solid */ }
|
||||
|
@ -584,6 +587,11 @@ p {
|
|||
|
||||
/* End icons */
|
||||
|
||||
#app {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#viewport {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
|
@ -613,7 +621,6 @@ p {
|
|||
line-height: 1;
|
||||
height: 36px;
|
||||
width: 36px;
|
||||
margin-top: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
@ -723,7 +730,7 @@ background on hover (unless active) */
|
|||
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,
|
||||
.channel-list-item.active {
|
||||
background-color: #303e4a;
|
||||
|
@ -1061,6 +1068,7 @@ textarea.input {
|
|||
}
|
||||
|
||||
.header {
|
||||
align-items: center;
|
||||
line-height: 45px;
|
||||
height: 45px;
|
||||
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.
|
||||
For example: user has quit ( ===> L O L <=== ) */
|
||||
|
||||
.header .topic,
|
||||
#chat .msg[data-type="action"] .content,
|
||||
#chat .msg[data-type="message"] .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 .part-reason,
|
||||
#chat .quit-reason,
|
||||
#chat .new-topic,
|
||||
#chat table.channel-list .topic {
|
||||
#chat .new-topic {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
#chat table.channel-list .topic,
|
||||
.header .topic {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-view[data-type="search-results"] .search-status {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
|
|
|
@ -48,7 +48,7 @@
|
|||
|
||||
</head>
|
||||
<body class="<%- public ? " public" : "" %>" data-transports="<%- JSON.stringify(transports) %>">
|
||||
<div id="viewport"></div>
|
||||
<div id="app"></div>
|
||||
<div id="loading">
|
||||
<div class="window">
|
||||
<div id="loading-status-container">
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
"use strict";
|
||||
|
||||
import storage from "./localStorage";
|
||||
import location from "./location";
|
||||
|
|
@ -1,90 +1,92 @@
|
|||
"use strict";
|
||||
|
||||
const constants = require("./constants");
|
||||
import constants from "./constants";
|
||||
|
||||
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 emojiMap from "./helpers/simplemap.json";
|
||||
import store from "./store";
|
||||
import {store} from "./store";
|
||||
|
||||
export default enableAutocomplete;
|
||||
|
||||
const emojiSearchTerms = Object.keys(emojiMap);
|
||||
const emojiStrategy = {
|
||||
const emojiStrategy: StrategyProps = {
|
||||
id: "emoji",
|
||||
match: /(^|\s):([-+\w:?]{2,}):?$/,
|
||||
search(term, callback) {
|
||||
search(term: string, callback: (matches) => void) {
|
||||
// Trim colon from the matched term,
|
||||
// as we are unable to get a clean string from match regex
|
||||
term = term.replace(/:$/, "");
|
||||
callback(fuzzyGrep(term, emojiSearchTerms));
|
||||
},
|
||||
template([string, original]) {
|
||||
return `<span class="emoji">${emojiMap[original]}</span> ${string}`;
|
||||
template([string, original]: [string, string]) {
|
||||
return `<span class="emoji">${String(emojiMap[original])}</span> ${string}`;
|
||||
},
|
||||
replace([, original]) {
|
||||
return "$1" + emojiMap[original];
|
||||
replace([, original]: [string, string]) {
|
||||
return "$1" + String(emojiMap[original]);
|
||||
},
|
||||
index: 2,
|
||||
};
|
||||
|
||||
const nicksStrategy = {
|
||||
const nicksStrategy: StrategyProps = {
|
||||
id: "nicks",
|
||||
match: /(^|\s)(@([a-zA-Z_[\]\\^{}|`@][a-zA-Z0-9_[\]\\^{}|`-]*)?)$/,
|
||||
search(term, callback) {
|
||||
search(term: string, callback: (matches: string[] | string[][]) => void) {
|
||||
term = term.slice(1);
|
||||
|
||||
if (term[0] === "@") {
|
||||
// TODO: type
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
|
||||
callback(completeNicks(term.slice(1), true).map((val) => ["@" + val[0], "@" + val[1]]));
|
||||
} else {
|
||||
callback(completeNicks(term, true));
|
||||
}
|
||||
},
|
||||
template([string]) {
|
||||
template([string]: [string, string]) {
|
||||
return string;
|
||||
},
|
||||
replace([, original]) {
|
||||
replace([, original]: [string, string]) {
|
||||
return "$1" + replaceNick(original);
|
||||
},
|
||||
index: 2,
|
||||
};
|
||||
|
||||
const chanStrategy = {
|
||||
const chanStrategy: StrategyProps = {
|
||||
id: "chans",
|
||||
match: /(^|\s)((?:#|\+|&|![A-Z0-9]{5})(?:[^\s]+)?)$/,
|
||||
search(term, callback) {
|
||||
search(term: string, callback: (matches: string[][]) => void) {
|
||||
callback(completeChans(term));
|
||||
},
|
||||
template([string]) {
|
||||
template([string]: [string, string]) {
|
||||
return string;
|
||||
},
|
||||
replace([, original]) {
|
||||
replace([, original]: [string, string]) {
|
||||
return "$1" + original;
|
||||
},
|
||||
index: 2,
|
||||
};
|
||||
|
||||
const commandStrategy = {
|
||||
const commandStrategy: StrategyProps = {
|
||||
id: "commands",
|
||||
match: /^\/(\w*)$/,
|
||||
search(term, callback) {
|
||||
search(term: string, callback: (matches: string[][]) => void) {
|
||||
callback(completeCommands("/" + term));
|
||||
},
|
||||
template([string]) {
|
||||
template([string]: [string, string]) {
|
||||
return string;
|
||||
},
|
||||
replace([, original]) {
|
||||
replace([, original]: [string, string]) {
|
||||
return original;
|
||||
},
|
||||
index: 1,
|
||||
};
|
||||
|
||||
const foregroundColorStrategy = {
|
||||
const foregroundColorStrategy: StrategyProps = {
|
||||
id: "foreground-colors",
|
||||
match: /\x03(\d{0,2}|[A-Za-z ]{0,10})$/,
|
||||
search(term, callback) {
|
||||
search(term: string, callback: (matches: string[][]) => void) {
|
||||
term = term.toLowerCase();
|
||||
|
||||
const matchingColorCodes = constants.colorCodeMap
|
||||
|
@ -105,19 +107,19 @@ const foregroundColorStrategy = {
|
|||
|
||||
callback(matchingColorCodes);
|
||||
},
|
||||
template(value) {
|
||||
template(value: string[]) {
|
||||
return `<span class="irc-fg${parseInt(value[0], 10)}">${value[1]}</span>`;
|
||||
},
|
||||
replace(value) {
|
||||
replace(value: string) {
|
||||
return "\x03" + value[0];
|
||||
},
|
||||
index: 1,
|
||||
};
|
||||
|
||||
const backgroundColorStrategy = {
|
||||
const backgroundColorStrategy: StrategyProps = {
|
||||
id: "background-colors",
|
||||
match: /\x03(\d{2}),(\d{0,2}|[A-Za-z ]{0,10})$/,
|
||||
search(term, callback, match) {
|
||||
search(term: string, callback: (matchingColorCodes: string[][]) => void, match: string[]) {
|
||||
term = term.toLowerCase();
|
||||
const matchingColorCodes = constants.colorCodeMap
|
||||
.filter((i) => fuzzy.test(term, i[0]) || fuzzy.test(term, i[1]))
|
||||
|
@ -138,25 +140,25 @@ const backgroundColorStrategy = {
|
|||
|
||||
callback(matchingColorCodes);
|
||||
},
|
||||
template(value) {
|
||||
template(value: string[]) {
|
||||
return `<span class="irc-fg${parseInt(value[2], 10)} irc-bg irc-bg${parseInt(
|
||||
value[0],
|
||||
10
|
||||
)}">${value[1]}</span>`;
|
||||
},
|
||||
replace(value) {
|
||||
replace(value: string[]) {
|
||||
return "\x03$1," + value[0];
|
||||
},
|
||||
index: 2,
|
||||
};
|
||||
|
||||
function enableAutocomplete(input) {
|
||||
function enableAutocomplete(input: HTMLTextAreaElement) {
|
||||
let tabCount = 0;
|
||||
let lastMatch = "";
|
||||
let currentMatches = [];
|
||||
let currentMatches: string[] | string[][] = [];
|
||||
|
||||
input.addEventListener("input", (e) => {
|
||||
if (e.detail === "autocomplete") {
|
||||
if ((e as CustomEvent).detail === "autocomplete") {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -177,7 +179,7 @@ function enableAutocomplete(input) {
|
|||
const text = input.value;
|
||||
|
||||
if (tabCount === 0) {
|
||||
lastMatch = text.substring(0, input.selectionStart).split(/\s/).pop();
|
||||
lastMatch = text.substring(0, input.selectionStart).split(/\s/).pop() || "";
|
||||
|
||||
if (lastMatch.length === 0) {
|
||||
return;
|
||||
|
@ -192,12 +194,14 @@ function enableAutocomplete(input) {
|
|||
|
||||
const position = input.selectionStart - lastMatch.length;
|
||||
const newMatch = replaceNick(
|
||||
currentMatches[tabCount % currentMatches.length],
|
||||
// TODO: type this properly
|
||||
String(currentMatches[tabCount % currentMatches.length]),
|
||||
position
|
||||
);
|
||||
const remainder = text.substr(input.selectionStart);
|
||||
const remainder = text.substring(input.selectionStart);
|
||||
|
||||
input.value = text.substr(0, position) + newMatch + remainder;
|
||||
|
||||
input.selectionStart -= remainder.length;
|
||||
input.selectionEnd = input.selectionStart;
|
||||
|
||||
|
@ -214,29 +218,21 @@ function enableAutocomplete(input) {
|
|||
"keydown"
|
||||
);
|
||||
|
||||
const editor = new Textarea(input);
|
||||
const textcomplete = new Textcomplete(editor, {
|
||||
dropdown: {
|
||||
className: "textcomplete-menu",
|
||||
placement: "top",
|
||||
},
|
||||
});
|
||||
|
||||
textcomplete.register([
|
||||
const strategies = [
|
||||
emojiStrategy,
|
||||
nicksStrategy,
|
||||
chanStrategy,
|
||||
commandStrategy,
|
||||
foregroundColorStrategy,
|
||||
backgroundColorStrategy,
|
||||
]);
|
||||
];
|
||||
|
||||
// Activate the first item by default
|
||||
// https://github.com/yuku-t/textcomplete/issues/93
|
||||
textcomplete.on("rendered", () => {
|
||||
if (textcomplete.dropdown.items.length > 0) {
|
||||
textcomplete.dropdown.items[0].activate();
|
||||
}
|
||||
const editor = new TextareaEditor(input);
|
||||
const textcomplete = new Textcomplete(editor, strategies, {
|
||||
dropdown: {
|
||||
className: "textcomplete-menu",
|
||||
placement: "top",
|
||||
},
|
||||
});
|
||||
|
||||
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 (!store.state.settings.nickPostfix) {
|
||||
return original;
|
||||
}
|
||||
|
||||
// If there is whitespace in the input already, append space to nick
|
||||
if (position > 0 && /\s/.test(store.state.activeChannel.channel.pendingMessage)) {
|
||||
if (position > 0 && /\s/.test(store.state.activeChannel?.channel.pendingMessage || "")) {
|
||||
return original + " ";
|
||||
}
|
||||
|
||||
|
@ -273,7 +269,7 @@ function replaceNick(original, position = 1) {
|
|||
return original + store.state.settings.nickPostfix;
|
||||
}
|
||||
|
||||
function fuzzyGrep(term, array) {
|
||||
function fuzzyGrep<T>(term: string, array: Array<T>) {
|
||||
const results = fuzzy.filter(term, array, {
|
||||
pre: "<b>",
|
||||
post: "</b>",
|
||||
|
@ -282,6 +278,10 @@ function fuzzyGrep(term, array) {
|
|||
}
|
||||
|
||||
function rawNicks() {
|
||||
if (!store.state.activeChannel) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (store.state.activeChannel.channel.users.length > 0) {
|
||||
const users = store.state.activeChannel.channel.users.slice();
|
||||
|
||||
|
@ -300,7 +300,7 @@ function rawNicks() {
|
|||
return [me];
|
||||
}
|
||||
|
||||
function completeNicks(word, isFuzzy) {
|
||||
function completeNicks(word: string, isFuzzy: boolean) {
|
||||
const users = rawNicks();
|
||||
word = word.toLowerCase();
|
||||
|
||||
|
@ -321,20 +321,22 @@ function getCommands() {
|
|||
return cmds;
|
||||
}
|
||||
|
||||
function completeCommands(word) {
|
||||
function completeCommands(word: string) {
|
||||
const commands = getCommands();
|
||||
return fuzzyGrep(word, commands);
|
||||
}
|
||||
|
||||
function completeChans(word) {
|
||||
const words = [];
|
||||
function completeChans(word: string) {
|
||||
const words: string[] = [];
|
||||
|
||||
if (store.state.activeChannel) {
|
||||
for (const channel of store.state.activeChannel.network.channels) {
|
||||
// Push all channels that start with the same CHANTYPE
|
||||
if (channel.type === "channel" && channel.name[0] === word[0]) {
|
||||
words.push(channel.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fuzzyGrep(word, words);
|
||||
}
|
|
@ -1,13 +1,16 @@
|
|||
"use strict";
|
||||
|
||||
export default function (chat) {
|
||||
export default function (chat: HTMLDivElement) {
|
||||
// Disable in Firefox as it already copies flex text correctly
|
||||
// @ts-expect-error Property 'InstallTrigger' does not exist on type 'Window & typeof globalThis'.ts(2339)
|
||||
if (typeof window.InstallTrigger !== "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (!selection) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If selection does not span multiple elements, do nothing
|
||||
if (selection.anchorNode === selection.focusNode) {
|
||||
return;
|
|
@ -1,10 +1,12 @@
|
|||
"use strict";
|
||||
|
||||
import socket from "../socket";
|
||||
import store from "../store";
|
||||
import {store} from "../store";
|
||||
|
||||
function input() {
|
||||
const messageIds = [];
|
||||
if (!store.state.activeChannel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messageIds: number[] = [];
|
||||
|
||||
for (const message of store.state.activeChannel.channel.messages) {
|
||||
let toggled = false;
|
||||
|
@ -24,7 +26,7 @@ function input() {
|
|||
// Tell the server we're toggling so it remembers at page reload
|
||||
if (!document.body.classList.contains("public") && messageIds.length > 0) {
|
||||
socket.emit("msg:preview:toggle", {
|
||||
target: store.state.activeChannel.channel.id,
|
||||
target: store.state.activeChannel?.channel.id,
|
||||
messageIds: messageIds,
|
||||
shown: false,
|
||||
});
|
|
@ -1,10 +1,12 @@
|
|||
"use strict";
|
||||
|
||||
import socket from "../socket";
|
||||
import store from "../store";
|
||||
import {store} from "../store";
|
||||
|
||||
function input() {
|
||||
const messageIds = [];
|
||||
if (!store.state.activeChannel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messageIds: number[] = [];
|
||||
|
||||
for (const message of store.state.activeChannel.channel.messages) {
|
||||
let toggled = false;
|
||||
|
@ -24,7 +26,7 @@ function input() {
|
|||
// Tell the server we're toggling so it remembers at page reload
|
||||
if (!document.body.classList.contains("public") && messageIds.length > 0) {
|
||||
socket.emit("msg:preview:toggle", {
|
||||
target: store.state.activeChannel.channel.id,
|
||||
target: store.state.activeChannel?.channel.id,
|
||||
messageIds: messageIds,
|
||||
shown: true,
|
||||
});
|
|
@ -1,14 +1,12 @@
|
|||
"use strict";
|
||||
|
||||
// Taken from views/index.js
|
||||
|
||||
// This creates a version of `require()` in the context of the current
|
||||
// directory, so we iterate over its content, which is a map statically built by
|
||||
// Webpack.
|
||||
// Second argument says it's recursive, third makes sure we only load javascript.
|
||||
const commands = require.context("./", true, /\.js$/);
|
||||
const commands = require.context("./", true, /\.ts$/);
|
||||
|
||||
export default commands.keys().reduce((acc, path) => {
|
||||
export default commands.keys().reduce<Record<string, unknown>>((acc, path) => {
|
||||
const command = path.substring(2, path.length - 3);
|
||||
|
||||
if (command === "index") {
|
|
@ -1,15 +1,13 @@
|
|||
"use strict";
|
||||
|
||||
import socket from "../socket";
|
||||
import store from "../store";
|
||||
import {store} from "../store";
|
||||
import {switchToChannel} from "../router";
|
||||
|
||||
function input(args) {
|
||||
function input(args: string[]) {
|
||||
if (args.length > 0) {
|
||||
let channels = args[0];
|
||||
|
||||
if (channels.length > 0) {
|
||||
const chanTypes = store.state.activeChannel.network.serverOptions.CHANTYPES;
|
||||
const chanTypes = store.state.activeChannel?.network.serverOptions.CHANTYPES;
|
||||
const channelList = args[0].split(",");
|
||||
|
||||
if (chanTypes && chanTypes.length > 0) {
|
||||
|
@ -27,15 +25,17 @@ function input(args) {
|
|||
if (chan) {
|
||||
switchToChannel(chan);
|
||||
} else {
|
||||
if (store.state.activeChannel) {
|
||||
socket.emit("input", {
|
||||
text: `/join ${channels} ${args.length > 1 ? args[1] : ""}`,
|
||||
target: store.state.activeChannel.channel.id,
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else if (store.state.activeChannel.channel.type === "channel") {
|
||||
} else if (store.state.activeChannel?.channel.type === "channel") {
|
||||
// If `/join` command is used without any arguments, re-join current channel
|
||||
socket.emit("input", {
|
||||
target: store.state.activeChannel.channel.id,
|
|
@ -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};
|
27
client/js/commands/search.ts
Normal file
27
client/js/commands/search.ts
Normal 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};
|
|
@ -1,5 +1,3 @@
|
|||
"use strict";
|
||||
|
||||
const colorCodeMap = [
|
||||
["00", "White"],
|
||||
["01", "Black"],
|
||||
|
@ -28,10 +26,9 @@ const timeFormats = {
|
|||
msg12hWithSeconds: "hh:mm:ss A",
|
||||
};
|
||||
|
||||
// This file is required by server, can't use es6 export
|
||||
module.exports = {
|
||||
export default {
|
||||
colorCodeMap,
|
||||
commands: [],
|
||||
commands: [] as string[],
|
||||
condensedTypes,
|
||||
timeFormats,
|
||||
// Same value as media query in CSS that forces sidebars to become overlays
|
|
@ -7,7 +7,7 @@ class EventBus {
|
|||
* @param {String} type Type of event to listen for.
|
||||
* @param {Function} handler Function to call in response to given event.
|
||||
*/
|
||||
on(type, handler) {
|
||||
on(type: string, handler: (...evt: any[]) => void) {
|
||||
if (events.has(type)) {
|
||||
events.get(type).push(handler);
|
||||
} else {
|
||||
|
@ -21,11 +21,11 @@ class EventBus {
|
|||
* @param {String} type Type of event to unregister `handler` from.
|
||||
* @param {Function} handler Handler function to remove.
|
||||
*/
|
||||
off(type, handler) {
|
||||
off(type: string, handler: (...evt: any[]) => void) {
|
||||
if (events.has(type)) {
|
||||
events.set(
|
||||
type,
|
||||
events.get(type).filter((item) => item !== handler)
|
||||
events.get(type).filter((item: (...evt: any[]) => void) => item !== handler)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -36,12 +36,12 @@ class EventBus {
|
|||
* @param {String} type The event type to invoke.
|
||||
* @param {Any} [evt] Any value (object is recommended and powerful), passed to each handler.
|
||||
*/
|
||||
emit(type, ...evt) {
|
||||
emit(type: string, ...evt: any) {
|
||||
if (events.has(type)) {
|
||||
events
|
||||
.get(type)
|
||||
.slice()
|
||||
.map((handler) => {
|
||||
.map((handler: (...evts: any[]) => void) => {
|
||||
handler(...evt);
|
||||
});
|
||||
}
|
|
@ -1,9 +1,9 @@
|
|||
"use strict";
|
||||
|
||||
import storage from "../localStorage";
|
||||
|
||||
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;
|
||||
|
||||
if (isCollapsed) {
|
|
@ -1,7 +1,5 @@
|
|||
"use strict";
|
||||
|
||||
// Generates a string from "color-1" to "color-32" based on an input string
|
||||
export default (str) => {
|
||||
export default (str: string) => {
|
||||
let hash = 0;
|
||||
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
|
@ -13,5 +11,5 @@ export default (str) => {
|
|||
due to A being ascii 65 (100 0001)
|
||||
while a being ascii 97 (110 0001)
|
||||
*/
|
||||
return "color-" + (1 + (hash % 32));
|
||||
return "color-" + (1 + (hash % 32)).toString();
|
||||
};
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue