Compare commits

...

606 commits

Author SHA1 Message Date
Max Leiter 4de413070d
Merge pull request #4873 from thelounge/types
client: remove setAppBadge and clearAppBadge
2024-05-12 23:08:52 -07:00
Reto Brunner 0955d9df06 Identd: fix various issues
There's a bunch of sub optimal behavior from our ident server.
For one, it allows user enumeration which we don't really want and it doesn't clean up connections that don't send any data.

Fix that
2024-05-12 11:51:18 +02:00
Reto Brunner cb4aaf6a97 client: remove setAppBadge and clearAppBadge
They already ship with typescript's lib.dom.d.ts
2024-05-10 13:21:18 +02:00
Reto Brunner 45c2fc87ee client: properly type Socket in window 2024-05-10 12:27:41 +02:00
Reto Brunner 29fcc2da05 ident: close connections if they don't send data 2024-05-09 19:01:10 +02:00
Reto Brunner 12679081c8 ident: fix write after connection end
We only respond once to data, then half-close the connection.
Hence, we should only listen to a single data event as well,
else if the remote doesn't stop sending data we keep trying to
write to the closed write end of the pipe.
2024-05-09 19:01:10 +02:00
Reto Brunner 0e48014d5a ident: only respond if the ip,port tuples match
Per RFC 1413, The uniquely identifying tuple includes not only the ports,
but also both addresses.

If multiple connections happen to use the same local port number
(which is possible if the addresses differ), the username of the first
is returned for all, resulting in the wrong ident for all but the
first.

By not checking the connection address, the information becomes
public. Because there is only relatively small number of local ports,
and the remote ports are likely to be either 6667 or 6697, it becomes
trivial to enumerate all the users.

Co-Authored-By: Juerd Waalboer <juerd@tnx.nl>
2024-05-09 19:01:10 +02:00
Reto Brunner 4819406af5 ident: order imports 2024-05-09 17:24:53 +02:00
Max Leiter 74563effa7
Merge pull request #4869 from thelounge/tsUpdate
Ts update
2024-05-04 21:49:58 -07:00
Max Leiter cbab10f416
Merge pull request #4870 from thelounge/dts
remove unused .d.ts file
2024-05-04 21:46:54 -07:00
Reto Brunner 4dfeb899b4 remove unused .d.ts file 2024-05-04 13:45:40 +02:00
Reto Brunner 3259ac596d client: fix all new linter errros 2024-05-04 12:29:57 +02:00
Reto Brunner 3fbbc39cd6 client/commands: statically import commands
Dynamic imports won't work very well with modules and we don't
really need them, it's just there to save us an import statement.

Let's flip this to a static version.
2024-05-04 12:29:57 +02:00
Reto Brunner 9ae9482223 bump all eslint related deps 2024-05-02 08:21:34 +02:00
Reto Brunner a3953405ed bump eslint to latest 8.* 2024-04-27 13:19:39 +02:00
Reto Brunner 9086bc648d bump typescript to v5 2024-04-27 13:06:30 +02:00
Reto Brunner da2572fe25 Merge remote-tracking branch 'origin/renovate/read-0.x' 2024-04-27 12:50:11 +02:00
Reto Brunner d9977df315 Merge remote-tracking branch 'origin/renovate/sqlite3-3.x' 2024-04-27 12:48:20 +02:00
Reto Brunner cc0aa5e8e5 Merge remote-tracking branch 'origin/renovate/npm-webpack-dev-middleware-vulnerability' 2024-04-27 12:47:01 +02:00
Reto Brunner 02df78b0f2 Merge remote-tracking branch 'origin/renovate/npm-express-vulnerability' 2024-04-27 12:46:42 +02:00
Reto Brunner 18b0e06855 Merge remote-tracking branch 'origin/renovate/express-4.x' 2024-04-27 12:46:27 +02:00
Reto Brunner d5db9c653b Merge remote-tracking branch 'origin/renovate/ua-parser-js-0.x' 2024-04-27 12:46:09 +02:00
Reto Brunner f7926267d9 untangle client and server
Our project was quite confused as to the boundaries between client and
server code.
This false sharing meant that it was quite hard to tell what was actually
sent to the client and what was uniquely scoped to either side.

Further, this meant that our compilation and build pipelines were very
confused and pulled in files they should not have.

This commit series tries to untangle the two. This also entails fixing
quite some typing issues.
It's hard to make this in sane, small, commits that still build at each
step (it's impossible, as fixing one type error / any type immediately lead
to further errors in a game of whack a mole).
So you'll get my actual progress in small commits that can each be reviewed,
however the earlier ones are in fact sometimes wrong and get cleaned up later
once the picture is a bit clearer.
2024-04-26 09:39:15 +02:00
Reto Brunner 8eb398c5cc server: don't throw in async callback from index requests
This was flagged as an issue by codeQL

> Server crash [High]
> The server of this route handler will terminate when an
> uncaught exception from this location escapes an
> asynchronous callback.
2024-04-21 15:49:51 +02:00
Reto Brunner 36cb75ee99 NetworkForm: disable unsafe return lints for now
The NetworkForm type is wrong, hence the compiler can't infer the type.
This needs quite some changes, so for now we just turn the linter off
for the 2 watch functions.
The whole component is too dynamic to fix easily.
2024-04-21 15:11:52 +02:00
Reto Brunner 1ec67a6605 test/sqlite: remove unused eslint directive 2024-04-21 15:11:52 +02:00
Reto Brunner 8372c5a57e test: token in init event payload is undefined not null
Doesn't matter which, code happens to emit undefined.
Adapt test expectation over writing strange || null code.
The conditional just checks for a falsey value.
2024-04-21 15:11:52 +02:00
Reto Brunner 5567f07a7c test/chan: remove users field from test
The client side fetches the user list when needed, we don't send
it over from the server.
Hence modify the test expectation.
2024-04-21 15:11:52 +02:00
Reto Brunner a200bab8bd test/chan: getFilteredClone has more fields than the test thinks
The codebase shoves various things into channel objects to transmit them
for things like channel lists etc.

This however means that the type does contains the fields and needs
to export them.

We should clean up the events so that we can get rid of all that.
But for now, we adapt the test expectation to reality.
2024-04-21 15:11:52 +02:00
Reto Brunner 91ac363cc6 components/MessageTypes/errors: fix eslint errors
This makes the code somewhat ugly, but to properly fix we need
to enforce the needed fields
2024-04-21 15:11:52 +02:00
Reto Brunner 6c9d2c36a1 components/Message: fix eslint errors 2024-04-21 15:11:52 +02:00
Reto Brunner 6241eed8f4 client/ImageViewer: fix types and guard against undefined 2024-04-21 15:11:52 +02:00
Reto Brunner 03151e0ab1 test/plugins/sqlite: fix import path 2024-04-21 15:11:52 +02:00
Reto Brunner 7f5e0f3ebf test/plugins/link: fix import path 2024-04-21 15:11:52 +02:00
Reto Brunner 5e444be37b test/models/network: fix import path 2024-04-21 15:11:52 +02:00
Reto Brunner c8664301ba test/models/msg: fix linkpreview import 2024-04-21 15:11:52 +02:00
Reto Brunner 1edb5a72c1 test/models/chan: we do not send an empty user list anymore 2024-04-21 15:11:52 +02:00
Reto Brunner 31d987283a fix mode tests 2024-04-21 15:11:52 +02:00
Reto Brunner 4ceafb653f test/client: fix import path 2024-04-21 15:11:52 +02:00
Reto Brunner f25fee4c6c previews: fix possibly undefined 2024-04-21 15:11:52 +02:00
Reto Brunner 96848c1c1b msg_preview: fix possibly undefined error 2024-04-21 15:11:52 +02:00
Reto Brunner 4b07e05491 client: add missing import for SharedMsg 2024-04-21 15:11:52 +02:00
Reto Brunner fc9805545b sharedMsg: remove userAway
userAway is purely server side and we don't send it to the client
2024-04-21 15:11:52 +02:00
Reto Brunner 82e4150cc8 server: remove type cast from change pw 2024-04-21 15:11:52 +02:00
Reto Brunner e61e356f1e server: somewhat type fix auth related functions
The auth functions are a bloody mess and need to be cleaned up.
using various callback functions and using variables as pointers makes the logic
hard to follow and hence idiotic to type too, as multiple orthogonal logic paths
are mixed up into one function.

This really needs to be untangled
2024-04-21 15:11:52 +02:00
Reto Brunner 5001d607b1 server: mark req params as unused 2024-04-21 15:11:52 +02:00
Reto Brunner 8c41356ae9 publicClient: type fix 2024-04-21 15:11:52 +02:00
Reto Brunner e2b56cf16b irc-events/message: fix types 2024-04-21 15:11:52 +02:00
Reto Brunner 92a0affba1 kick: use the user object 2024-04-21 15:11:52 +02:00
Reto Brunner edb96f683b cap: type the boolean 2024-04-21 15:11:52 +02:00
Reto Brunner 5c8951ffc3 fix extractTargetGroup typing 2024-04-21 15:11:52 +02:00
Reto Brunner c3fc54e158 ignorelist: shut up the linter 2024-04-21 15:11:52 +02:00
Reto Brunner 917fdb2a0a ignore: remove dead import 2024-04-21 15:11:52 +02:00
Reto Brunner b8400a3a46 ignore: clean up the types and conditionals
Now that ignorelist doesn't muddy the waters, we can clean up
all the funny conditional types and enforce `when`
2024-04-21 15:11:52 +02:00
Reto Brunner 071a5afda6 ignore: move ignorelist to its own command
ignorelist shares no logic with /ignore or /unignore so it shouldn't
share a file. That just makes typing awkward.
2024-04-21 15:11:52 +02:00
Reto Brunner 5274fdc21a ignore: keep happy path on the left
It is much easier to follow the control flow if error checks
are done on the indented path, immediately returning.
2024-04-21 15:11:52 +02:00
Reto Brunner b8a9fe08ab clientCertificate: remove unsafe casts 2024-04-21 15:11:52 +02:00
Reto Brunner a4afa08add ldap: type SearchOptions scope 2024-04-21 15:11:52 +02:00
Reto Brunner 4614c35486 chan: type untyped method params 2024-04-21 15:11:52 +02:00
Reto Brunner 540144c417 chan: remove cast in pushMessage 2024-04-21 15:11:52 +02:00
Reto Brunner bb7c3925c6 type serverOptions for network:options 2024-04-21 15:11:52 +02:00
Reto Brunner 9898f38de6 add todo 2024-04-21 15:11:52 +02:00
Reto Brunner 9f2c82e152 fix mentions 2024-04-21 15:11:52 +02:00
Reto Brunner 17ba07db3b fix mentions import 2024-04-21 15:11:52 +02:00
Reto Brunner 0311e5f836 add socket-events import to entry point
socket-events aren't ever imported, if we don't do that however
webpack never actually sees any code that leads to it and skips
bundling it.

So for now, do an import that has the side effect of registering
all the events until we have a proper registration in place that's
a bit more sane to call
2024-04-21 15:11:52 +02:00
Reto Brunner 4d0474b897 store: don't duplicate import 2024-04-21 15:11:52 +02:00
Reto Brunner 14b9169899 store: fix import 2024-04-21 15:11:52 +02:00
Reto Brunner 50037644c0 socket-events: fix join 2024-04-21 15:11:52 +02:00
Reto Brunner 7287c6bcaa remove dead import 2024-04-21 15:11:52 +02:00
Reto Brunner bfca0ca612 fix more 2024-04-21 15:11:52 +02:00
Reto Brunner 300bd4c84c add timestamp to NotificationOptions 2024-04-21 15:11:52 +02:00
Reto Brunner 42ea66c343 socket-events/msg: fix errors 2024-04-21 15:11:51 +02:00
Reto Brunner 1565eb8d05 socket-events/msg: if/else chains are not a switch replacement
If we switch on a field, use switch for god's sake.
If/elif chains are for cases where you have multiple selectors.
2024-04-21 15:11:51 +02:00
Reto Brunner 29750a3e51 ClientChan: does need a user array after all 2024-04-21 15:11:51 +02:00
Reto Brunner 3ea5170e6a socket-events: fix network:status 2024-04-21 15:11:51 +02:00
Reto Brunner fe4f497fad fix socket-event: network 2024-04-21 15:11:51 +02:00
Reto Brunner c20cd6bda1 publicClient: add FIXME and ignore the type mismatch
The publicClient interface is utterly horrific.
It allows any client to inject arbitrary events into the socket.io
event stream.
This should get wrapped into a "plugin" event so that it can get properly
typed, better yet, this should get removed completely.
2024-04-21 15:11:51 +02:00
Reto Brunner 1c4ce5d4a5 fix sync_sort:channels emitter 2024-04-21 15:11:51 +02:00
Reto Brunner 9c4d24d1f7 fix join socket type 2024-04-21 15:11:51 +02:00
Reto Brunner 35e38d13c4 client: properly type the emit method
This breaks the world -.-
2024-04-21 15:11:51 +02:00
Reto Brunner bf7eb0e727 network event: remove unused array
All the network events only ever emit a single copy
There's no point in wrapping it into an array
2024-04-21 15:11:51 +02:00
Reto Brunner 5ee9c2b338 type Server 2024-04-21 15:11:51 +02:00
Reto Brunner e15b121080 remove obsolete error override 2024-04-21 15:11:51 +02:00
Reto Brunner 98452ccc18 remove obsolete import 2024-04-21 15:11:51 +02:00
Reto Brunner a8e7022d04 fix search event params 2024-04-21 15:11:51 +02:00
Reto Brunner 60486bf5e3 server: fix init client 2024-04-21 15:11:51 +02:00
Reto Brunner 46f3fd9682 server: fix push subscription 2024-04-21 15:11:51 +02:00
Reto Brunner 56215382a3 server: remove static props which are currently unused 2024-04-21 15:11:51 +02:00
Reto Brunner 9ab9ad0f56 socket-events: fix up init 2024-04-21 15:11:51 +02:00
Reto Brunner 0660a8772c server: fix getFilteredClone of chan 2024-04-21 15:11:51 +02:00
Reto Brunner f5c691f37b wip: unbork init progress 2024-04-21 15:11:51 +02:00
Reto Brunner 0067c30273 Split sort event
The sort event bundled networks and channels for no reason at all.
They share none of the actual logic, so combining them just makes
the typing poor but serves no benefit.
2024-04-21 15:11:51 +02:00
Reto Brunner 843db1727b server: actually type the socket 2024-04-21 15:11:51 +02:00
Reto Brunner e9ef59b641 fix bad typing
There were quite some errors, where the type was passed the wrong way
```
// This is invalid
"change-password": ({ old_password: string, new_password: string, verify_password: string})

// What was actually meant
"change-password": (data: { old_password: string, new_password: string, verify_password: string})
```

The whole callback function is also very verbose as is, with fluff we don't need.
It's always a function that returns void, so there's no real information to be gained
by spelling it out time and time again.

Let's use a helper type that just accepts the payload.
That should make the above error impossible to do.
2024-04-21 15:11:51 +02:00
Reto Brunner fceffd42b9 fix missing import 2024-04-21 15:11:51 +02:00
Reto Brunner b89b0cad53 client: id is always a string, not a number 2024-04-21 15:11:51 +02:00
Reto Brunner c869ea9a73 sharedchans does not have users 2024-04-21 15:11:51 +02:00
Reto Brunner 9aee3e3e98 Some whitespace is good mkey 2024-04-21 15:11:51 +02:00
Reto Brunner 636b5c5b04 models/network: unfuck client export 2024-04-21 15:11:51 +02:00
Reto Brunner 6984e8f25a unused import 2024-04-21 15:11:51 +02:00
Reto Brunner e43cbb139c remove all server files from compilation 2024-04-21 15:11:51 +02:00
Reto Brunner e57e547b74 further chan fixes 2024-04-21 15:11:51 +02:00
Reto Brunner 3217536245 searchresponse 2024-04-21 15:11:51 +02:00
Reto Brunner 194b4e1a2f import fix 2024-04-21 15:11:51 +02:00
Reto Brunner 88c8830a17 chatuserlist 2024-04-21 15:11:51 +02:00
Reto Brunner 7073584f1c fix msg event 2024-04-21 15:11:51 +02:00
Reto Brunner 8e6920af1d configuration 2024-04-21 15:11:51 +02:00
Reto Brunner 7bc184b252 changelog data type 2024-04-21 15:11:51 +02:00
Reto Brunner 4d237600d5 changelog: don't type assert to a broken type
The mandatory fields are unset, stop lying to the compiler
2024-04-21 15:11:51 +02:00
Reto Brunner 383907c2b8 Use SharedTypes 2024-04-21 15:10:45 +02:00
Reto Brunner f0ee3be6fb wip: config 2024-04-21 15:10:45 +02:00
Reto Brunner 12a0b0b6f9 network 2024-04-21 15:10:45 +02:00
Reto Brunner d716402da2 mention 2024-04-21 15:10:45 +02:00
Reto Brunner d0b71aba32 shared: extract chan + user 2024-04-21 15:10:45 +02:00
Reto Brunner 3f0ee6a961 move chan enums 2024-04-21 15:10:45 +02:00
Reto Brunner b67e4699f5 wip sharedmsg in client 2024-04-21 15:10:45 +02:00
Reto Brunner 68ba13ca12 wip: searchquery 2024-04-21 15:10:45 +02:00
Reto Brunner 3eb19135f5 wip: msg 2024-04-21 15:10:41 +02:00
Max Leiter 549c445853
Merge pull request #4856 from thelounge/generateConfigDoc
fix generate-config-doc.js
2024-04-07 19:38:13 -07:00
Reto Brunner 2466c1b1e4 fix generate-config-doc.js
It errored out with
> Error: Cannot find module '../server/log'
Which is expected, but we don't really need it, we can just open code
the log functions
2024-04-07 16:22:08 +02:00
Reto Brunner f5867c3643 v4.4.3
Bump version to kick CI/CD for the actual deployment
2024-04-06 13:48:04 +02:00
Reto Brunner 231c498def release workflow: fix broken npm update
The release workflow wants to use --provenance but the update fails:

Run npm install -g npm
npm ERR! code EACCES
npm ERR! syscall mkdir
npm ERR! path /usr/local/share/man/man7
npm ERR! errno -13
npm ERR! Error: EACCES: permission denied, mkdir '/usr/local/share/man/man7'
npm ERR!  [Error: EACCES: permission denied, mkdir '/usr/local/share/man/man7'] {
npm ERR!   errno: -13,
npm ERR!   code: 'EACCES',
npm ERR!   syscall: 'mkdir',
npm ERR!   path: '/usr/local/share/man/man7'
npm ERR! }
npm ERR!
npm ERR! The operation was rejected by your operating system.
npm ERR! It is likely you do not have the permissions to access this file as the current user
npm ERR!
npm ERR! If you believe this might be a permissions issue, please double-check the
npm ERR! permissions of the file and its containing directories, or try running
npm ERR! the command again as root/Administrator.

So we fix it by just telling the action what we want rather than monkey patching it.

Link: https://github.com/thelounge/thelounge/actions/runs/8580746748/job/23517165611
2024-04-06 13:31:48 +02:00
Reto Brunner eeaec413d6 v4.4.2 2024-04-03 08:20:36 +02:00
Reto Brunner 515f894c13 changelog: don't break if author is nil
The author field can somehow be null for whatever reason...
Guard the script against blowing up
2024-04-01 15:15:50 +02:00
renovate[bot] e8f6ba5b08
chore(deps): update dependency @types/ua-parser-js to v0.7.39 2024-04-01 00:49:35 +00:00
renovate[bot] 07276bbde4
chore(deps): update dependency @types/express to v4.17.21 2024-04-01 00:49:23 +00:00
renovate[bot] 9ad92e1860
fix(deps): update dependency express to v4.19.2 [security] 2024-03-28 00:33:05 +00:00
renovate[bot] 7923d4a2cd
chore(deps): update dependency webpack-dev-middleware to v5.3.4 [security] 2024-03-23 16:07:59 +00:00
renovate[bot] 9248358169
chore(deps): update dependency @types/sqlite3 to v3.1.11 2024-03-01 02:16:09 +00:00
renovate[bot] 6ab52bc9a9
chore(deps): update dependency @types/read to v0.0.32 2024-03-01 02:15:57 +00:00
Reto Brunner 48213955b9 v4.4.2-rc.1 2024-02-19 19:52:37 +01:00
Reto Brunner 682b3b91aa rc changelog 2024-02-19 19:46:46 +01:00
Reto Brunner be3e27aa19 Merge remote-tracking branch 'origin/pull/4231' 2024-02-19 13:50:00 +01:00
Reto Brunner c09f751552 Merge remote-tracking branch 'origin/pull/4834' 2024-02-19 13:45:53 +01:00
Reto Brunner fb5864ee00 Merge branch 'linkifyOverride' 2024-02-19 13:43:00 +01:00
Reto Brunner 3bd5b704c7 Merge remote-tracking branch 'origin/renovate/node-17.x' 2024-02-19 13:37:06 +01:00
Reto Brunner 139ce47b73 update @types/mousetrap 2024-02-19 13:34:46 +01:00
Zach Bloomquist 45563d9a59
server: remove version from CTCP response 2024-02-01 00:09:45 -05:00
renovate[bot] e2fda1fb84
chore(deps): update dependency @types/node to v17.0.45 2024-02-01 03:42:37 +00:00
renovate[bot] a77fbb894f
chore(deps): update dependency @types/mousetrap to v1.6.15 2024-02-01 03:42:30 +00:00
renovate[bot] fe50a90235
chore(deps): update dependency @types/lodash to v4.14.202 2024-02-01 01:15:55 +00:00
Reto Brunner a8be84028c Merge rm-node-16 2024-01-28 10:55:12 +01:00
Max Leiter 25e55ce75c
Merge pull request #4815 from thelounge/renovate/sqlite3-5.x
chore(deps): update dependency sqlite3 to v5.1.7
2024-01-27 15:56:29 -08:00
Max Leiter 113e9bd2fb Remove Node.js 16 from package.json and testing matrix
Node.js 16 entered EOL in September 2023 (https://nodejs.org/en/blog/announcements/nodejs16-eol)
2024-01-27 15:55:55 -08:00
Max Leiter 2b146ba3e6
Merge pull request #4825 from thelounge/testingSetup
Testing setup
2024-01-27 15:40:09 -08:00
Max Leiter f95dd29a0d
Merge pull request #4811 from thelounge/renovate/textcomplete-textarea-0.x
chore(deps): update dependency @textcomplete/textarea to v0.1.13
2024-01-27 15:40:01 -08:00
Max Leiter 91dc719c93
Merge branch 'master' into testingSetup 2024-01-27 15:34:07 -08:00
Max Leiter 5af893db3a
Merge pull request #4826 from thelounge/huskyDie
Remove husky, add githooks-install
2024-01-27 15:33:26 -08:00
Nachtalb daabb76781
Add shorcut to navigate between channels with undread msgs 2024-01-27 22:50:10 +01:00
Reto Brunner 393d0a63b7 Remove husky, add githooks-install
Fixes: https://github.com/thelounge/thelounge/issues/4452
2024-01-27 16:59:46 +01:00
Reto Brunner 037fc479b8 test: be specific as to which command we want to invoke 2024-01-27 15:41:46 +01:00
Reto Brunner 646bafab99 mocha: move spec to invocation
If we specify the spec in the config file, we can't manually
specify a specific test file from the cli.

This is annoying, as the alternative is copying out the full
package.json blurb into the shell.

Rather, give the spec in the invocation and add a helper
that makes testing a specific file simple.

With this `yarn test:nospec test/plugins/link.ts` will only run
tests within that file
2024-01-27 15:34:13 +01:00
Reto Brunner d4c77c74f6 test:mocha remove duplicate flags
We already specify color in the config file.
ts-node is already required in the config file
2024-01-27 15:26:37 +01:00
Reto Brunner eeefeb229c mocharc: Remove interactive
Interactive isn't a thing according to the help output or the
config docs
2024-01-27 15:24:09 +01:00
Reto Brunner 29c5323bfd test:mocha: webpack doesn't switch on NODE_ENV=test
So we might as well not complicate the cli for no reason
2024-01-27 14:11:30 +01:00
Reto Brunner a12ddc75d8 test:mocha don't run coverage report
Tests should run the tests, not the coverage.
Frequently one is debugging a test, the coverage won't change
between runs but it delays the cycle considerably.

Rather, if one wants to look at the coverage, one should use
the "coverage" command
2024-01-27 14:11:30 +01:00
Reto Brunner dd24cb1300 linkify: simplify noscheme detection logic
Overriding the built in is poor form, as this prevents adding
a new type handler with its own normalize handler.

We only ever want to override protocol-less URLs to http, so
we just do so explicitly in the "//" schema normalizer.

This also means that we don't need all that type conversion dance,
we simply set the schema to null when we patch it and filter on the
schema directly
2024-01-21 21:18:09 +01:00
SoniEx2 ae6bae69ac linkify: Add web+ schema support
Co-Authored-By: Reto Brunner <reto@slightlybroken.com>
2024-01-21 17:47:32 +01:00
renovate[bot] b5372e3ed7
chore(deps): update dependency sqlite3 to v5.1.7 2024-01-06 10:39:32 +00:00
Reto Brunner d15998d919 Merge renovate/content-disposition-0.x 2024-01-04 19:04:00 +01:00
Reto Brunner 436bf6a180 Merge renovate/linkify-it-3.x 2024-01-04 19:03:40 +01:00
renovate[bot] 1d2fdd95b0
chore(deps): update dependency @types/linkify-it to v3.0.5 2024-01-01 03:42:56 +00:00
renovate[bot] eaa70caad7
chore(deps): update dependency @types/is-utf8 to v0.2.3 2024-01-01 03:42:48 +00:00
renovate[bot] aa95032760
chore(deps): update dependency @types/content-disposition to v0.5.8 2024-01-01 01:41:48 +00:00
renovate[bot] e636121d7a
chore(deps): update dependency @textcomplete/textarea to v0.1.13 2024-01-01 01:41:39 +00:00
Max Leiter 083abae750
Merge pull request #4783 from thelounge/router-api
router: don't use next() in router guards
2023-12-26 16:49:37 -08:00
Max Leiter 01cfe3d19d
Merge pull request #4807 from flotwig/fixup-generate-config-docs-script
scripts: fix generate-config-doc, handle usage errors
2023-12-26 16:49:20 -08:00
Reto Brunner 7f0b721790 add storage cleaner
Introduce the ability to clean up old messages from the sqlite db.
The StoragePolicy can be chosen by the user. Currently there's
two versions, delete everything based on age is the obvious.

The other is for the data hoarders among us. It'll only delete
message types which can be considered low value... Types with
a time aspect like away / back... joins / parts etc.

It tries to do that in a sensible way, so that we don't block
all other db writers that are ongoing.
The "periodically" interval is by design not exposed to the user.
2023-12-26 12:00:53 +01:00
Reto Brunner edb1226b47 sqlite: add msg type index to speed up cleaner 2023-12-24 16:55:45 +01:00
Reto Brunner b0ca8e51fb wire up storage cleaner upon server start 2023-12-24 16:55:45 +01:00
Reto Brunner 21b1152f53 cleaner: expose cli task to do cleaning + vacuum
Make the cleaner available to users by exposing it as a subcommand
to thelounge storage.

This is recommended to be run whenever the storage policy significantly
changes in a way that makes many messages eligible for deletion.
The cleaner would cope, but it'll be inefficient and can take many hours.
Due to how storage works in sqlite, the space would not actually be
given back to the OS, just marked for future writes.
Hence this also runs a vacuum to compact the DB as much as it can.
2023-12-24 16:55:45 +01:00
Reto Brunner 74aff7ee5a introduce storage cleaner
Once this is getting hooked up, it'll periodically delete old
messages.

The StoragePolicy can be chosen by the user, currently there's
two versions, delete everything based on age is the obvious.

The other is for the data hoarders among us. It'll only delete
message types which can be considered low value... Types with
a time aspect like away / back... joins / parts etc.

It tries to do that in a sensible way, so that we don't block
all other db writers that are ongoing.
The "periodically" interval is by design not exposed to the user.
2023-12-24 16:55:45 +01:00
Reto Brunner 14d9ff247d sqlite: implement deleteMessages
This is laying the foundation to build a cleaning task that's
sort of database agnostic.
All calls are done by acting on a "DeletionRequest" so interpretation
of the config will go through a single point
2023-12-23 21:08:07 +01:00
Reto Brunner aec8d0b033 sqlite: accept db connection string
This allows us to inject a memory db during testing
2023-12-23 21:08:07 +01:00
Reto Brunner 60ddf17124 sqlite: use variadic function for serialize_run
This makes the usage of the function a bit nicer
2023-12-23 21:08:07 +01:00
dependabot[bot] 20227b174c
build(deps): bump @babel/traverse from 7.18.9 to 7.23.6
Bumps [@babel/traverse](https://github.com/babel/babel/tree/HEAD/packages/babel-traverse) from 7.18.9 to 7.23.6.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.23.6/packages/babel-traverse)

---
updated-dependencies:
- dependency-name: "@babel/traverse"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-23 10:07:23 +00:00
Reto Brunner d18182da8b Merge 'renovate/cheerio-0.x' 2023-12-20 07:40:09 +01:00
Reto Brunner ea35040b42 Merge renovate/bcryptjs-2.x 2023-12-20 07:38:59 +01:00
Reto Brunner 97f553eea8 cli: don't fail if stderr is not in json format
A user reported in the IRC chan that installing packages fails with

```
2023-12-13 20:02:34 [INFO] Installing thelounge-theme-solarized v1.1.9...
undefined:1
(node:3329) [DEP0040] DeprecationWarning: The `punycode` module is deprecated. Please use a userland alternative instead.
^
SyntaxError: Unexpected token '(', "(node:3329"... is not valid JSON
```

Now, this happens as yarn helpfully prints a deprecation warning
that is shown in the stack trace.

Let's assume that we may get non json messages and log them at debug, as we
don't know their severity.
2023-12-13 22:55:53 +01:00
Zach Bloomquist 6603c1a6e6
scripts: fix generate-config-doc, handle usage errors 2023-12-12 11:52:45 -05:00
renovate[bot] 73a529acea
chore(deps): update dependency @types/cheerio to v0.22.35 2023-12-01 01:11:42 +00:00
renovate[bot] 2f40d9dbcc
chore(deps): update dependency @types/bcryptjs to v2.4.6 2023-12-01 01:11:29 +00:00
Reto Brunner d1561f8ebc sqlite: return new version in downgrade()
We want to give the caller the current version, not the last
version we rolled back, fix that
2023-11-06 07:52:59 +01:00
Reto Brunner ec75ff00cb sqlite: don't modify global array during tests 2023-11-06 07:52:59 +01:00
Reto Brunner 884a92c74b sqlite: fix typo fetch_rollbacks 2023-11-06 07:52:59 +01:00
Reto Brunner 77b64c546b dont' crash on rDNS failure 2023-11-06 07:36:52 +01:00
Reto Brunner cc59e6b578 Merge renovate/is-utf8-0.x 2023-11-04 12:22:05 +01:00
Reto Brunner fb1d79f5fa Merge renovate/bcryptjs-2.x 2023-11-04 12:21:46 +01:00
Reto Brunner 100ff3c198 Merge renovate/actions-setup-node-4.x 2023-11-04 12:19:04 +01:00
Reto Brunner d893feff1c Merge renovate/mousetrap-1.x 2023-11-04 12:18:47 +01:00
Reto Brunner 88a5fef4ea Merge renovate/lodash-4.x 2023-11-04 12:17:54 +01:00
Reto Brunner 5b64ecbe68 Merge renovate/content-disposition-0.x 2023-11-04 12:11:33 +01:00
Reto Brunner 5024acd7dc Merge renovate/cheerio-0.x 2023-11-04 12:10:14 +01:00
Reto Brunner bbfada251c Merge renovate/npm-postcss-vulnerability 2023-11-04 12:09:49 +01:00
Reto Brunner 8cec292f2c Merge dependabot/npm_and_yarn/get-func-name-2.0.2 2023-11-04 12:08:42 +01:00
Reto Brunner 22ae594cc3 bump caniuse-lite 2023-11-04 12:04:51 +01:00
Reto Brunner 1c6bec2323 Merge branch 'cliMigrations' 2023-11-04 11:59:41 +01:00
Reto Brunner 9105fbc23a Merge branch 'emoji' 2023-11-04 11:53:30 +01:00
Reto Brunner 8c54cd50d8 don't crash on rDNS failure
Node apparently throws even on valid ipv6 input in certain environments,
probably due to the DNS server returning SERVFAIL.
Guard against it and fallback with the plain IP

Fixes: https://github.com/thelounge/thelounge/issues/4768
2023-11-04 11:45:11 +01:00
renovate[bot] 59de6afd3f
chore(deps): update dependency @types/is-utf8 to v0.2.2 2023-11-01 00:33:46 +00:00
renovate[bot] b506966b08
chore(deps): update dependency @types/bcryptjs to v2.4.5 2023-11-01 00:33:33 +00:00
renovate[bot] 785ec0a0e2
chore(deps): update actions/setup-node action to v4 2023-10-24 16:50:41 +00:00
renovate[bot] 250433c875
chore(deps): update dependency @types/mousetrap to v1.6.13 2023-10-19 10:52:51 +00:00
renovate[bot] d4d5a8e386
chore(deps): update dependency @types/lodash to v4.14.200 2023-10-19 08:04:02 +00:00
renovate[bot] bcca111a4d
chore(deps): update dependency @types/content-disposition to v0.5.7 2023-10-19 02:00:59 +00:00
renovate[bot] b686059c6b
chore(deps): update dependency @types/cheerio to v0.22.33 2023-10-19 02:00:46 +00:00
renovate[bot] ff77a33663
chore(deps): update dependency postcss to v8.4.31 [security] 2023-10-08 01:28:22 +00:00
dependabot[bot] d308e74183
build(deps): bump get-func-name from 2.0.0 to 2.0.2
Bumps [get-func-name](https://github.com/chaijs/get-func-name) from 2.0.0 to 2.0.2.
- [Release notes](https://github.com/chaijs/get-func-name/releases)
- [Commits](https://github.com/chaijs/get-func-name/commits/v2.0.2)

---
updated-dependencies:
- dependency-name: get-func-name
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-09-28 05:21:42 +00:00
Reto Brunner f999db99c7 Merge branch 'renovate/linkify-it-3.x' 2023-09-18 21:03:10 +02:00
Reto Brunner 76c896aea2 Merge branch 'renovate/bcryptjs-2.x' 2023-09-18 20:22:50 +02:00
dependabot[bot] 08413c7b6b
build(deps): bump word-wrap from 1.2.3 to 1.2.5
Bumps [word-wrap](https://github.com/jonschlinkert/word-wrap) from 1.2.3 to 1.2.5.
- [Release notes](https://github.com/jonschlinkert/word-wrap/releases)
- [Commits](https://github.com/jonschlinkert/word-wrap/compare/1.2.3...1.2.5)

---
updated-dependencies:
- dependency-name: word-wrap
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-09-18 18:13:29 +00:00
renovate[bot] 48301b1ca3
chore(deps): update dependency @types/bcryptjs to v2.4.4 2023-09-16 19:28:31 +00:00
Reto Brunner 03795a2718 router: don't use next() in router guards
Vue wants to get rid of the next call.
https://router.vuejs.org/guide/advanced/navigation-guards.html#Optional-third-argument-next

For one of the router guards, it's easy enough to do so let's do
that.
2023-09-16 13:17:06 +02:00
renovate[bot] 2985727996
chore(deps): update dependency @types/linkify-it to v3.0.3 2023-09-01 01:55:02 +00:00
Max Leiter 9f05a75c39
Merge pull request #4770 from thelounge/smallTScleanup 2023-07-31 11:04:08 -07:00
Reto Brunner c0b38d4762 store: use return type over a type cast 2023-07-31 10:50:48 +02:00
Reto Brunner 2878f87879 Respect bind setting for all outgoing requests
So far the bind config only impacted the IRC connections.
However, nothing in our doc comment says that this is intentional.

bind
Set the local IP to bind to for outgoing connections.

This commit fixes the leak and uses it for all outgoing requests
as described by the docstring.
2023-07-16 12:02:22 +02:00
dependabot[bot] 447a237fc6
build(deps): bump semver from 7.3.5 to 7.5.2
Bumps [semver](https://github.com/npm/node-semver) from 7.3.5 to 7.5.2.
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/main/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v7.3.5...v7.5.2)

---
updated-dependencies:
- dependency-name: semver
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-07-16 09:56:00 +00:00
Reto Brunner 430a865e9f update dependency postcss to v8.4.26 2023-07-16 11:52:16 +02:00
Reto Brunner 816b7686e3 update dependency @types/chai to v4.3.5 2023-07-16 11:50:19 +02:00
Reto Brunner 2e019a2fdb update dependency @types/lodash to v4.14.195 2023-07-16 11:49:15 +02:00
Reto Brunner 4f9ca3e192 update dependency @vue/test-utils to v2.4.0 2023-07-16 11:45:47 +02:00
Reto Brunner 57c4d5513c update dependency webpack-hot-middleware to v2.25.4 2023-07-16 11:42:58 +02:00
Reto Brunner 3e21bfcbea update dependency cheerio to v1.0.0-rc.12 2023-07-16 11:35:49 +02:00
Reto Brunner 607b9fc96a update emoji 2023-07-15 10:41:43 +02:00
Reto Brunner 1a1153aed6 use shebang for generate-emoji script 2023-07-15 10:40:44 +02:00
renovate[bot] 54ff563247
chore(deps): update dependency postcss to v8.4.26 2023-07-14 20:02:42 +00:00
Reto Brunner ed0a47fe2c bump emoji-regex to latest
Fixes: https://github.com/thelounge/thelounge/issues/4761
2023-07-12 08:24:58 +02:00
Reto Brunner 3af4ad1076 Respect bind setting for all outgoing requests
So far the bind config only impacted the IRC connections.
However, nothing in our doc comment says that this is intentional.

> ### bind
> Set the local IP to bind to for outgoing connections.

This commit fixes the leak and uses it for all outgoing requests
as described by the docstring.
2023-06-25 19:30:52 +02:00
Reto Brunner 79fae26f39 test/storage: use helper for url creation
We keep repeating ourselves, let's move that into a helper instead.
In order to get a sane host, we fix the listener to 127.0.0.1
else we get the unspecified :: ipv6 addr (on suitable hosts),
which isn't useful.
2023-06-25 19:30:52 +02:00
Reto Brunner c6b1913b91 test/link: use helper for url creation
We keep repeating ourselves, let's move that into a helper instead.
In order to get a sane host, we fix the listener to 127.0.0.1
else we get the unspecified :: ipv6 addr (on suitable hosts),
which isn't useful.
2023-06-25 19:08:23 +02:00
Reto Brunner 071ad96d9b Merge branch 'signin' 2023-06-25 10:15:21 +02:00
Reto Brunner 2ef8b37009 sqlite: add migrations support and introduce primary key
Add the ability to migrate our db in the upwards direction.
Use the facility to add primary keys to our messages table.
This should allow work like jumping to messages and the likes.

This also introduces the framework for rollback, without actually
hooking it up.
This should be easy enough to do when the need arises.
2023-06-24 14:50:39 +02:00
Reto Brunner 8aa5e33b1d Fix semver for prerelease versions #4744 2023-06-24 14:35:41 +02:00
Reto Brunner 43a2b397a2 Add comments explaining behavior when echo-message is not available 2023-06-24 14:32:33 +02:00
Reto Brunner c43a47afc1 Merge branch 'applePush' 2023-06-24 14:31:23 +02:00
Reto Brunner 14575c94cf Merge branch 'frameworkInternals' 2023-06-24 14:28:48 +02:00
renovate[bot] 303f53fe72
chore(deps): update dependency @vue/test-utils to v2.4.0 2023-06-23 00:52:37 +00:00
renovate[bot] 06f1387f7b
chore(deps): update dependency webpack-hot-middleware to v2.25.4 2023-06-21 23:04:16 +00:00
Reto Brunner c5326e8795 Sign in: use v-model
There's no need to mess with DOM elements, we can use the normal
v-model approach for both username and password
2023-06-18 15:20:08 +02:00
Reto Brunner 355c5d6fa4 v4.4.1 2023-06-13 08:24:37 +02:00
Kufat 7ac2a6fd77 Fix semver for prerelease versions
Noticed this breakage while trying to install a plugin on 4.4.1-rc2.

```
> semver.default.satisfies("4.4.1-rc2", ">=4.3.0")
false
> semver.default.satisfies("4.4.1-rc2", ">=4.3.0", {includePrerelease: true})
true
```
2023-06-01 08:03:07 -04:00
renovate[bot] c4879fdbba
fix(deps): update dependency cheerio to v1.0.0-rc.12 2023-06-01 02:09:05 +00:00
Val Lorentz 4255c1cdec Add comments explaining behavior when echo-message is not available 2023-05-30 22:09:39 +02:00
Reto Brunner ae9d312b2a v4.4.1-rc.2 2023-05-27 08:51:42 +02:00
renovate[bot] f7c6ba5eb1
chore(deps): update dependency @types/lodash to v4.14.195 2023-05-26 23:27:55 +00:00
Reto Brunner 4d60d9c282 bump socket.io-parser from 4.2.1 to 4.2.3 2023-05-25 07:36:54 +02:00
dependabot[bot] af49ef21ea
build(deps): bump socket.io-parser from 4.2.1 to 4.2.3
Bumps [socket.io-parser](https://github.com/socketio/socket.io-parser) from 4.2.1 to 4.2.3.
- [Release notes](https://github.com/socketio/socket.io-parser/releases)
- [Changelog](https://github.com/socketio/socket.io-parser/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io-parser/compare/4.2.1...4.2.3)

---
updated-dependencies:
- dependency-name: socket.io-parser
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-05-25 05:33:59 +00:00
Reto Brunner 7a9ddc01e1 settings: make missing_field msg descriptive
The "missing_fields" error triggers on any missing field (duh).
"Please enter a new password" is not a sensible string for that.
2023-05-23 08:24:20 +02:00
Reto Brunner 8f08cf3d0b client: fix password change input
The TS rewrite dropped the form that was expected to be passed
as props.
That lead to the password change being borked, as the fields
were always set to "null".
We don't need a form, can just use refs here.
2023-05-23 08:18:20 +02:00
Reto Brunner e05871fd2f 4.4.1-rc.1
Co-Authored-By: Max Leiter <maxwell.leiter@gmail.com>
2023-05-21 14:42:30 +02:00
Reto Brunner ede48ab034 Merge branch 'previewFix' 2023-05-20 10:10:50 +02:00
Reto Brunner 4c6fa550aa Merge branch 'nodeEOL' 2023-05-20 10:09:48 +02:00
Reto Brunner 9388960497 linkPreview: Pass channel prop
Else the update logic doesn't work and we don't show
the next / prev buttons
2023-05-15 09:38:09 +02:00
Reto Brunner 7bce779254 Remove unused code 2023-05-15 09:37:40 +02:00
Reto Brunner a7b85db990 v4.4.0 2023-05-13 13:21:12 +02:00
Reto Brunner f4ef11de3f v4.4.0-rc.1 2023-05-06 10:39:34 +02:00
Reto Brunner 3a63484762 Merge branch 'changelog' 2023-05-06 10:36:58 +02:00
Reto Brunner 04b2bf036b bump socket.io to 4.6.1 2023-05-04 23:39:07 +02:00
Max Leiter 3066f48a69
Merge pull request #4728 from thelounge/settingsFormProp 2023-05-01 11:43:07 -07:00
renovate[bot] ed40c83a2b
Update dependency @types/chai to v4.3.5 2023-05-01 02:39:13 +00:00
Reto Brunner f21f665384 Add changelog for 4.4.0
Co-Authored-by: Max Leiter <maxwell.leiter@gmail.com>
2023-04-29 13:52:18 +02:00
Reto Brunner 12d9ef34f0 Client/Settings: Remove bogus settings-form prop
As is this has no effect, other than looking weird in html:
<div settings-form="[object HTMLFormElement]"><div><h2>Native app</h2>
2023-04-29 11:52:34 +02:00
Reto Brunner 9ee1cf13a8 Publish to npm with provenance 2023-04-25 22:36:42 +02:00
Lenore ba1a4206a6 fix motd display to match settings 2023-04-25 00:49:57 +02:00
Reto Brunner 59cf29ef4a workflows: bump Windows + Mac node versions to 18 2023-04-23 21:48:39 +02:00
Reto Brunner 8e43d8083d node: remove v14, add v20 to build matrix
EOL date reached.
2023-04-23 21:46:53 +02:00
Pavel Djundik 3cd0a75ac2 Publish to npm with provenance
Ref: https://github.blog/changelog/2023-04-19-npm-provenance-public-beta/
2023-04-23 12:11:08 +03:00
Reto Brunner 21d1dbaad6 Unbreak nick colors for existing themes
https://github.com/thelounge/thelounge/pull/4649 broke existing
themes by removing the colored-nicks class from chat.

Considering that we don't bump the major version, keep backwards
compatibility for now
2023-04-17 01:11:35 +02:00
Reto Brunner 90ad06a29a Fix load of channels from user config
While the commits that caused the problem have been reverted,
this still adds test cases to it and make the loading more robust.
2023-04-08 13:46:13 +02:00
Val Lorentz 0c7cc85184 Fix load of channels from user config
Network.export() only writes the "type" key if it's a ChanType.QUERY;
so the config on disk has no "type".

This causes it to be undefined when loading, which breaks various other
checks, and then drops it the next time the config is saved.
2023-04-08 12:38:06 +02:00
Reto Brunner 3be805bd38 sqlite: Add rollback support
This enables db migrations to be undone, or "down migrated".
The down migration shouldn't be done automatically
as it could lead to severe data loss if that were done.
Hence, we still hard fail if we encounter a version lower than what
we have in the DB.

A CLI will be added in a later commit that allows users to explicitly
do that.
2023-03-31 11:34:26 +02:00
Reto Brunner e25c296901 push: remove iOS warning
iOS 16.4 introduced webpush, we can get rid of the special case
in our settings panel.
2023-03-28 10:00:56 +02:00
Max Leiter 4babd17383
Merge pull request #4715 from progval/test-server-teardown
tests/server: Tear down test fixtures in the order they were setup
2023-03-20 13:54:06 -07:00
Max Leiter b408843ff1
Merge pull request #4717 from thelounge/changelog
Inline logger into changelog script
2023-03-20 13:53:41 -07:00
Reto Brunner 0f3487c533 Inline logger into changelog script
We can't really import easily from our build without it being
brittle. TL isn't meant to be used as a library.

Instead, just inline the logger as it is trivial enough.
2023-03-19 23:49:42 +01:00
Reto Brunner 21ada132b1 v4.4.0-pre.2 2023-03-19 22:07:59 +01:00
Reto Brunner 2f162daee1 Revert "models/chan: don't force existence of constructor properties"
This reverts commit e31c95e32d.
2023-03-19 21:58:14 +01:00
Reto Brunner 3ac9c36d95 Revert "user: don't force existence of constructor properties"
This reverts commit c3e3322a79.
2023-03-19 21:58:14 +01:00
Reto Brunner c30da27f95 Revert "network: don't force existence of constructor properties"
This reverts commit 429efb0c3c.
2023-03-19 21:58:14 +01:00
Reto Brunner 30a3ba489a 4.4.0-pre.1 2023-03-19 13:43:01 +01:00
Reto Brunner 0dca3954f4 Add changelog entry for v4.4.0-pre.1 2023-03-19 13:35:26 +01:00
Reto Brunner e8b6434144 Clean up command input code 2023-03-19 12:57:08 +01:00
Val Lorentz edc6f77c64
add setup 2023-03-18 07:50:35 +01:00
Val Lorentz 0dd74a93bf tests/server: Tear down test fixtures in the order they were setup
if for whatever reason before() fails to import the server, it causes after()
to fail on the first line, so it doesn't restore stubs; causing other errors
to be printed in other tests ("TypeError: Attempted to wrap warn which is
already wrapped")
2023-03-17 17:12:29 +01:00
Reto Brunner 4e954b919c server/client: refactor command input
Keep happy path on the left and try to return as early
as we can to help the reader understand the logic better

The function is too large to be able to quickly scan an if / else
chain and see the function return at the end
2023-03-17 11:28:54 +01:00
Reto Brunner eb509f7100 Fix config typing and make Client easier to test 2023-03-17 11:03:50 +01:00
Reto Brunner 845dabad53 Fix sqlite query invocation in test
46da1abba4
changed the types of the db functions... fix our code to deal with it.
2023-03-17 10:57:21 +01:00
Reto Brunner 6b00ccf82b update dependency webpack to v5.76.0 2023-03-17 10:19:43 +01:00
renovate[bot] 34a01c2dd1
chore(deps): update dependency sqlite3 to v5.1.6 2023-03-15 19:08:51 +00:00
Val Lorentz 320075e376 Remove override of UserConfig 2023-03-15 11:49:13 +01:00
Val Lorentz d58fb84565 Fix test wording 2023-03-15 08:40:53 +01:00
Val Lorentz a049a01aeb Client: move socket connection out of the constructor
It will make it easier to write tests for what used to be in
the connect() method
2023-03-15 08:40:53 +01:00
Val Lorentz 76098d7e76 Fix incorrect typing of dehydrated networks and channels
Client and ClientManager deal with both 'dehydrated' channels/networks (ie. directly
from JSON configuration) and the 'rehydrated' ones (classes, with socket objects,
message arrays, etc.).

However, because their attributes are similar, both types were used interchangeably,
which becomes an issue when splitting Client's configuration loading into smaller
methods.
2023-03-15 08:40:53 +01:00
renovate[bot] a67cee1ee4
chore(deps): update dependency webpack to v5.76.0 [security] 2023-03-14 22:38:59 +00:00
Reto Brunner efd24fd12c packaging: Use an include list in package.json 2023-03-14 07:35:50 +01:00
renovate[bot] bc4c3082b8
chore(deps): update dependency sqlite3 to v5.1.5 [security] 2023-03-14 00:17:53 +00:00
Reto Brunner d471a4c959 packaging: Use an include list in package.json
Rather than playing whack a mole with an exclude list, let's use
an include list instead.
2023-03-12 17:44:21 +01:00
Reto Brunner 4831c20804 update dependency webpack-dev-middleware to v5.3.3 2023-03-12 12:33:10 +01:00
Reto Brunner eddcbcc766 update dependency vue-loader to v17.0.1 2023-03-12 12:32:48 +01:00
Reto Brunner 0183d89384 update dependency sinon to v13.0.2 2023-03-12 12:32:28 +01:00
Reto Brunner 95e56300db update dependency postcss to v8.4.21 2023-03-12 12:32:12 +01:00
renovate[bot] 8e249d46af
chore(deps): update dependency postcss to v8.4.21 2023-03-12 11:18:55 +00:00
renovate[bot] 50e8d2a890
chore(deps): update dependency @vue/test-utils to v2.3.1 2023-03-10 04:58:30 +00:00
Reto Brunner 7f6059d5b7 input/raw: use the irc-framework api
We are not allowed to mess with the connection object directly
according to the public api surface of the framework
2023-03-04 18:17:17 +01:00
Reto Brunner 8ca9ee873b use the irc connected helper function
We should not mess with irc-framework internals.
Technically we shouldn't even access the connection object,
it's not part of the documented API surface
2023-03-04 18:16:28 +01:00
Reto Brunner 402332340b pluginCommand: type it and guard against bad input 2023-03-04 17:00:53 +01:00
renovate[bot] 4742a07721
chore(deps): update dependency webpack-dev-middleware to v5.3.3 2023-03-01 00:16:25 +00:00
renovate[bot] 2f8dc01930
chore(deps): update dependency vue-loader to v17.0.1 2023-03-01 00:15:55 +00:00
Reto Brunner fade6a8d2e network: add getLobby accessor
This documents what we actually want and allows us to shift the
logic to the network
2023-02-27 18:30:33 +01:00
Reto Brunner dfed1dd757 skip migrations if the user has disabled logging 2023-02-27 14:33:34 +01:00
Reto Brunner d67277d996 clientManager: Expose user config 2023-02-27 14:33:34 +01:00
Reto Brunner 95aaba43fa cli: Implement storage migrate subcommand
This introduces the ability to run the migration offline, while
TL is not running as the migrations can take a long time.

The migrate command is added as a `thelounge storage` subcommand.
Reason being that it is expected that more subcommands will follow,
say `thelounge storage clean` to remove partial data from the db.
2023-02-27 14:33:34 +01:00
Reto Brunner 3e7255ff20 sqlite: Add primary keys to the messages table
We want primary keys to never get re-used to so that we
can implement jump to messages / context fetching etc
in the future.

This isn't hooked up yet at all to the rest of the code, only
the schema is changed
2023-02-27 14:20:31 +01:00
Reto Brunner 86e376fc03 sqlite: run migrations on startup 2023-02-27 14:20:31 +01:00
Reto Brunner 899762cddd sqlite: Add infrastructure for migration tests
This sets up the testing infrastructure to test migrations we are
doing.
It's done on a in memory database directly, we are only interested
in the statements themselves and it's easier than to try and
inject a prepared db into the store.

We do add some dummy data though to make sure we actually execute
the things as we expect.
2023-02-27 14:20:29 +01:00
Reto Brunner 063aca948c sqlite: don't hardcode version test 2023-02-27 14:17:04 +01:00
Reto Brunner 25642fbe98 sqlite: delete table creation test
This just repeats the hard coded values from the code, which
is not helping.
We need to touch that test whenever we modify the sql which is
undesired and it doesn't test any useful functionality.

Any error that may ensue would hopefully be tracked by the other
test.
2023-02-27 14:11:47 +01:00
Max Leiter c2e7390127
Merge pull request #4685 from thelounge/networkProps
network: don't force existence of constructor properties
2023-02-26 17:23:23 -08:00
Max Leiter d10a59395c
Merge pull request #4684 from thelounge/userProps
user: don't force existence of constructor properties
2023-02-26 17:22:31 -08:00
Max Leiter 8fc696620f
Merge pull request #4683 from thelounge/chanProps
models/chan: don't force existence of constructor properties
2023-02-26 17:22:06 -08:00
Max Leiter c6a202d6ab
Merge pull request #4686 from thelounge/decoupleServer
Decouple server
2023-02-26 17:20:20 -08:00
Max Leiter 7c9ed14909
Merge pull request #4695 from maxpoulin64/fix/oidentd-crash-race-condition
Don't crash on oidentd socket race condition
2023-02-25 13:44:37 -08:00
Reto Brunner bdc1f23107 fix formatting 2023-02-18 11:46:31 +01:00
Reto Brunner e9a09f5447 Add id to error log 2023-02-18 11:35:52 +01:00
Reto Brunner d93cd88dd5 Fix uploader mount/unmount lifecycle #4691 2023-02-18 11:16:30 +01:00
Pavel Djundik 2f04150461 Fix git commit not being available in dist build 2023-02-15 12:03:08 +02:00
Max Leiter c816e4053e
Merge pull request #4692 from thelounge/irc-framework
bump irc-framework to 4.13.1
2023-02-13 20:56:38 -08:00
Max Leiter 4cff2ccabe
Link to PR in log.warn 2023-02-13 20:51:27 -08:00
Max Leiter 26b7fbf2c0
Apply suggestions from code review
Co-authored-by: Mina Galić <me+github@igalic.co>
2023-02-13 20:50:16 -08:00
Maxime Poulin 243cb10e2a
Don't crash on oidentd socket race condition 2023-02-08 22:05:22 -05:00
dependabot[bot] 7304acd8e0
build(deps): bump http-cache-semantics from 4.1.0 to 4.1.1
Bumps [http-cache-semantics](https://github.com/kornelski/http-cache-semantics) from 4.1.0 to 4.1.1.
- [Release notes](https://github.com/kornelski/http-cache-semantics/releases)
- [Commits](https://github.com/kornelski/http-cache-semantics/compare/v4.1.0...v4.1.1)

---
updated-dependencies:
- dependency-name: http-cache-semantics
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-02-06 21:21:55 +00:00
Reto Brunner 511209a100 bump irc-framework to 4.13.1 2023-02-06 22:20:40 +01:00
Maxime Poulin 2ce374fe85 Fix uploader mount/unmount lifecycle
Currently, in `ChatInput.vue` we call `upload.abort()` which removes the event listeners, which are never added back. This effectively permanently disable uploads if the user navigates away to Settings or any other non-chat pages, and back.

Moves the binding to `mounted()` so that they're properly rebound when a chat window is in view, and also adds an `unmounted()` for clarity.

This should also fix an edge case if the page opens up on a non-chat page and there was never a ChatInput to unbind it, such as login page or add network pages.
2023-02-05 22:32:12 -05:00
Max Leiter 00366967ae
Merge pull request #4690 from maxpoulin64/fix-morning-colors
Fix Morning theme nick colors
2023-02-05 14:01:49 -08:00
Maxime Poulin f2c59c23e2 Fix Morning theme nick colors
PR #4649 introduced a regression on the Morning theme as the `#chat.colored-nicks` CSS selector was removed from Default but not Morning. The result is that Morning no longer had nick colors.
2023-02-05 03:22:43 -05:00
renovate[bot] 90d17cacc1
chore(deps): update dependency sinon to v13.0.2 2023-02-01 02:17:22 +00:00
Reto Brunner 12c03a868d base tsconfig: remove files section
All files are specified in the individual sub projects
2023-01-30 09:14:40 +01:00
Reto Brunner b7540b5827 Move condensedTypes to shared/
This decouples the rest of the server from the client
2023-01-30 09:14:40 +01:00
Reto Brunner 6f13735a7f eslint: add shared/ 2023-01-30 09:14:40 +01:00
Reto Brunner 60bb561e49 Extract tests to shared/ 2023-01-30 09:14:40 +01:00
Reto Brunner e305e23c43 client: use the versions in shared/ where applicable 2023-01-30 09:14:40 +01:00
Reto Brunner 9d34955836 extract cleanIrcMessage from client to shared 2023-01-30 09:14:40 +01:00
Reto Brunner a8149c0f1a Extract linkify to shared directory
This is the first step to sever any dependency of the server on
the client
2023-01-30 09:14:40 +01:00
Reto Brunner 21d1eea6b8 tsconfig: Add shared reference 2023-01-30 09:14:40 +01:00
Reto Brunner e1ae79cb9c server/tsconfig: remove redundant options 2023-01-30 09:14:40 +01:00
Reto Brunner 429efb0c3c network: don't force existence of constructor properties 2023-01-30 08:52:30 +01:00
Reto Brunner c3e3322a79 user: don't force existence of constructor properties 2023-01-30 01:45:58 +01:00
Reto Brunner e31c95e32d models/chan: don't force existence of constructor properties 2023-01-30 00:29:09 +01:00
William Goodspeed f785acb07d
Fix misleading LDAP filiter in default config
The default filter provided in config files is invalid. This may mislead people. Confirm to issue #4620.
2023-01-27 20:02:57 +08:00
renovate[bot] bde5c3d443
fix(deps): update dependency ua-parser-js to v1.0.33 [security] 2023-01-24 17:40:19 +00:00
Reto Brunner 375164ca88 Merge branch 'storageCleanup' 2023-01-22 15:23:56 +01:00
Reto Brunner 7f3ac62e0d Merge branch 'searchFixTakeTwo' 2023-01-22 15:22:32 +01:00
dependabot[bot] ce3ad56ced build(deps): bump json5 from 2.2.1 to 2.2.3
Bumps [json5](https://github.com/json5/json5) from 2.2.1 to 2.2.3.
- [Release notes](https://github.com/json5/json5/releases)
- [Changelog](https://github.com/json5/json5/blob/main/CHANGELOG.md)
- [Commits](https://github.com/json5/json5/compare/v2.2.1...v2.2.3)

---
updated-dependencies:
- dependency-name: json5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-01-22 15:13:25 +01:00
Reto Brunner efd3b64564 caniuse-lite: update db 2023-01-22 15:13:25 +01:00
renovate[bot] 6b23b87063 chore(deps): update dependency @vue/test-utils to v2.2.7 2023-01-22 15:13:25 +01:00
renovate[bot] 502fb7a705 chore(deps): update dependency @types/ws to v8.5.4 2023-01-22 15:13:24 +01:00
renovate[bot] c854d27d3d chore(deps): update dependency sqlite3 to v5.1.4 2023-01-22 15:13:24 +01:00
Max Leiter 2803018c5a
Merge pull request #4669 from thelounge/collapseAway
allow away and back to be collapsed
2023-01-20 15:30:53 -08:00
Reto Brunner 0ebc3a574c search: ignore searchResults if it isn't the active query
Prior to this, the search is still racy but one tends to notice
this only when the DB is large or network is involved.
The user can initiate a search, get bored, navigate to another chan
issue a different search.

Now however, the results of the first search come back in and
hilarity ensues as we are now confused with the state.

To avoid this, keep track of the last search done and any result
that comes in that isn't equal to the active query is garbage and
can be dropped.
2023-01-08 11:41:09 +01:00
Reto Brunner 958a948456 sqlite: Remove client from sqlitestorage
The only reason we accepted a client was that so we have access
to the next message id when we need it.
So let's accept an id provider function instead.
2022-12-30 16:52:04 +01:00
Reto Brunner 52b8a2a78e textStorage: rip out client instance
We don't need the client, so there's no need to accept it.
2022-12-30 16:42:48 +01:00
Reto Brunner 661d5cb5b0 messagestorage: remove implementation details from interface
The interface should not contain things that aren't the API of the
storage interface.
Further, rename ISqliteMessageStorage to SearchableMessageStorage,
as that's also an implementation detail.
We'll never have a second sqlite backend, so the name seems
strange.
2022-12-30 16:42:48 +01:00
Reto Brunner e597e75847 allow away and back to be collapsed
This means we also apply the collapsing to normal queries,
which might also collapse other things like joins / quits
which may be undesired by some

Fixes: https://github.com/thelounge/thelounge/issues/4583
2022-12-30 13:35:38 +01:00
aab12345 8b1a4f72fa Add password param to /join docs 2022-12-29 13:12:15 +01:00
Pavel Djundik 502780c5a3 Fix sidebar swipe flicker after letting go 2022-12-23 10:50:20 +02:00
Reto 073a38ef1e
Fix previous-source calculation (#4656)
CondensedMessage is a proxy object, outside of the templates
we need to unwrap it manually
2022-12-17 13:59:07 -08:00
Reto Brunner c67df36a29 update dependency @types/lodash to v4.14.191 2022-12-04 12:58:15 +01:00
Reto Brunner d50296385f Merge branch 'sqliteHotFix' 2022-12-04 12:54:45 +01:00
Reto Brunner 068de0c10c Merge branch 'nickColorScope' 2022-12-04 12:53:31 +01:00
renovate[bot] d61ab7e7a0
chore(deps): update dependency @types/lodash to v4.14.191 2022-12-01 21:36:32 +00:00
Reto Brunner 2d4143b779 sqlite: synchronize enable() internally
TL is stupid and doesn't wait for message{Provider,Storage} to
settle before it starts using the store.

While this should be fixed globally, we can hack around the problem
by pushing everything onto the call stack and hope that we'll eventually
finish the setup before we blow the stack.
2022-11-30 10:28:26 +01:00
Reto Brunner f55f772659 style: Put user colors into the smallest possible scope
The only thing that cares about user colors is the user component.
Putting a class value on the chat component seems to be the wrong
place.

This also allows us to remove various css selectors so that we
don't need to be that specific.
After all whatever has that class needs to be colored, we don't
care where it is.
2022-11-27 16:04:56 +01:00
Reto Brunner 982816ff20 store: addMessageSearchResults shouldn't accept null
It makes no sense to emit a add mutation with null, so let's
forbid it.
2022-11-27 14:06:14 +01:00
Reto Brunner 8204c3481a search: fix order of result merging
During a search, we get the results from oldest --> newest.
When we hit the more button, we get the results of the second batch
in the same order.
However, logically to the first batch everything is older, so we
need to prepend it to the result array, not
append.

msg  DB  logical ID
A    3     5
B    2     4
C    1     3

D    3     2
E    2     1
F    1     0
2022-11-27 14:06:14 +01:00
Reto Brunner deeea274da Merge branch 'sqlite_cleanup'
Converts sqlite to async, providing a way forward
for migrations to actually happen
2022-11-24 09:45:01 +01:00
Reto Brunner d34b58811a Merge branch 'search' 2022-11-24 09:34:24 +01:00
Reto Brunner dfb4217167 remove VueApp from router
Nothing actually depends on the vue app being monkey patched onto
the router, so let's get rid of it.
2022-11-22 21:27:19 +01:00
dependabot[bot] f8eb0ebafd
Bump engine.io from 6.2.0 to 6.2.1
Bumps [engine.io](https://github.com/socketio/engine.io) from 6.2.0 to 6.2.1.
- [Release notes](https://github.com/socketio/engine.io/releases)
- [Changelog](https://github.com/socketio/engine.io/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/engine.io/compare/6.2.0...6.2.1)

---
updated-dependencies:
- dependency-name: engine.io
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-11-22 08:33:49 +00:00
Reto Brunner fd14b4a172 make getClientConfiguration type safe
TS type assertions need to be avoided.

The following trivial example demonstrates why

```
type Person = {
	name: string;
	isBad: boolean;
};

function makePerson(): Person {
	const p: Person = {name: 'whatever'} as Person
	p.isBad = false
	return p // theoretically we are now good, p is a Person
}
```

Should the type ever change though, TS will happily trot along

```
type Person = {
	name: string;
	isBad: boolean;
	omgHowCouldYou: number;
};

function makePerson(): Person {
	const p: Person = {name: 'whatever'} as Person
	p.isBad = true
	return p // p is *not* a Person, omgHowCouldYou is missing
}
```

But we pinky swore to the compiler that p is in fact a Person.
In other words, the types are now wrong and you will fail during
runtime.
2022-11-22 03:07:29 +01:00
Reto Brunner 1597c2c56e server: the http{,s} server can't be null 2022-11-22 02:21:27 +01:00
Reto Brunner 4c7337b625 bump socket.io-client to 4.5.0 2022-11-16 07:18:26 +01:00
Reto Brunner 0765d209f2 keybinds: Fix invalid return
Mousetrap doesn't take an async function.
It either accepts False (stop key propagation) or any other
value (bubble up the event)
2022-11-16 06:50:56 +01:00
Reto Brunner 7ee4b80a6e update dependency @types/mousetrap to v1.6.11 2022-11-16 06:32:22 +01:00
Reto Brunner 21c8b0d17f Bump loader-utils from 2.0.2 to 2.0.4 2022-11-16 06:31:19 +01:00
dependabot[bot] 89245455ce
Bump loader-utils from 2.0.2 to 2.0.4
Bumps [loader-utils](https://github.com/webpack/loader-utils) from 2.0.2 to 2.0.4.
- [Release notes](https://github.com/webpack/loader-utils/releases)
- [Changelog](https://github.com/webpack/loader-utils/blob/v2.0.4/CHANGELOG.md)
- [Commits](https://github.com/webpack/loader-utils/compare/v2.0.2...v2.0.4)

---
updated-dependencies:
- dependency-name: loader-utils
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-11-16 05:29:50 +00:00
Reto Brunner d4bbd9191c bump socket.io to 4.5.2 2022-11-16 06:28:03 +01:00
renovate[bot] 5037383c4c
chore(deps): update dependency @types/mousetrap to v1.6.11 2022-11-16 00:46:37 +00:00
Reto Brunner 83e11b0143 Search: Clear earlier searches when a new one is executed
Fixes: https://github.com/thelounge/thelounge/issues/4637
2022-11-15 18:50:52 +01:00
Reto Brunner 51c9ce078d Search: fix off by one offset error
Offset is eventually passed to sqlite as an OFFSET clause.

This works as follows:

sqlite> select num from seq limit 5 offset 0;
┌─────┐
│ num │
├─────┤
│ 1   │
│ 2   │
│ 3   │
│ 4   │
│ 5   │
└─────┘

sqlite> select num from seq limit 5 offset 5;
┌─────┐
│ num │
├─────┤
│ 6   │
│ 7   │
│ 8   │
│ 9   │
│ 10  │
└─────┘

However, the code currently emits a request for offset + 1, which ends
up skipping a message

sqlite> select num from seq limit 5 offset 5+1;
┌─────┐
│ num │
├─────┤
│ 7   │
│ 8   │
│ 9   │
│ 10  │
│ 11  │
└─────┘
2022-11-15 18:50:52 +01:00
Reto Brunner 8095d9e88a SearchQuery: offset is always a number
Fix type confusion that specified offset to be a string, it is
always a number.
2022-11-15 18:50:52 +01:00
renovate[bot] 221884166d
chore(deps): update dependency postcss to v8.4.19 2022-11-13 17:56:51 +00:00
renovate[bot] 19307d05e7
chore(deps): update dependency @types/chai to v4.3.4 2022-11-13 17:49:23 +00:00
renovate[bot] dfe288ef16 chore(deps): update dependency @types/lodash to v4.14.188 2022-11-13 18:11:48 +01:00
renovate[bot] b5ea7cceb3 chore(deps): update dependency @types/is-utf8 to v0.2.1 2022-11-13 18:11:48 +01:00
renovate[bot] 0ad033fe0a chore(deps): update dependency chai to v4.3.7 2022-11-13 18:11:48 +01:00
renovate[bot] 5a4a39b9d1 chore(deps): update dependency postcss to v8.4.18 2022-11-13 18:11:48 +01:00
renovate[bot] cb17f8d87f chore(deps): update dependency @vue/test-utils to v2.2.1 2022-11-13 18:11:48 +01:00
renovate[bot] 5a803ccd23 chore(deps): update dependency sqlite3 to v5.1.2 2022-11-13 18:11:48 +01:00
Reto 53f6041f42
SearchResults: remove dead code (#4639)
Nachtalb put some infra in place that was never actually working.
It errors out when a user clicks on a message.

Remove the offending code, but keep it all in place so that we
can improve on it.
2022-11-12 22:34:41 -08:00
Reto Brunner dca202427a SearchResults: Fix search progess upon search
When we hit doSearch, we always reset the offset value to 0,
meaning we always hit the conditional (!0) and always set the
messageSearchInProgress flag to undefined.
This is wrong, we do want to set this flag when we initiate a search.
2022-11-12 23:14:53 +01:00
Reto Brunner 6b617f893d SearchResults: remove computed search prop
It is only used in one location, and not from the template.
In other words we should inline it to make the code simpler.
2022-11-12 23:14:53 +01:00
Reto Brunner d62dd3e62d messageStorage: convert to async
Message stores are more complicated that a sync "fire and forget"
API allows for.
For starters, non trivial stores (say sqlite) can fail during init
and we want to be able to catch that.
Second, we really need to be able to run migrations and such, which
may block (and fail) the activation of the store.

On the plus side, this pushes error handling to the caller rather
than the stores, which is a good thing as that allows us to eventually
push this to the client in the UI, rather than just logging it in the
server on stdout
2022-11-02 00:01:36 +01:00
Reto Brunner f068fd4290 sqlite: convert migrations to async
This removes quite a bunch of indention and callbacks
2022-11-01 22:23:47 +01:00
Reto Brunner bbe81bb2fa sqlite: add serialize_get 2022-11-01 22:23:47 +01:00
Reto Brunner f04a06682d extract migrations 2022-11-01 22:23:47 +01:00
Reto Brunner 5e1cbe32f9 sqlite: use serialize_fetchall in search 2022-11-01 22:23:47 +01:00
Reto Brunner ee8223c200 sqlite: use serialize_fetchall in getMessages 2022-11-01 22:23:47 +01:00
Reto Brunner cc3302e874 sqlite: create serialize_fetchall helper function
That puts all the serialization logic into one place and
allows us to use async / promises
2022-11-01 22:23:47 +01:00
Reto Brunner 89ee537364 sqlite: add run helper function
Extract the serialization logic into a single place and
consistently log errors to the console rather than a fire
and forget approach.
2022-11-01 22:23:30 +01:00
Reto Brunner e62b169a6a sqlite: fix docstring 2022-11-01 22:19:56 +01:00
Reto Brunner f6b292107e sqlite: move export to bottom of the file
This makes it easier to see what's getting exported, rather than
if it's interspersed randomly in the middle of the file
2022-11-01 22:19:56 +01:00
Reto Brunner bea4545abf don't call search on a disabled msg provider
A provider might be available, but not functional (broken migration
invalid configuration or what have you).
Don't try to call search in this case.
2022-11-01 22:19:56 +01:00
Reto Brunner cebc6d069f sqlite: error if sqlite isn't enabled but search() is called
When we assert that something can't possibly happen, we better
error out rather than jugging on with no error ;)
2022-11-01 22:19:56 +01:00
Reto 0fa203569a
connect: Trim white space from user input fields (#4623)
Fixes: https://github.com/thelounge/thelounge/issues/4521
2022-09-07 20:25:08 -07:00
Reto Brunner 30e9f45fac Use nick as a realname fallback
Currently the realname is set to an advertisement if it isn't explicitly
set by the user.
Some clients started to show the realname as a display name in their
UI, which makes this tedious as you'll end up with gazillion "The Lounge
User" entries.

To avoid this, set the realname to the nick on first connect, so that
it is useful.
Note that this isn't done on nick changes, but only on the initial
connect step.

Fixes: https://github.com/thelounge/thelounge/issues/4527
2022-08-28 11:21:54 +02:00
Antonio Mika 117c5fa3fd
Added client type checking to webpack (#4619)
* Added client type checking

* Fixed client-side typescript issues
2022-08-23 00:26:07 -07:00
Reto Brunner 621fa92036 linkPreviews: Enforce TLS validity
When a URL is prefixed with a TLS scheme, we should make sure
that the remote provides a valid cert, even just for prefetches.
Else MITM of such a site is trivial.

This probably breaks some people with self signed cert, but the
age where that was acceptable is past. We have free CAs now like
Let's Encrypt.
2022-08-06 12:37:51 +02:00
Reto Brunner 11f7ae98be Merge branch 'regexFix' 2022-08-01 13:31:59 +02:00
Reto Brunner a95ab55154 Merge branch 'installDocs' 2022-08-01 13:29:48 +02:00
Reto Brunner 38bccd3635 Merge branch 'installExpandHome' 2022-08-01 13:29:39 +02:00
Reto Brunner 3240997347 Revert "chore(deps): update dependency @textcomplete/core to v0.1.12"
This reverts commit 0cb4791cd0.
It breaks the autocompletion when clicking on a suggestion
from the nick popup.
2022-07-25 06:44:58 +02:00
renovate[bot] 57ed37c1fd
chore(deps): lock file maintenance 2022-07-24 13:33:42 +00:00
renovate[bot] 0495761c44
fix(deps): update dependency file-type to v16.5.4 [security] 2022-07-24 13:20:16 +00:00
renovate[bot] 520646a212 chore(deps): update dependency sqlite3 to v5.0.10 2022-07-24 15:02:54 +02:00
renovate[bot] 0cb4791cd0 chore(deps): update dependency @textcomplete/core to v0.1.12 2022-07-24 15:02:14 +02:00
renovate[bot] 740618ca49 chore(deps): update dependency @types/content-disposition to v0.5.5 2022-07-24 15:01:22 +02:00
renovate[bot] e97216518a chore(deps): update dependency @textcomplete/textarea to v0.1.12 2022-07-24 14:55:19 +02:00
Reto Brunner 31739b8ac9 install: Document file: prefix in cli help 2022-07-23 23:05:34 +02:00
Reto Brunner e221e708c1 install: expand ~ for local paths
Make `thelounge install file:~/path/to/package` work rather than
erroring out that the folder doesn't exists.

Probably funny on Windows, but it doesn't hurt either
2022-07-23 17:05:31 +02:00
Reto c8cd4057bc
Fix ctcp request message (#4603)
The message was ordered the wrong way in the TS rewrite.

Old:
    +bookworm sent a CTCP request: "chadler" to version
New:
    +bookworm sent a CTCP request: "version" to chadler
2022-07-06 22:28:18 -07:00
Reto Brunner d6e1af0e7d Fix regex escape for prefix patterns
Our regex escape function escapes proper regexes, however
it isn't meant to be shoved into a char class via string interpolation.

We need to also escape '-' if we do so.
2022-07-04 10:08:23 +02:00
Max Leiter d72d8694bb
Potentially fix saving new networks (#4599) 2022-06-28 13:32:08 -07:00
Max Leiter 80f65c5b72
Remove uploading event listeners on ChatInput unmount (#4600) 2022-06-28 13:31:55 -07:00
Pavel Djundik bc709af9fe
Merge pull request #4596 from thelounge/renovate/npm-got-vulnerability
fix(deps): update dependency got to v11.8.5 [security]
2022-06-22 18:48:15 +03:00
renovate[bot] e7d18a91c0
fix(deps): update dependency got to v11.8.5 [security] 2022-06-22 05:33:15 +00:00
renovate[bot] ddcee5371a
chore(deps): update dependency sqlite3 to v5.0.8 (#4564)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-06-21 22:32:58 -07:00
renovate[bot] 194b85be4d
chore(deps): update dependency mocha to v9.2.2 (#4581)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-06-21 22:32:18 -07:00
Pavel Djundik f715c833e7
Merge pull request #4594 from thelounge/xpaw/fix-4593
Fix user commands not working
2022-06-21 15:05:19 +03:00
Pavel Djundik a15ac88ff2 Fix user commands not working
Fixes #4593
2022-06-21 10:51:24 +03:00
Murph Finnicum 4af5fc6f33
Use correct option name (filter instead of ldapFilter) in config.js comment. (#4590)
The comment for the "filter" key under "searchDN" refers to it as "ldapFilter" instead of "filter".
2022-06-18 18:23:59 -07:00
Max Leiter dd05ee3a65
TypeScript and Vue 3 (#4559)
Co-authored-by: Eric Nemchik <eric@nemchik.com>
Co-authored-by: Pavel Djundik <xPaw@users.noreply.github.com>
2022-06-18 17:25:21 -07:00
Eric Nemchik 2e3d9a6265
Fix yarn dev (#4574)
* Fix yarn dev and yarn test
2022-05-21 11:45:42 -07:00
Eric Nemchik c205b89523
Convert configs to cjs, move babel to own file, combine webpack configs (#4561)
* Convert configs to cjs
* Fix lint script in package.json
* Move babel config to separate file
* Combine webpack configs and include babelConfig
2022-05-02 19:19:12 -07:00
John Sullivan 5f7acbf994
Merge pull request #4489 from thelounge/maxleiter/tabbedSettings
Refactor settings to their own tabs and routes
2022-05-01 15:03:03 -07:00
Reto d4cc2dd361
Refactor config out of Helper (#4558)
* Remove config from Helper

Helper is the usual util grab bag of useful stuff.
Somehow the config ended up there historically but
structurally that doesn't make any sense.

* Add cert folder to prettier ignore file
2022-05-01 12:12:39 -07:00
renovate[bot] 38f13525e6
chore(deps): update babel monorepo (#4554)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-05-01 02:40:09 -07:00
renovate[bot] 99c48dbcea
chore(deps): update dependency @textcomplete/core to v0.1.11 (#4555)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-05-01 02:39:58 -07:00
Pavel Djundik 9dbb6e5e19
Remove node 12, add node 18. Bump minimum node version 14 (#4552)
* Remove node 12, add node 18
* Bump minimum node version
2022-04-30 13:12:24 -07:00
Reto Brunner 791205d4f0 Merge branch 'zncPlayback' 2022-04-30 12:55:54 +02:00
Max Leiter 437dd1667d
Improve setings menu responsiveness 2022-04-29 19:30:35 -07:00
Max Leiter 24bdc46b0a
Settings: move nav to left side on larger screens 2022-04-29 19:27:52 -07:00
Max Leiter 5a383814f6
Settings: nav style tweaks 2022-04-29 19:27:51 -07:00
Max Leiter 1f39e078f4
Settings: change general icon from paintbrush to desktop 2022-04-29 19:27:51 -07:00
Max Leiter 6f64243671
Settings: rename user settings -> account 2022-04-29 19:27:51 -07:00
Max Leiter 31b67b7786
git push --set-upstream origin maxleiter/tabbedSettings 2022-04-29 19:27:50 -07:00
Reto Brunner abf8906757 Merge sqlite3 upgrade to v5.0.6 2022-04-28 21:38:01 +02:00
Reto Brunner c8115e22ac enable znc/playback even without message storage
Fixes: https://github.com/thelounge/thelounge/issues/4464
2022-04-28 17:02:36 +02:00
Reto Brunner aa7db1e7f7 Merge pull/4524: Add prefetchTimeout 2022-04-27 18:22:49 +02:00
Renovate Bot da02350725
chore(deps): update dependency sqlite3 to v5.0.6 2022-04-27 16:04:31 +00:00
Reto Brunner c9c8cadb1a Merge pull/4477 Preserve client certificate 2022-04-27 17:58:09 +02:00
Max Leiter 3726a8d00b
Merge pull request #4541 from thelounge/renovate/sqlite3-5.x
Update dependency sqlite3 to v5.0.4
2022-04-19 17:23:23 -07:00
Reto 605b75c6ed
Merge pull request #4537 from bookworm/renovateShutUp
renovate: Disable digest updates
2022-04-19 06:33:35 +02:00
Max Leiter 5e8adafb3e
Merge pull request #4539 from ronilaukkarinen/master
Fix the alignment of the header buttons
2022-04-18 15:15:48 -07:00
John Sullivan 487d880d32
Merge pull request #4540 from itsjohncs/sortable-upstream
Pull in SortableJS from NPM again.
2022-04-18 15:05:18 -07:00
Renovate Bot 7cb8d33122
Update dependency sqlite3 to v5.0.4 2022-04-18 13:05:43 +00:00
itsjohncs bbe103ca6f Pull in SortableJS from NPM again.
SortableJS/Sortable#2095 has been merged so we no longer need to use
our fork.
2022-04-17 17:34:07 -07:00
Roni Laukkarinen ec757c9b69 Fix topic wrapping on mobile 2022-04-17 19:07:56 +03:00
Roni Laukkarinen 7b725ea55c Fix the alignment of the header buttons 2022-04-17 13:39:52 +03:00
Reto Brunner 0d12be138b renovate: Disable digest updates
There's a reason one pins a commit and it is because we
want that specific commit.
Renovate gets *really* noisy if commits are made frequently
to a repo, so let's disable it.
2022-04-13 21:48:21 +02:00
Pavel Djundik 7db0d4619d
Update sqlite3 to 5.0.3 2022-04-13 19:07:31 +03:00
Max Leiter bdd6e71049
Autocomplete: update to @textcomplete package and close on blur (#4493)
* Autocomplete: update to @textcomplete package
* Autocomplete: close on blur
2022-04-11 18:11:43 -07:00
xnaas 57b1e51e9f
set 'video/quicktime' to 'video/mp4' (#4495)
`video/quicktime` only plays in Firefox and Safari.
`video/mp4` plays in Firefox, Safari, and Chromium-based browsers.
2022-04-11 17:50:00 -07:00
Val Lorentz 20ed3e6dc5
sqlite: Escape '%' and '_' in search queries. (#4487)
I picked '@' arbitrarily, it doesn't matter much.
I just don't like '\' because it needs to be escaped itself in the JS code,
which is annoying.
2022-04-11 17:49:13 -07:00
Reto e4840b4d75
Plugins: include pre-releases in compatibility lookup (#4506)
Semver doesn't treat pre-release versions as upgrades, meaning >4.3.0 isn't satisfied
by 4.3.1-rc.1.
For the purpose of TL plugins however, we are only interested in the semantic version and
expect that rc's adhere to the compatibility promise.
2022-04-11 17:47:51 -07:00
Reto d7bba325a7
Fix user file permissions on create (#4507)
User files contain secrets and should be protected.
Chances are that the user folder can be protected as well,
so let's do that if TL is creating the folder.
2022-04-11 17:47:22 -07:00
Reto 815319810c
cli: don't error if the user folder doesn't exist (#4508)
The user folder gets created on demand, thelounge list should not
fail if the folder doesn't exist.
This just means that no users are present, so report that instead.
2022-04-11 17:46:29 -07:00
Reto 37d7de7671
Kill TL when ident can't start up (#4512)
Fixes: https://github.com/thelounge/thelounge/issues/4509
2022-04-11 17:45:36 -07:00
Max Leiter e362704f6b
v4.3.1 2022-04-11 17:29:13 -07:00
Max Leiter 48f2b79c37
Add changelog entry for v4.3.1 2022-04-11 17:26:23 -07:00
Emily Strickland 3a84290314
Apply fixes suggested by Prettier to fix CI 2022-04-09 19:40:38 +00:00
Emily Strickland ff886846a8
Warn about unset prefetchTimeout, default to 5000 ms 2022-04-09 00:19:08 +00:00
Emily Strickland b2a363f099
Document default value for prefetchTimeout 2022-04-09 00:17:57 +00:00
Emily Strickland 3796485217
Configure link fetch to use prefetchTimeout
This change modifies the `fetch` function in `link.js` to use the new `prefetchTimeout` config setting introduced in the previous commit. This allows configuring the length of the timeout.

I've added a comment here to indicate milliseconds are the unit in use, since otherwise that would no longer be obvious from the code without looking at the default value (which could change).
2022-04-08 22:49:20 +00:00
Emily Strickland 3202b79990
Set prefetchTimeout setting default to 5000 ms
This change adds a `prefetchTimeout` setting to the default configuration and sets it to 5000 milliseconds. Its description indicates the advantages and disadvantages of changing its default value.
2022-04-08 15:42:31 -07:00
Renovate Bot a42325d801
Lock file maintenance 2022-04-06 19:03:11 +00:00
dependabot[bot] bbc7280c41
Bump minimist from 1.2.5 to 1.2.6
Bumps [minimist](https://github.com/substack/minimist) from 1.2.5 to 1.2.6.
- [Release notes](https://github.com/substack/minimist/releases)
- [Commits](https://github.com/substack/minimist/compare/1.2.5...1.2.6)

---
updated-dependencies:
- dependency-name: minimist
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-04-06 18:42:08 +00:00
Reto Brunner b76058e4cf Merge renovate/express-4.x 2022-04-06 20:39:12 +02:00
Reto Brunner ace09d434c Merge renovate/babel-monorepo 2022-04-06 20:37:14 +02:00
Renovate Bot 4d9442d9e3
Update babel monorepo 2022-04-06 17:19:54 +00:00
Renovate Bot 56bf078e29
Update dependency express to v4.17.3 2022-04-01 00:57:31 +00:00
Renovate Bot 9f7a2e942b
Update dependency node-forge to v1.3.0 [SECURITY] 2022-03-26 03:05:38 +00:00
Reto Brunner f440b67dbe Change sqlite3 module name to the actual name
It leads to confusion as the module is named "sqlite3", not
"node-sqlite3"
2022-03-12 16:39:03 +01:00
Val Lorentz ae7020f569 Do not remove client certificate, even when TLS is disabled
It does not really make sense to remove it, as it can lock someone out of
their account, just by temporarily disabling TLS.
2022-03-05 11:20:57 +01:00
Max Leiter 38fa3bee22
v4.3.1-rc.1 2022-03-02 16:38:22 -08:00
Max Leiter 2e1b2d44f6
Add changelog entry for v4.3.1-rc.1 2022-03-02 16:37:49 -08:00
renovate[bot] 69f3501165
Update actions/setup-node action to v3 (#4496)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-03-02 16:31:28 -08:00
renovate[bot] 8a92bc9fb9
Update dependency dayjs to v1.10.8 (#4499)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-03-02 16:27:50 -08:00
renovate[bot] 7cf95d3cbd
Update dependency @babel/core to v7.17.5 (#4498)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-03-01 16:29:56 -08:00
renovate[bot] 53f5b8e991
Lock file maintenance (#4491)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-02-21 12:39:54 -08:00
Val Lorentz d145fb3738
Re-add missing space between timestamp and nick (#4492)
It was accidentally removed by 027c5b4ff7
2022-02-21 12:38:50 -08:00
Max Leiter 551f85ea51
Fix /collapse and /expand from interacting with the server in public mode (#4488)
Reported by xnaas on IRC
2022-02-18 12:21:17 -08:00
Val Lorentz 66455f2c40
Show a nicer error in Chan.loadMessages() when network is misconfigured (#4476)
Show a nicer error in Chan.loadMessages() when network is misconfigured

ie. an actual error message instead of crashing on a `null` value.
2022-02-16 16:27:41 -08:00
Val Lorentz c12dd6c740
Network.validate: Deduplicate code + tell users what the invalid hostname is (#4475)
* De-duplicate error message creation in Network.validate()
* Tell users what the invalid hostname is.
* Reword the log error message
2022-02-16 16:27:14 -08:00
Reto Brunner cb28204517 Use the DNS result order returned by the OS 2022-02-15 09:20:52 +01:00
Reto Brunner e2e050d3c3 Add leading '<' + trim space when copying messages
Fixes: #4369
2022-02-15 09:15:34 +01:00
sfan5 17b174dddb Use the DNS result order returned by the OS
Effectively, this stops Node from always preferring IPv4.
2022-02-15 07:44:13 +01:00
Val Lorentz 027c5b4ff7 Remove leading space when copying multiple messages 2022-02-14 19:41:34 +01:00
renovate[bot] 1ed4f57afc
Lock file maintenance (#4479)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-02-13 16:41:18 -08:00
Val Lorentz 53b4d00732 Preserve client certificate when TLS is indirectly enabled by a STS policy
Closes GH-4152.
2022-02-13 14:43:04 +01:00
Val Lorentz ba210e853b test/models/network.js: Add tests for automatic client certificate creation/deletion 2022-02-13 14:42:38 +01:00
Val Lorentz bd2a6cc5be test/models/network.js: Add a simple test for STS policies 2022-02-13 14:42:01 +01:00
Val Lorentz bcd4a060ec test/models/network.js: Reorder tests 2022-02-13 13:24:38 +01:00
Val Lorentz ed3ec6a560 test/models/network.js: Fix test groupping 2022-02-13 13:24:06 +01:00
Val Lorentz 8edec1a5a8 Make sure the leading '<' is select when copypasting a message
Firefox does not seem to select leading (or trailing) characters that are
too small; so this commit sets a very small width, that is still large
enough to be selected.

This commit also adds `display: inline-block`, so the width is not
ignored; but this causes Chrome to ignore the space after `>`, so I made
it a non-breakable space.

An alternative is to make only the leading `only-copy` an
`inline-block`, but I think the non-breakable space is a good idea
regardless.
2022-02-12 13:59:31 +01:00
xnaas 9dfb2a3fdb
Upload m4a as audio/mp4; embed audio/mp4, x-flac, and x-m4a (#4470)
* 'audio/x-m4a' should be 'audio/mp4'
* add handling for x-flac and x-m4a
2022-02-11 17:42:59 -08:00
Max Leiter 4be9a282fa
Add the option to mute channels, queries, and networks (#4282)
Co-authored-by: Reto <reto@labrat.space>
2022-02-10 17:56:17 -08:00
renovate[bot] 337bfa489b
Update dependency cssnano to v5.0.17 (#4441)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-02-10 16:27:12 -08:00
Val Lorentz 1e3a7b1250 Emit a message for SASL loggedin/loggedout events
Closes GH-3921
2022-02-10 22:30:04 +01:00
renovate[bot] 3fb18717a7
Update dependency postcss to v8.4.6 (#4456) 2022-02-09 18:12:08 -08:00
renovate[bot] 76cbec9ac6
Update dependency @babel/core to v7.17.2 (#4439) 2022-02-09 18:11:05 -08:00
renovate[bot] 734f5b18d3
Lock file maintenance (#4465)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-02-09 15:40:08 -08:00
Reto d228a8c4f4
Bump most deps (#4453) 2022-02-09 15:27:34 -08:00
Val Lorentz f07d6b1ea4
README: suggest running 'yarn format:prettier' when linting fails (#4467)
* README: suggest running 'yarn format:prettier' when linting fails

Co-authored-by: Max Leiter <maxwell.leiter@gmail.com>
2022-02-08 17:52:07 -08:00
Max Leiter d0fab98c1d
Merge pull request #4462 from thelounge/bookworm/certErr
clientCert: fix up error message
2022-02-04 16:56:33 -08:00
Max Leiter d4d139505f
Merge pull request #4463 from thelounge/bookworm/canIuse
update caniuse-lite
2022-02-04 16:55:54 -08:00
Reto Brunner 9528515647 update caniuse-lite 2022-02-04 23:21:26 +01:00
Reto Brunner c0b81902f5 clientCert: fix up error message 2022-02-04 23:06:53 +01:00
Reto a86fa168b8
Merge pull request #4450 from itsjohncs/renovate-lock-file
Have Renovate bot refresh our lockfile for us.
2022-02-03 00:16:34 +01:00
itsjohncs 3e387156f7 Have Renovate bot refresh our lockfile for us.
I noticed that caniuse-lite wants to be updated regularly via
`npx browserslist@latest --update-db`. Renovate bot can do this if we
enable its `lockFileMaintenance` option
([source](https://github.com/renovatebot/renovate/issues/8615)).

I'm not sure exactly how annoying Renovate bot will be if we enable
this option but I figure we can just try it and disable it if it's
annoying.
2022-02-02 14:37:24 -08:00
itsjohncs 7e0afc90fd Replace deprecated Renovate config options.
This was an automatically requested migration that the
`renovate-config-validator` asked for. It's hard to tell because the
config options it asked to be removed are no longer in their docs, but
I believe this will not change the behavior of the renovate bot at all.
2022-02-02 14:36:02 -08:00
Max Leiter dcce9eba25
Merge pull request #4459 from fnutt/patch-2
Adding 'to' in a sentence in config.js
2022-02-01 14:39:34 -08:00
fnutt b1aa8528a4
Added 'to* in a sentence 2022-02-01 11:31:34 +01:00
Max Leiter 4489d5c8b8
Merge pull request #4449 from thelounge/bookworm/node_eol
Remove node 15.x from build matrix
2022-01-27 13:17:25 -08:00
Reto Brunner 1f8881a1d7 Remove node 15.x from build matrix
EOL was June 2021, this time has passed long ago.
2022-01-27 00:47:43 +01:00
Reto Brunner c7e504eeab sqlite3: update to latest commit
It doesn't look like upstream wants to release a new version.
However, it forces us to use python2 and a insecure tar version.
So staying on the release is not really an option.
Mitigate it by switching to the latest commit in the repo.
2022-01-24 23:59:56 +01:00
Reto Brunner 4db2d28216 Merge branch 'bookworm/mentions' 2022-01-03 09:28:29 +01:00
Taavi Väänänen be498e8f93
Count number of mode changes, not MODE messages
Update the code in MessageCondensed that generates the condensed
messages ("X users have joined, Y modes were set") to count the number
of actual mode changes instead of the raw count of MODE messages. One
mode message can contain multiple mode changes.

Signed-off-by: Taavi Väänänen <hi@taavi.wtf>
2021-12-31 23:32:17 +02:00
Reto Brunner e999171f29 Mentions window: filter list when we part a chan
Should some other client part a chan, then we need to clean
up the list from the mentions window in case it's open in ours.
2021-12-29 16:46:16 +01:00
Reto Brunner 0d209fce09 Clear obsolete mentions upon channel part
Currently, the mentions only track the chanID and MsgID.
However, when we part a channel the chanID becomes orphaned.

Considering that mentions from a parted channel probably aren't
that relevant, let's automatically clear them when we part.
Should the user really want to look at them again, they can re-join
the channel and get the scroll back that way.
2021-12-29 16:46:16 +01:00
Max Leiter acf520bd9a
Merge pull request #4435 from thelounge/bookworm/uploadErr
upload: improve error message
2021-12-28 02:40:29 -07:00
Reto Brunner 26c2562124 upload: improve error message 2021-12-27 02:11:56 +01:00
itsjohncs 763047889d Remove uses of window.event.
window.event is a deprecated global that's set to the currently
dispatched event.

- Opened and closed mentions box by clicking its icon in the top bar
- Left and right clicked on an inline channel name and saw context menu
  open both times
- Two-finger swiped on iOS and saw channel change
- Long-touched and dragged channel in network list on iOS and reordered
  the list successfully
2021-12-20 15:34:28 -08:00
Max Leiter e0bbf19d9d
Merge pull request #4430 from thelounge/maxleiter/configAdjustment
Remove extra 'be' in default config.js LDAP comment
2021-12-06 14:40:04 -08:00
Max Leiter 0fce974f2c
Remove extra 'be' in default config.js LDAP comment 2021-12-06 14:30:25 -08:00
John Sullivan cd7916b6d9
Merge pull request #4427 from thelounge/bookworm/mode
handle RPL_UMODEIS
2021-12-06 13:38:29 -08:00
Max Leiter 2c79d53c6d
Merge pull request #4428 from maxpoulin64/switch-busboy-implementation
Switch busboy implementation to `@fastify/busboy`
2021-12-06 12:38:58 -08:00
Reto Brunner 514c6fbf95 Rewrite conditional as switch
This is actually what the code tries to do, the conditional just
makes it harder to read
2021-12-06 07:24:21 +01:00
Reto Brunner 1953e03253 Add RPL_UMODEIS msg handler 2021-12-06 07:24:21 +01:00
Max Leiter 981de663fb
Merge pull request #4426 from brunnre8/worktrees
getGitCommit: allow git worktrees
2021-12-05 14:23:12 -08:00
Maxime Poulin 2c2dd1c76f Switch busboy implementation to @fastify/busboy
I've been notified the current implementation is abandonned and has been forked by fastify to fix bugs, including some crashes and hangs:
See:
* https://github.com/mscdex/busboy/issues/250
* https://github.com/mscdex/dicer/pull/22
* https://github.com/mscdex/dicer/pull/25
2021-12-04 19:49:21 -05:00
Max Leiter ecc0b9183e
Merge pull request #4423 from brunnre8/exit
Use non 0 exit code in abnormal shutdown
2021-12-04 14:57:48 -08:00
Max Leiter 4065d5de97
Merge pull request #4425 from brunnre8/deps
update irc-framework to 4.12.1
2021-12-03 12:02:11 -08:00
Reto Brunner 1c08b6dce6 getGitCommit: allow git worktrees
Change the short circuit logic to only test for a .git path.
With worktrees that's just a file, not a directory and we really
shouldn't play git anyhow and not rely on implementation details.
2021-12-03 18:16:30 +01:00
Reto Brunner 0c50c2d274 update irc-framework to 4.12.1
Remove ping timer on socket close
2021-12-03 18:00:56 +01:00
Reto Brunner 96c2d2419b Use non 0 exit code in abnormal shutdown 2021-12-02 07:55:06 +01:00
Max Leiter 304d207820
Merge pull request #4361 from supertassu/kickban 2021-12-01 18:25:09 -08:00
Max Leiter 35d8f4e212
Merge pull request #4373 from brunnre8/permissions 2021-12-01 14:18:10 -08:00
Reto 3c70fab7c6
Fix vue/this-in-template linter warning (#4418) 2021-11-30 12:01:45 -08:00
Reto Brunner 0ff9703a28 logs: Set umode to a more restrictive value
When TL first creates the log folder, let only the user read the
log files, should the admin override that subsequently we'll
simply warn about it but respect the decision.

Meaning we have private by default, but this can be overriden
2021-11-30 19:13:33 +01:00
Reto Brunner dc3a387120 vapid: keep the file secret
Contains a secret key, so we probably should keep it, well, secret.
Warn if the file is world readable.
2021-11-30 19:08:29 +01:00
renovate[bot] 212212fe70
Update dependency cssnano to v5 (#4408)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-30 02:42:46 -08:00
renovate[bot] 9b9b357001
Update dependency postcss-loader to v6 (#4415)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-30 02:42:22 -08:00
renovate[bot] 684d7f2db4
Update dependency mini-css-extract-plugin to v2 (#4412)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-30 02:42:07 -08:00
renovate[bot] 8040945913
Update dependency css-loader to v6 (#4407)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-30 02:19:32 -08:00
renovate[bot] 168f2ba46b
Update dependency mocha to v9 (#4413)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-30 02:18:38 -08:00
renovate[bot] d9f2fed398
Update dependency package-json to v7 (#4414)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-30 02:18:22 -08:00
renovate[bot] 84d779a4d0
Update dependency postcss-preset-env to v7 (#4416)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-30 02:17:57 -08:00
Taavi Väänänen 324fb9023e
Add /kickban
This commit adds a new command, /kickban, that is a combination of /kick
and /ban: it kicks the specific user from the channel and then sets the
+b mode to ban the user from the channel.
2021-11-30 12:07:11 +02:00
renovate[bot] cda3bb4e7c
Update dependency postcss to v8.4.4 (#4399)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-29 22:27:39 -08:00
renovate[bot] 117792fb4d
Update dependency webpack to v5.64.4 (#4398)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-29 22:27:26 -08:00
renovate[bot] c69588dd10
Update vue monorepo (#4403)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-29 22:16:21 -08:00
renovate[bot] 361af7f514
Update dependency postcss-loader to v5.3.0 (#4400)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-29 22:16:09 -08:00
renovate[bot] 5b76ec45ee
Update dependency webpack-cli to v4.9.1 (#4402)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-29 22:15:33 -08:00
renovate[bot] d596c0cee5
Update dependency babel-plugin-istanbul to v6.1.1 (#4396)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-29 19:54:10 -08:00
renovate[bot] 1bb5b74236
Update dependency mini-css-extract-plugin to v1.6.2 (#4393)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
Co-authored-by: Max Leiter <maxwell.leiter@gmail.com>
2021-11-29 19:54:00 -08:00
renovate[bot] 62fd807f78
Update babel monorepo (#4395)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-29 19:31:56 -08:00
renovate[bot] 5d6746c9c4
Update dependency mocha to v8.4.0 (#4394)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-29 19:31:14 -08:00
renovate[bot] 60f4b3a434
Update dependency tlds to v1.226.0 (#4397)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-29 18:21:18 -08:00
renovate[bot] a05dd6c612
Update dependency irc-framework to v4.12.0 (#4392)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-29 18:20:44 -08:00
renovate[bot] 244daea66c
Update dependency filenamify to v4.3.0 (#4391)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-29 18:20:25 -08:00
renovate[bot] 5f78574ecd
Update dependency eslint-plugin-vue to v7.20.0 (#4383)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-29 18:07:09 -08:00
renovate[bot] e39e9d2f8a
Update dependency webpack-dev-middleware to v5 (#4390)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-29 18:06:55 -08:00
renovate[bot] 98e38c8947
Update dependency ua-parser-js to v1 (#4389)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-29 18:06:46 -08:00
renovate[bot] 40fb2190fa
Update dependency sinon to v12 (#4386)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-29 17:31:31 -08:00
renovate[bot] 1160517c2c
Update dependency css-loader to v5.2.7 (#4381)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-29 17:31:26 -08:00
renovate[bot] f719027566
Update dependency file-type to v16.5.3 (#4384)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-29 17:31:21 -08:00
renovate[bot] cffb838284
Update dependency eslint to v7.32.0 (#4382)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-29 16:17:27 -08:00
renovate[bot] b02001c079
Update dependency pretty-quick to v3.1.2 (#4379)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-29 16:17:03 -08:00
renovate[bot] 79e56d1c4b
Update dependency yarn to v1.22.17 (#4380)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-29 16:16:54 -08:00
renovate[bot] b54cdf7880
Update dependency mime-types to v2.1.34 (#4378)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-29 15:49:01 -08:00
renovate[bot] cb404cd986
Update dependency got to v11.8.3 (#4377)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-11-29 15:47:55 -08:00
sfan5 1d5291929c
Add context menu when clicking inline channel name (#4376) 2021-11-29 15:35:26 -08:00
Max Leiter 172cd63739
Remove downloads badge and add thelounge/thelounge-docker link to README (#4371) 2021-11-22 22:51:07 -08:00
Max Leiter 368f3f910b
Add changelog entry for v4.3.0
oops
2021-11-22 17:54:54 -08:00
sfan5 5f7ec9e8da
Don't download image contents during prefetch if not needed (#4363) 2021-11-22 16:59:33 -08:00
Max Leiter 4419029d2e
v4.3.0 2021-11-22 16:54:39 -08:00
Max Leiter af96f7771c
Revert "Preserve location on first and last line when scrolling through inputs" (#4367)
This reverts commit c5f6b4617f.
2021-11-20 18:43:51 -08:00
Max Leiter 315198ac0b
Switch to thelounge/Sortable fork for Sortable.js (#4368) 2021-11-20 18:28:44 -08:00
Max Leiter f4096234d4
v4.3.0-rc.2 2021-11-18 17:57:30 -08:00
Max Leiter bfdbbce77d
Add changelog entry for v4.3.0-rc.2 2021-11-18 17:57:21 -08:00
Reto 9dbf647f7e
Make esc key close mentions window (#4365)
* Mentions: rename method to what it's doing
* Mentions: make <esc> dismiss the window
2021-11-18 17:54:44 -08:00
Max Leiter 6dfd51bb57
Merge pull request #4364 from thelounge/maxleiter/escCloseSearch
Allow escape key to close search bar and search page
2021-11-18 16:47:21 -08:00
Max Leiter 371ebfb810
Close search results with escape 2021-11-18 13:32:21 -08:00
Max Leiter c439e51617
Clear search input on close 2021-11-18 13:31:01 -08:00
Max Leiter 58110189fe
Allow esc key to close search input 2021-11-18 13:27:52 -08:00
404 changed files with 23391 additions and 16233 deletions

View file

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

193
.eslintrc.cjs Normal file
View file

@ -0,0 +1,193 @@
// @ts-check
const {defineConfig} = require("eslint-define-config");
const projects = defineConfig({
parserOptions: {
project: [
"./tsconfig.json",
"./client/tsconfig.json",
"./server/tsconfig.json",
"./shared/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"],
"@typescript-eslint/no-redundant-type-constituents": "off",
},
}).rules;
const tsRulesTemp = defineConfig({
rules: {
// TODO: eventually remove these
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-this-alias": "off",
"@typescript-eslint/no-unnecessary-type-assertion": "off",
"@typescript-eslint/no-unsafe-argument": "off",
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-call": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/no-unused-vars": "off",
},
}).rules;
const tsTestRulesTemp = defineConfig({
rules: {
// TODO: remove these
"@typescript-eslint/no-unsafe-return": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/restrict-plus-operands": "off",
},
}).rules;
module.exports = defineConfig({
root: true,
parserOptions: {
ecmaVersion: 2022,
},
overrides: [
{
files: ["**/*.ts", "**/*.vue"],
parser: "@typescript-eslint/parser",
parserOptions: {
tsconfigRootDir: __dirname,
project: projects,
extraFileExtensions: [".vue"],
},
plugins: ["@typescript-eslint"],
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking",
"prettier",
],
rules: {
...baseRules,
...tsRules,
...tsRulesTemp,
},
},
{
files: ["**/*.vue"],
parser: "vue-eslint-parser",
parserOptions: {
ecmaVersion: 2022,
ecmaFeatures: {
jsx: true,
},
parser: "@typescript-eslint/parser",
tsconfigRootDir: __dirname,
project: projects,
},
plugins: ["vue"],
extends: [
"eslint:recommended",
"plugin:vue/vue3-recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking",
"prettier",
],
rules: {...baseRules, ...tsRules, ...tsRulesTemp, ...vueRules},
},
{
files: ["./tests/**/*.ts"],
parser: "@typescript-eslint/parser",
rules: {
...baseRules,
...tsRules,
...tsRulesTemp,
...tsTestRulesTemp,
},
},
],
env: {
es6: true,
browser: true,
mocha: true,
node: true,
},
extends: ["eslint:recommended", "prettier"],
rules: baseRules,
});

View file

@ -1,76 +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-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]
plugins:
- vue
extends:
- eslint:recommended
- plugin:vue/recommended
- prettier
- prettier/vue

View file

@ -1,5 +1,8 @@
name: Build
permissions:
contents: read
on: [push, pull_request]
jobs:
@ -9,25 +12,19 @@ jobs:
strategy:
matrix:
include:
# EOL: April 2022
- os: ubuntu-latest
node_version: 12.x
# EOL: April 2023
- os: ubuntu-latest
node_version: 14.x
# EOL: June 2021
- os: ubuntu-latest
node_version: 15.x
# EOL: April 2024
- os: ubuntu-latest
node_version: 16.x
# EOL: April 2025
- os: macOS-latest
node_version: 16.x
node_version: 18.x
- os: windows-latest
node_version: 16.x
node_version: 18.x
- os: ubuntu-latest
node_version: 18.x
# EOL: April 2026
- os: ubuntu-latest
node_version: 20.x
# EOL: April June 2024
- os: ubuntu-latest
node_version: 21.x
runs-on: ${{ matrix.os }}
@ -35,7 +32,7 @@ jobs:
- uses: actions/checkout@master
- name: Setup Node.js
uses: actions/setup-node@v2
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node_version }}

View file

@ -1,5 +1,9 @@
name: Release
permissions:
contents: read
id-token: write
on:
push:
tags: v*
@ -14,8 +18,9 @@ jobs:
- uses: actions/checkout@master
- name: Setup Node.js
uses: actions/setup-node@v2
uses: actions/setup-node@v4
with:
node-version: "latest"
registry-url: "https://registry.npmjs.org/"
- name: Install
@ -31,13 +36,13 @@ jobs:
- name: Publish latest
if: "!contains(github.ref, '-')"
run: npm publish --tag latest
run: npm publish --tag latest --provenance
env:
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
- name: Publish next
if: contains(github.ref, '-')
run: npm publish --tag next
run: npm publish --tag next --provenance
env:
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}

1
.gitignore vendored
View file

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

View file

@ -1,24 +0,0 @@
# This file must not contain generated assets listed in .gitignore.
# npm-debug.log and node_modules/ are ignored by default.
# See https://docs.npmjs.com/misc/developers#keeping-files-out-of-your-package
# Ignore all dot files except for .thelounge_home
.*
!.thelounge_home
# 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/index.html.tpl
public/js/bundle.vendor.js.map
coverage/
scripts/
test/
appveyor.yml
webpack.config*.js
postcss.config.js
renovate.json

View file

@ -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

View file

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

View file

@ -1,15 +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

View file

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

View file

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

View file

@ -4,6 +4,671 @@ All notable changes to this project will be documented in this file.
<!-- New entries go after this line -->
## v4.4.3 - 2024-04-01
The Lounge finally gains the ability to automatically clean up sqlite databases.
Note that cleaning existing, large databases can take a significant amount of time
and running a database `VACUUM` will use up ~2x the current DB disc space for a short period.
If you enable the storagePolicy, stop the running instance and run `thelounge storage clean`.
This will force a full cleanup once, rather than doing so incrementally and will release all the
disc space back to the OS.
As usual, we follow the Node.js release schedule, so the minimum Node.js version required is now 18.
Many thanks to all the contributors to this release, be that documentation, code or maintaining the packages.
Your help is greatly appreciated!
For more details, [see the full changelog](https://github.com/thelounge/thelounge/compare/v4.4.1...v4.4.3)
### Added
- Sign in: use v-model ([`c5326e8`](https://github.com/thelounge/thelounge/commit/c5326e87958b1e99ca9405da5c8d17e3f45c983c) by [@brunnre8](https://github.com/brunnre8))
- Add comments explaining behavior when echo-message is not available ([`43a2b39`](https://github.com/thelounge/thelounge/commit/43a2b397a2efc65c7214893846831376bb880138) by [@brunnre8](https://github.com/brunnre8))
- Fix semver for prerelease versions #4744 ([`8aa5e33`](https://github.com/thelounge/thelounge/commit/8aa5e33b1d9e0a56e51481c227bf7d61fdd7b21f) by [@brunnre8](https://github.com/brunnre8))
- sqlite: add migrations support and introduce primary key ([`2ef8b37`](https://github.com/thelounge/thelounge/commit/2ef8b3700945deb9a113ddf4e3010ad36556deef) by [@brunnre8](https://github.com/brunnre8))
- test/link: use helper for url creation ([`c6b1913`](https://github.com/thelounge/thelounge/commit/c6b1913b919421ab2b70093218422a390d822c75) by [@brunnre8](https://github.com/brunnre8))
- test/storage: use helper for url creation ([`79fae26`](https://github.com/thelounge/thelounge/commit/79fae26f396081b6f557ae7b4f0c8fd4649b6a74) by [@brunnre8](https://github.com/brunnre8))
- Respect bind setting for all outgoing requests ([`3af4ad1`](https://github.com/thelounge/thelounge/commit/3af4ad1076330428da41f4205bb069d714b2a4e2) by [@brunnre8](https://github.com/brunnre8))
- bump emoji-regex to latest ([`ed0a47f`](https://github.com/thelounge/thelounge/commit/ed0a47fe2c10a2512832c9365a863967f9fc1ee0) by [@brunnre8](https://github.com/brunnre8))
- use shebang for generate-emoji script ([`1a1153a`](https://github.com/thelounge/thelounge/commit/1a1153aed638de0e5e2ca4089cb7656bbfa4394a) by [@brunnre8](https://github.com/brunnre8))
- Respect bind setting for all outgoing requests ([`2878f87`](https://github.com/thelounge/thelounge/commit/2878f87879cab30eabedbe2376507dae33295f22) by [@brunnre8](https://github.com/brunnre8))
- store: use return type over a type cast ([#4770](https://github.com/thelounge/thelounge/pull/4770) by [@brunnre8](https://github.com/brunnre8))
- don't crash on rDNS failure ([`8c54cd5`](https://github.com/thelounge/thelounge/commit/8c54cd50d8431481a70dec26a66a5343f2bbbd2c) by [@brunnre8](https://github.com/brunnre8))
- sqlite: fix typo fetch_rollbacks ([`884a92c`](https://github.com/thelounge/thelounge/commit/884a92c74bb669ff9a94c5a1c164912a9bd9891b) by [@brunnre8](https://github.com/brunnre8))
- sqlite: don't modify global array during tests ([`ec75ff0`](https://github.com/thelounge/thelounge/commit/ec75ff00cb8fdcef1857749ce6d033860e1ca157) by [@brunnre8](https://github.com/brunnre8))
- sqlite: return new version in downgrade() ([`d1561f8`](https://github.com/thelounge/thelounge/commit/d1561f8ebccacd0277d185626f3737bfd23bc99e) by [@brunnre8](https://github.com/brunnre8))
- cli: don't fail if stderr is not in json format ([`97f553e`](https://github.com/thelounge/thelounge/commit/97f553eea8ed4a57f6d760a767425159f6451e08) by [@brunnre8](https://github.com/brunnre8))
- sqlite: use variadic function for serialize_run ([`60ddf17`](https://github.com/thelounge/thelounge/commit/60ddf17124af8e451412b14a11910ded894979d8) by [@brunnre8](https://github.com/brunnre8))
- sqlite: accept db connection string ([`aec8d0b`](https://github.com/thelounge/thelounge/commit/aec8d0b03341691a0211d172538afc61560a919c) by [@brunnre8](https://github.com/brunnre8))
- sqlite: implement deleteMessages ([`14d9ff2`](https://github.com/thelounge/thelounge/commit/14d9ff247d51e77640bc0f37464804eadc822dd7) by [@brunnre8](https://github.com/brunnre8))
- introduce storage cleaner ([`74aff7e`](https://github.com/thelounge/thelounge/commit/74aff7ee5a9440a653859879390191031f81153e) by [@brunnre8](https://github.com/brunnre8))
- cleaner: expose cli task to do cleaning + vacuum ([`21b1152`](https://github.com/thelounge/thelounge/commit/21b1152f5357f47586456949cadfb9876a0613da) by [@brunnre8](https://github.com/brunnre8))
- wire up storage cleaner upon server start ([`b0ca8e5`](https://github.com/thelounge/thelounge/commit/b0ca8e51fb21b23859f95406f41dfe1ce273f419) by [@brunnre8](https://github.com/brunnre8))
- sqlite: add msg type index to speed up cleaner ([`edb1226`](https://github.com/thelounge/thelounge/commit/edb1226b474e9dc74d096201220d8e675821ac21) by [@brunnre8](https://github.com/brunnre8))
- add storage cleaner ([`7f0b721`](https://github.com/thelounge/thelounge/commit/7f0b7217906abf90343f5b91dc7ceaa650dd058f) by [@brunnre8](https://github.com/brunnre8))
- scripts: fix generate-config-doc, handle usage errors ([#4807](https://github.com/thelounge/thelounge/pull/4807) by [@flotwig](https://github.com/flotwig))
- router: don't use next() in router guards ([#4783](https://github.com/thelounge/thelounge/pull/4783) by [@brunnre8](https://github.com/brunnre8))
- linkify: Add web+ schema support ([`ae6bae6`](https://github.com/thelounge/thelounge/commit/ae6bae69ac2c915c3dcac4262168da46f8eddf39) by [@SoniEx2](https://github.com/SoniEx2))
- linkify: simplify noscheme detection logic ([`dd24cb1`](https://github.com/thelounge/thelounge/commit/dd24cb13002b76ba0a67abfa11faedaa455df828) by [@brunnre8](https://github.com/brunnre8))
- Add shortcut to navigate between channels with undread msgs ([`daabb76`](https://github.com/thelounge/thelounge/commit/daabb7678172fc6b6d7c6eebc6fad40b6f84ea39) by [@Nachtalb](https://github.com/Nachtalb))
- Remove husky, add githooks-install ([#4826](https://github.com/thelounge/thelounge/pull/4826) by [@brunnre8](https://github.com/brunnre8))
- Testing setup ([#4825](https://github.com/thelounge/thelounge/pull/4825) by [@brunnre8](https://github.com/brunnre8))
- Remove Node.js 16 from package.json and testing matrix ([`113e9bd`](https://github.com/thelounge/thelounge/commit/113e9bd2fb9a5154c048234d8ebbd8c0a61070d1) by [@MaxLeiter](https://github.com/MaxLeiter))
- server: remove version from CTCP response ([`45563d9`](https://github.com/thelounge/thelounge/commit/45563d9a5938ae4fa46da8a2d6c51fc829ebb910) by [@flotwig](https://github.com/flotwig))
### Documentation
On the [website repository](https://github.com/thelounge/thelounge.github.io):
- Merge branch 'localInstall' ([`8c0d5a5`](https://github.com/thelounge/thelounge.github.io/commit/8c0d5a58075fc1035f5c71675847823751e1f98d) by [@brunnre8](https://github.com/brunnre8))
- docs: update docker image to point to the new ghcr.io repository ([`5d7c993`](https://github.com/thelounge/thelounge.github.io/commit/5d7c993b9e26050b482550cb3f16aa11e0b99d9e) by [@brunnre8](https://github.com/brunnre8))
- Add "Hide all chat messages containing a link in a specific channel" … ([`993cf8b`](https://github.com/thelounge/thelounge.github.io/commit/993cf8b00e35ffeff1c20d122defc32d09e236b3) by [@zDEFz](https://github.com/zDEFz))
- ctcp: remove stale link to code (#273) ([`379c34d`](https://github.com/thelounge/thelounge.github.io/commit/379c34d88aa73dd86078af7757a4536bb9958e02) by [@brunnre8](https://github.com/brunnre8))
- docs: sync config.js.md (add prefetchTimeout, update ldap) (#275) ([`51dfc80`](https://github.com/thelounge/thelounge.github.io/commit/51dfc803415946e985c36317ea362ba625c67a3c) by [@flotwig](https://github.com/flotwig))
- Removing #thelounge-scandinavia due to inactivity (#278) ([`403cc6a`](https://github.com/thelounge/thelounge.github.io/commit/403cc6aa05cd30a0f9a86b81369ec0c9f1ffd24f) by [@fnutt](https://github.com/fnutt))
- Nodejs documentation link update (#277) ([`06e4725`](https://github.com/thelounge/thelounge.github.io/commit/06e47254cc6b98eabe4d527b1ce6be6f7ea7b9eb) by [@xfisbest](https://github.com/xfisbest))
- Add installation instructions for Gentoo (#276) ([`52be432`](https://github.com/thelounge/thelounge.github.io/commit/52be432b36cabc7a9d393a07e7702e3aebff8075) by [@rahilarious](https://github.com/rahilarious))
### Dependency updates
- chore(deps): update dependency webpack-hot-middleware to v2.25.4 ([`06f1387`](https://github.com/thelounge/thelounge/commit/06f1387f7b5ff374b52bc4aeac06d6e936bc00f4) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency @vue/test-utils to v2.4.0 ([`303f53f`](https://github.com/thelounge/thelounge/commit/303f53fe72a6cde53410821b2d59c81db90d308a) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency postcss to v8.4.26 ([`54ff563`](https://github.com/thelounge/thelounge/commit/54ff56324714bd5c6221250d02491f20b7ede6df) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency @types/linkify-it to v3.0.3 ([`2985727`](https://github.com/thelounge/thelounge/commit/2985727996c1e84fefce06e5c2a0da02a8b6ccb6) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency @types/bcryptjs to v2.4.4 ([`48301b1`](https://github.com/thelounge/thelounge/commit/48301b1ca31f0eb145695f320c81d0047e6883e6) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- build(deps): bump word-wrap from 1.2.3 to 1.2.5 ([`08413c7`](https://github.com/thelounge/thelounge/commit/08413c7b6b78f460bdee31239a87e6f86e14dda2) by [@dependabot[bot]](https://github.com/dependabot%5Bbot%5D))
- chore(deps): update dependency postcss to v8.4.31 [security] ([`ff77a33`](https://github.com/thelounge/thelounge/commit/ff77a3366305c23180e6e509f5f39d285edca8d1) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency @types/cheerio to v0.22.33 ([`b686059`](https://github.com/thelounge/thelounge/commit/b686059c6bf2f2014497d7dceb093422c5fb8fc2) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency @types/content-disposition to v0.5.7 ([`bcca111`](https://github.com/thelounge/thelounge/commit/bcca111a4dd42e8b648acee1da9548a0c677d056) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency @types/lodash to v4.14.200 ([`d4d5a8e`](https://github.com/thelounge/thelounge/commit/d4d5a8e386df60c69826fb9b1c63c138a1503640) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency @types/mousetrap to v1.6.13 ([`250433c`](https://github.com/thelounge/thelounge/commit/250433c87549b59f34cd4d3933364a3766cf587e) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update actions/setup-node action to v4 ([`785ec0a`](https://github.com/thelounge/thelounge/commit/785ec0a0e26f2233ddea6f51ef16cd5cc5e14e40) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency @types/bcryptjs to v2.4.5 ([`b506966`](https://github.com/thelounge/thelounge/commit/b506966b08fba11ab9b8b88268c9371dac78c314) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency @types/is-utf8 to v0.2.2 ([`59de6af`](https://github.com/thelounge/thelounge/commit/59de6afd3fdbeb894e8cf39321c786220bbcf66b) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency @types/bcryptjs to v2.4.6 ([`2f40d9d`](https://github.com/thelounge/thelounge/commit/2f40d9dbcca6fff43f1a66a2e0efb826e22cd4b4) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency @types/cheerio to v0.22.35 ([`73a529a`](https://github.com/thelounge/thelounge/commit/73a529acea765705c1903762106d8f8f3221e6fc) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency @types/content-disposition to v0.5.8 ([`aa95032`](https://github.com/thelounge/thelounge/commit/aa95032760761cc7e28d802ed9bec93d4a807335) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency @types/is-utf8 to v0.2.3 ([`eaa70ca`](https://github.com/thelounge/thelounge/commit/eaa70caad7e578af4bf5f1603c5008b9159a04e6) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency @types/linkify-it to v3.0.5 ([`1d2fdd9`](https://github.com/thelounge/thelounge/commit/1d2fdd95b0ee698bbdc85eb70fd02f47d46e86da) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency @types/lodash to v4.14.202 ([`fe50a90`](https://github.com/thelounge/thelounge/commit/fe50a9023509412b8c6d981053b469e27b5a49c0) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency @types/mousetrap to v1.6.15 ([`a77fbb8`](https://github.com/thelounge/thelounge/commit/a77fbb894ff550cabf7d6f54e06296babdeb2b67) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency @types/node to v17.0.45 ([`e2fda1f`](https://github.com/thelounge/thelounge/commit/e2fda1fb84da9cdbb445d6ebfe0f9795cb83633d) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- build(deps): bump semver from 7.3.5 to 7.5.2 ([`447a237`](https://github.com/thelounge/thelounge/commit/447a237fc6d54e59e563e982a406e16011c57b7a) by [@dependabot[bot]](https://github.com/dependabot%5Bbot%5D))
- build(deps): bump get-func-name from 2.0.0 to 2.0.2 ([`d308e74`](https://github.com/thelounge/thelounge/commit/d308e7418367e880f1b5454ade8267f5996bd035) by [@dependabot[bot]](https://github.com/dependabot%5Bbot%5D))
- build(deps): bump @babel/traverse from 7.18.9 to 7.23.6 ([`20227b1`](https://github.com/thelounge/thelounge/commit/20227b174c4bf375af1168c60ef57e6124c199f4) by [@dependabot[bot]](https://github.com/dependabot%5Bbot%5D))
- update emoji ([`607b9fc`](https://github.com/thelounge/thelounge/commit/607b9fc96a9ca933154dcc082fb2bb6dd545a2db) by [@brunnre8](https://github.com/brunnre8))
- update dependency cheerio to v1.0.0-rc.12 ([`3e21bfc`](https://github.com/thelounge/thelounge/commit/3e21bfcbea579c08f0c02d692e59242653b553b3) by [@brunnre8](https://github.com/brunnre8))
- update dependency webpack-hot-middleware to v2.25.4 ([`57c4d55`](https://github.com/thelounge/thelounge/commit/57c4d5513cfe6f0770a89330932dc07623c35e26) by [@brunnre8](https://github.com/brunnre8))
- update dependency @vue/test-utils to v2.4.0 ([`4f9ca3e`](https://github.com/thelounge/thelounge/commit/4f9ca3e1923837f2886a58df4605255229b200b2) by [@brunnre8](https://github.com/brunnre8))
- update dependency @types/lodash to v4.14.195 ([`2e019a2`](https://github.com/thelounge/thelounge/commit/2e019a2fdba684ad4cef15f55e514ae7a1bc8edf) by [@brunnre8](https://github.com/brunnre8))
- update dependency @types/chai to v4.3.5 ([`816b768`](https://github.com/thelounge/thelounge/commit/816b7686e36aaac36371a5bfbcd2648443bc4e48) by [@brunnre8](https://github.com/brunnre8))
- update dependency postcss to v8.4.26 ([`430a865`](https://github.com/thelounge/thelounge/commit/430a865e9fd7218ac8b0deaa6fc0841341b823ab) by [@brunnre8](https://github.com/brunnre8))
- update @types/mousetrap ([`139ce47`](https://github.com/thelounge/thelounge/commit/139ce47b73a4907da0e2737dbb245bc686330ec1) by [@brunnre8](https://github.com/brunnre8))
- bump caniuse-lite ([`22ae594`](https://github.com/thelounge/thelounge/commit/22ae594cc3d6905c82aa2238f4cd68506acf79a3) by [@brunnre8](https://github.com/brunnre8))
## v4.4.2-rc.1 - 2024-02-19 [Pre-release]
The Lounge finally gains the ability to automatically clean up sqlite databases.
Note that cleaning existing, large databases can take a significant amount of time
and running a database `VACUUM` will use up ~2x the current DB disc space for a short period.
If you enable the storagePolicy, stop the running instance and run `thelounge storage clean`.
This will force a full cleanup once, rather than doing so incrementally and will release all the
disc space back to the OS.
As usual, we follow the Node.js release schedule, so the minimum Node.js version required is now 18.
[See the full changelog](https://github.com/thelounge/thelounge/compare/v4.4.1...v4.4.2-rc.1)
This is a release candidate (RC) for v4.4.2 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.4.1 - 2023-06-13
Small bug fix release that addresses the bugs reported since v4.4.0
- fixes the image preview buttons disappearing.
- Restores the ability to change the password via the user interface.
Following the [Node.js maintenance schedule](https://nodejs.dev/en/about/releases/), The Lounge now needs at least Node.js 16 to run.
For more details, [see the full changelog](https://github.com/thelounge/thelounge/compare/v4.4.0...v4.4.1) and [milestone](https://github.com/thelounge/thelounge/milestone/43?closed=1).
### Fixed
- linkPreview: Pass channel prop ([`9388960`](https://github.com/thelounge/thelounge/commit/93889604973eeefb3a875e3ad5c9de737638888c) by [@brunnre8](https://github.com/brunnre8))
- client: fix password change input ([`8f08cf3`](https://github.com/thelounge/thelounge/commit/8f08cf3d0bd5b839016000afca1c700c74193f39) by [@brunnre8](https://github.com/brunnre8))
### Documentation
On the [website repository](https://github.com/thelounge/thelounge.github.io):
- Document local installation of packages ([`c72092e`](https://github.com/thelounge/thelounge.github.io/commit/c72092e2f8feab66f912b2c63c5a0572b123ea29) by [@brunnre8](https://github.com/brunnre8))
- docs: update docker image to point to the new ghcr.io repository ([`b43d002`](https://github.com/thelounge/thelounge.github.io/commit/b43d002584757709fff19dfdcf558c9d378f3d61) by [@williamboman](https://github.com/williamboman))
- Fix deb link ([`485570d`](https://github.com/thelounge/thelounge.github.io/commit/485570d4c4027296c546c2773272e4b44b0db06a) by [@brunnre8](https://github.com/brunnre8))
- deb: directly link to latest ([`c9a8ad9`](https://github.com/thelounge/thelounge.github.io/commit/c9a8ad95bbfc62f9ef704581fc742b069ff605fe) by [@brunnre8](https://github.com/brunnre8))
### Internals
- Remove unused code ([`7bce779`](https://github.com/thelounge/thelounge/commit/7bce77925449e2bcfa2db5d66dc5f808e04058c7) by [@brunnre8](https://github.com/brunnre8))
- settings: make missing_field msg descriptive ([`7a9ddc0`](https://github.com/thelounge/thelounge/commit/7a9ddc01e1819da8d28860548a82736f35283ab0) by [@brunnre8](https://github.com/brunnre8))
### Dependency updates
- build(deps): bump socket.io-parser from 4.2.1 to 4.2.3 ([`af49ef2`](https://github.com/thelounge/thelounge/commit/af49ef21ea3fed54c0807a4d87f9c0f9f70017c3) by [@dependabot[bot]](https://github.com/dependabot%5Bbot%5D))
- bump socket.io-parser from 4.2.1 to 4.2.3 ([`4d60d9c`](https://github.com/thelounge/thelounge/commit/4d60d9c282490ad63a1ff61e57e9a6c7a5fb9684) by [@brunnre8](https://github.com/brunnre8))
## v4.4.1-rc.2 - 2023-05-27 [Pre-release]
Restore the ability to change the password via the user interface.
[See the full changelog](https://github.com/thelounge/thelounge/compare/v4.4.0-rc.1...v4.4.1-rc.2)
This is a release candidate (RC) for v4.4.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.
## v4.4.1-rc.1 - 2023-05-20 [Pre-release]
Small bug fix release that addresses the image preview buttons disappearing.
Following the [Node.js maintenance schedule](https://nodejs.dev/en/about/releases/), The Lounge now needs at least Node.js 16 to run.
[See the full changelog](https://github.com/thelounge/thelounge/compare/v4.4.0...v4.4.1-rc.1)
This is a release candidate (RC) for v4.4.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.4.0 - 2023-04-22
For more details, [see the full changelog](https://github.com/thelounge/thelounge/compare/v4.3.1...v4.4.0) and [milestone](https://github.com/thelounge/thelounge/milestone/42?closed=1).
This is mostly a developer focused release. Max, Eric and others rewrote the whole thing in TypeScript / Vue 3,
which should make it much easier to add features and find bugs in the future. So huge kudos from the rest of the team!
Additionally, there's the obvious grab bag of fixes, dependency updates and improvements.
Settings are now grouped and easier to navigate for new users.
Following the Node.js maintenance schedule, The Lounge now needs at least Node.js 14 to run.
A big thanks to everyone who contributed in any way to this release, your help is much appreciated.
Considering that a bunch of our dependencies had security issues assigned to them, all users are advised to update to the new version.
Packagers: Considering the switch to TypeScript, the server build now emits to the dist/ folder.
You might need to adapt your build scripts.
### Added
- Add prefetchTimeout ([`aa7db1e`](https://github.com/thelounge/thelounge/commit/aa7db1e7f787350f4102f98b85a2e8173173f92a) by [@brunnre8](https://github.com/brunnre8))
### Changed
- Refactor settings to their own tabs and routes ([#4489](https://github.com/thelounge/thelounge/pull/4489) by [@MaxLeiter](https://github.com/MaxLeiter))
- allow away and back to be collapsed ([#4669](https://github.com/thelounge/thelounge/pull/4669) by [@brunnre8](https://github.com/brunnre8))
- Kill TL when ident can't start up (#4512) ([`37d7de7`](https://github.com/thelounge/thelounge/commit/37d7de7671cf07f8a7fb3a8b3ea32122a738b646) by [@brunnre8](https://github.com/brunnre8))
- enable znc/playback even without message storage ([`c8115e2`](https://github.com/thelounge/thelounge/commit/c8115e22acf4a6e34a1546fd2fc273c76cbb7e86) by [@brunnre8](https://github.com/brunnre8))
- Remove node 12, add node 18. Bump minimum node version 14 (#4552) ([`9dbb6e5`](https://github.com/thelounge/thelounge/commit/9dbb6e5e1923dc1a2d3d69b0eac2778ff8cf5d3b) by [@xPaw](https://github.com/xPaw))
- linkPreviews: Enforce TLS validity ([`621fa92`](https://github.com/thelounge/thelounge/commit/621fa92036d59aa6558df828a1ff48136eed19ce) by [@brunnre8](https://github.com/brunnre8))
- Use nick as a realname fallback ([`30e9f45`](https://github.com/thelounge/thelounge/commit/30e9f45fac5b675ddadf5f904f0d0f05a7cdb5f9) by [@brunnre8](https://github.com/brunnre8))
- Plugins: include pre-releases in compatibility lookup (#4506) ([`e4840b4`](https://github.com/thelounge/thelounge/commit/e4840b4d75ff4dc79083955ebd9dfbdd7dd7ea8a) by [@brunnre8](https://github.com/brunnre8))
- install: expand ~ for local paths ([`e221e70`](https://github.com/thelounge/thelounge/commit/e221e708c1237eaa3088d97aebf8bf4869843dc6) by [@brunnre8](https://github.com/brunnre8))
### Fixed
- Fix the alignment of the header buttons ([#4539](https://github.com/thelounge/thelounge/pull/4539) by [@ronilaukkarinen](https://github.com/ronilaukkarinen))
- Fix user commands not working ([#4594](https://github.com/thelounge/thelounge/pull/4594) by [@xPaw](https://github.com/xPaw))
- Don't crash on oidentd socket race condition ([#4695](https://github.com/thelounge/thelounge/pull/4695) by [@maxpoulin64](https://github.com/maxpoulin64))
- cli: don't error if the user folder doesn't exist (#4508) ([`8153198`](https://github.com/thelounge/thelounge/commit/815319810c28ffe17119a5dc62f7eac33eba12f5) by [@brunnre8](https://github.com/brunnre8))
- Fix user file permissions on create (#4507) ([`d7bba32`](https://github.com/thelounge/thelounge/commit/d7bba325a73b1898edfa4299c4525749e174bbac) by [@brunnre8](https://github.com/brunnre8))
- sqlite: Escape '%' and '\_' in search queries. (#4487) ([`20ed3e6`](https://github.com/thelounge/thelounge/commit/20ed3e6dc5cf482e38d537444163e98b2bae0879) by [@progval](https://github.com/progval))
- set 'video/quicktime' to 'video/mp4' (#4495) ([`57b1e51`](https://github.com/thelounge/thelounge/commit/57b1e51e9f0f65e0866f5a809b12efaaf277536a) by [@xnaas](https://github.com/xnaas))
- Preserve client certificate ([`c9c8cad`](https://github.com/thelounge/thelounge/commit/c9c8cadb1a00f01d00920792cc129077aa6934fd) by [@brunnre8](https://github.com/brunnre8))
- Remove uploading event listeners on ChatInput unmount (#4600) ([`80f65c5`](https://github.com/thelounge/thelounge/commit/80f65c5b7276c466d2032fb3a7822fa39df3c685) by [@MaxLeiter](https://github.com/MaxLeiter))
- Potentially fix saving new networks (#4599) ([`d72d869`](https://github.com/thelounge/thelounge/commit/d72d8694bbea9fde7bf86275fb77b4c4c8a168ec) by [@MaxLeiter](https://github.com/MaxLeiter))
- Fix regex escape for prefix patterns ([`d6e1af0`](https://github.com/thelounge/thelounge/commit/d6e1af0e7dedb34dcd9932105ee4f2ddbe98e221) by [@brunnre8](https://github.com/brunnre8))
- Fix ctcp request message (#4603) ([`c8cd405`](https://github.com/thelounge/thelounge/commit/c8cd4057bc4ef19271720fc6b893b9c74e690457) by [@brunnre8](https://github.com/brunnre8))
- connect: Trim white space from user input fields (#4623) ([`0fa2035`](https://github.com/thelounge/thelounge/commit/0fa203569a62ee6bc6062b781729c7d801ccb8ba) by [@brunnre8](https://github.com/brunnre8))
- Search: Clear earlier searches when a new one is executed ([`83e11b0`](https://github.com/thelounge/thelounge/commit/83e11b0143e599a40924cab856636beeca6df27c) by [@brunnre8](https://github.com/brunnre8))
- Fix previous-source calculation (#4656) ([`073a38e`](https://github.com/thelounge/thelounge/commit/073a38ef1ef3c46740a028d4cbe7ebe4c7a08526) by [@brunnre8](https://github.com/brunnre8))
- Fix sidebar swipe flicker after letting go ([`502780c`](https://github.com/thelounge/thelounge/commit/502780c5a3e3455d977d8873506f1be51946fa68) by [@xPaw](https://github.com/xPaw))
- search: ignore searchResults if it isn't the active query ([`0ebc3a5`](https://github.com/thelounge/thelounge/commit/0ebc3a574c42185c818ca8795a56d8eb58a20f4e) by [@brunnre8](https://github.com/brunnre8))
- fix motd display to match settings ([#4726])(https://github.com/thelounge/thelounge/pull/4726) by [@SpaceLenore](https://github.com/SpaceLenore))
### Documentation
- Fix misleading LDAP filiter in default config ([`f785acb`](https://github.com/thelounge/thelounge/commit/f785acb07d78ae791a24a39821a93afb81616934) by [@goodspeed34](https://github.com/goodspeed34))
- Use correct option name (filter instead of ldapFilter) in config.js c… ([`4af5fc6`](https://github.com/thelounge/thelounge/commit/4af5fc6f33b43d64adcebcbf5aa8c4dceaad493f) by [@murph](https://github.com/murph))
- Add password param to /join docs ([`8b1a4f7`](https://github.com/thelounge/thelounge/commit/8b1a4f72fa79e12b43ff3073f0d48b13d93008e7) by [@aab12345](https://github.com/aab12345))
- install: Document file: prefix in cli help ([`31739b8`](https://github.com/thelounge/thelounge/commit/31739b8ac9ff95a03c374b32cc9bce2163d05d1e) by [@brunnre8](https://github.com/brunnre8))
On the [website repository](https://github.com/thelounge/thelounge.github.io):
- Link directly to themes on npm (#261) ([`410f5d0`](https://github.com/thelounge/thelounge.github.io/commit/410f5d077676cf597397b01acdc81414cc3dbc01) by [@jeremiah-rs](https://github.com/jeremiah-rs))
- Don't use yarn link for source installs ([#262](https://github.com/thelounge/thelounge.github.io/pull/262) by [@brunnre8](https://github.com/brunnre8))
- Add Insecure Warning CSS (#264) ([`95efa48`](https://github.com/thelounge/thelounge.github.io/commit/95efa482668af7997c7058cf01dff611efdea644) by [@aab12345](https://github.com/aab12345))
- Add custom nick colors section to custom css guide (#265) ([`63847c3`](https://github.com/thelounge/thelounge.github.io/commit/63847c346b6e49ddcdb34f5b733b57e3db8cc2df) by [@xnaas](https://github.com/xnaas))
- Fix Apache configuration syntax ([`41cb84e`](https://github.com/thelounge/thelounge.github.io/commit/41cb84ee70f5dc4a6920dfd1916fdf5eb00f190c) by [@lucaswerkmeister](https://github.com/lucaswerkmeister))
- Be more explicit about needing Yarn 1 (Classic) (#268) ([`1eff267`](https://github.com/thelounge/thelounge.github.io/commit/1eff26768a437e2bac1b62982da5ae02fdbda950) by [@SyntaxColoring](https://github.com/SyntaxColoring))
- Don't mention `npm` command for installation ([`7e936c2`](https://github.com/thelounge/thelounge.github.io/commit/7e936c2814b2902855570e928e0f13a40e17fce7) by [@SyntaxColoring](https://github.com/SyntaxColoring))
- Update reverse-proxies.md ([`afc7e29`](https://github.com/thelounge/thelounge.github.io/commit/afc7e2957211f0fa9a4f986fb4a0a03547384a6d) by [@PeGaSuS-Coder](https://github.com/PeGaSuS-Coder))
### Internals
- Decouple server ([#4686](https://github.com/thelounge/thelounge/pull/4686) by [@brunnre8](https://github.com/brunnre8))
- Tests/server: Tear down test fixtures in the order they were setup ([#4715](https://github.com/thelounge/thelounge/pull/4715) by [@progval](https://github.com/progval))
- Refactor config out of Helper (#4558) ([`d4cc2dd`](https://github.com/thelounge/thelounge/commit/d4cc2dd361bd2f166924dd18efdc57634d67bc19) by [@brunnre8](https://github.com/brunnre8))
- Convert configs to cjs, move babel to own file, combine webpack confi… ([`c205b89`](https://github.com/thelounge/thelounge/commit/c205b895233f5d7c58ef44bad31ccee777f3b95d) by [@nemchik](https://github.com/nemchik))
- Fix yarn dev (#4574) ([`2e3d9a6`](https://github.com/thelounge/thelounge/commit/2e3d9a6265d4c0d0168729a60b319bea236e098b) by [@nemchik](https://github.com/nemchik))
- TypeScript and Vue 3 (#4559) ([`dd05ee3`](https://github.com/thelounge/thelounge/commit/dd05ee3a656cb5eb5d0ab7620dbc7a1cfa4102ab) by [@MaxLeiter](https://github.com/MaxLeiter))
- Added client type checking to webpack (#4619) ([`117c5fa`](https://github.com/thelounge/thelounge/commit/117c5fa3fdbd2787bc1df521627b7b07fc1522c6) by [@antoniomika](https://github.com/antoniomika))
- don't call search on a disabled msg provider ([`bea4545`](https://github.com/thelounge/thelounge/commit/bea4545abffe738dfeb025b36817490c1b5fa61d) by [@brunnre8](https://github.com/brunnre8))
- extract migrations ([`f04a066`](https://github.com/thelounge/thelounge/commit/f04a06682d3690b571dc0b9720baa79b687b9465) by [@brunnre8](https://github.com/brunnre8))
- sqlite: error if sqlite isn't enabled but search() is called ([`cebc6d0`](https://github.com/thelounge/thelounge/commit/cebc6d069fa609de918881854414768fadc87fed) by [@brunnre8](https://github.com/brunnre8))
- sqlite: move export to bottom of the file ([`f6b2921`](https://github.com/thelounge/thelounge/commit/f6b292107ee4e627562d170babcb272cfa102a1e) by [@brunnre8](https://github.com/brunnre8))
- sqlite: fix docstring ([`e62b169`](https://github.com/thelounge/thelounge/commit/e62b169a6abab4b2a0df34a5da21c92136ba3790) by [@brunnre8](https://github.com/brunnre8))
- sqlite: add run helper function ([`89ee537`](https://github.com/thelounge/thelounge/commit/89ee5373643d1c5cb664401de745109bf7bcb77c) by [@brunnre8](https://github.com/brunnre8))
- sqlite: create serialize_fetchall helper function ([`cc3302e`](https://github.com/thelounge/thelounge/commit/cc3302e8743633b3b87e15fb54a964510b2466d1) by [@brunnre8](https://github.com/brunnre8))
- sqlite: use serialize_fetchall in getMessages ([`ee8223c`](https://github.com/thelounge/thelounge/commit/ee8223c2006ad31fc746824b495125b321da4bf8) by [@brunnre8](https://github.com/brunnre8))
- sqlite: use serialize_fetchall in search ([`5e1cbe3`](https://github.com/thelounge/thelounge/commit/5e1cbe32f95aca776fe4dff550a0c8c369460417) by [@brunnre8](https://github.com/brunnre8))
- sqlite: add serialize_get ([`bbe81bb`](https://github.com/thelounge/thelounge/commit/bbe81bb2fa9001762df90c1a267afa0239ebb7c7) by [@brunnre8](https://github.com/brunnre8))
- sqlite: convert migrations to async ([`f068fd4`](https://github.com/thelounge/thelounge/commit/f068fd429012c47648faf8c4d751f972062709bd) by [@brunnre8](https://github.com/brunnre8))
- messageStorage: convert to async ([`d62dd3e`](https://github.com/thelounge/thelounge/commit/d62dd3e62d106009cbded2fd9af13fe9fae35ae5) by [@brunnre8](https://github.com/brunnre8))
- SearchResults: remove computed search prop ([`6b617f8`](https://github.com/thelounge/thelounge/commit/6b617f893d73fb9e8304d228336cf574c29992a3) by [@brunnre8](https://github.com/brunnre8))
- SearchResults: Fix search progess upon search ([`dca2024`](https://github.com/thelounge/thelounge/commit/dca202427aa543d43d18fb72ae10ffa51b3b6c60) by [@brunnre8](https://github.com/brunnre8))
- SearchResults: remove dead code (#4639) ([`53f6041`](https://github.com/thelounge/thelounge/commit/53f6041f42ac36b5d69fc05cc66618ea0fe67a88) by [@brunnre8](https://github.com/brunnre8))
- SearchQuery: offset is always a number ([`8095d9e`](https://github.com/thelounge/thelounge/commit/8095d9e88a0018d2ac559ab01488d2736b4fe5e6) by [@brunnre8](https://github.com/brunnre8))
- Search: fix off by one offset error ([`51c9ce0`](https://github.com/thelounge/thelounge/commit/51c9ce078d15efafd677cff525b681dcec51fdd5) by [@brunnre8](https://github.com/brunnre8))
- keybinds: Fix invalid return ([`0765d20`](https://github.com/thelounge/thelounge/commit/0765d209f2ce204e2a3e86c56a7c2108a0487a6f) by [@brunnre8](https://github.com/brunnre8))
- server: the http{,s} server can't be null ([`1597c2c`](https://github.com/thelounge/thelounge/commit/1597c2c56ec932859ebc77e31eda8c164f196388) by [@brunnre8](https://github.com/brunnre8))
- make getClientConfiguration type safe ([`fd14b4a`](https://github.com/thelounge/thelounge/commit/fd14b4a17203bc043b8c9c1f371c2c5ced96eef7) by [@brunnre8](https://github.com/brunnre8))
- remove VueApp from router ([`dfb4217`](https://github.com/thelounge/thelounge/commit/dfb4217167bd20232bf2bdc443454a7ea9cc1094) by [@brunnre8](https://github.com/brunnre8))
- search: fix order of result merging ([`8204c34`](https://github.com/thelounge/thelounge/commit/8204c3481ad1e5eb3f59cabdb5c3c52936094b48) by [@brunnre8](https://github.com/brunnre8))
- store: addMessageSearchResults shouldn't accept null ([`982816f`](https://github.com/thelounge/thelounge/commit/982816ff2015077fe2903180df6420005c73b33e) by [@brunnre8](https://github.com/brunnre8))
- sqlite: synchronize enable() internally ([`2d4143b`](https://github.com/thelounge/thelounge/commit/2d4143b7798c9cf0600280a5a79cb9061585be0e) by [@brunnre8](https://github.com/brunnre8))
- messagestorage: remove implementation details from interface ([`661d5cb`](https://github.com/thelounge/thelounge/commit/661d5cb5b0d6c3aebb9a83ac4c5115d0411b3f39) by [@brunnre8](https://github.com/brunnre8))
- textStorage: rip out client instance ([`52b8a2a`](https://github.com/thelounge/thelounge/commit/52b8a2a78e62dfdcdd2313e8c7e81a7b07f383e2) by [@brunnre8](https://github.com/brunnre8))
- sqlite: Remove client from sqlitestorage ([`958a948`](https://github.com/thelounge/thelounge/commit/958a948456d1a0c3c97bb60e8759e8f9f5578ac8) by [@brunnre8](https://github.com/brunnre8))
- Fix uploader mount/unmount lifecycle ([`2ce374f`](https://github.com/thelounge/thelounge/commit/2ce374fe858992c5c930b0c49bf40cba2928f839) by [@maxpoulin64](https://github.com/maxpoulin64))
- Fix git commit not being available in dist build ([`2f04150`](https://github.com/thelounge/thelounge/commit/2f04150461fbd538b09e58d8c1beb33ee0db18ce) by [@xPaw](https://github.com/xPaw))
- network: add getLobby accessor ([`fade6a8`](https://github.com/thelounge/thelounge/commit/fade6a8d2ec5d621d761e2f6a716c5e59f4a9770) by [@brunnre8](https://github.com/brunnre8))
- pluginCommand: type it and guard against bad input ([`4023323`](https://github.com/thelounge/thelounge/commit/402332340b727d7f4087b1f24dcd4eecf16b0891) by [@brunnre8](https://github.com/brunnre8))
- packaging: Use an include list in package.json ([`efd24fd`](https://github.com/thelounge/thelounge/commit/efd24fd12cad9192d6f333c5a3c01c33ad23b0c6) by [@brunnre8](https://github.com/brunnre8))
- Fix incorrect typing of dehydrated networks and channels ([`76098d7`](https://github.com/thelounge/thelounge/commit/76098d7e766ad074eb6278ee487410f1f02817c3) [@progval](https://github.com/progval))
- Client: move socket connection out of the constructor ([`a049a01`](https://github.com/thelounge/thelounge/commit/a049a01aeb2b09edaaf46411bb764c14a607b343) [@progval](https://github.com/progval))
- Fix test wording ([`d58fb84`](https://github.com/thelounge/thelounge/commit/d58fb845651fe2859313c05a80cdcdebc27a8c68) [@progval](https://github.com/progval))
- Remove override of UserConfig ([`320075e`](https://github.com/thelounge/thelounge/commit/320075e376eecc0843f57b2f9b3207f8f245930e) [@progval](https://github.com/progval))
- Fix sqlite query invocation in test ([`845daba`](https://github.com/thelounge/thelounge/commit/845dabad53c4a47b6c39f7529ad02ec810c5ed48) by [@brunnre8](https://github.com/brunnre8))
- Fix config typing and make Client easier to test ([`eb509f7`](https://github.com/thelounge/thelounge/commit/eb509f7100869427d3f8b4dbd54692bf12630e67) by [@brunnre8](https://github.com/brunnre8))
- server/client: refactor command input ([`4e954b9`](https://github.com/thelounge/thelounge/commit/4e954b919c86ad17f6c7f934de4aa8d6fe5b9b1d) by [@brunnre8](https://github.com/brunnre8))
- Clean up command input code ([`e8b6434`](https://github.com/thelounge/thelounge/commit/e8b6434144998693532ce2853c049e878f158d63) by [@brunnre8](https://github.com/brunnre8))
- Inline logger into changelog script ([#4717](https://github.com/thelounge/thelounge/pull/4717) by [@brunnre8](https://github.com/brunnre8))
- Fix load of channels from user config ([`0c7cc85`](https://github.com/thelounge/thelounge/commit/0c7cc85184d9f90987000ffcddfa2b9581bb96cb) Val Lorentz)
- style: Put user colors into the smallest possible scope ([`f55f772`](https://github.com/thelounge/thelounge/commit/f55f772659a505ceb8751d8728c22c810afed018) by [@brunnre8](https://github.com/brunnre8))
- Fix Morning theme nick colors ([#4690](https://github.com/thelounge/thelounge/pull/4690) by [@maxpoulin64](https://github.com/maxpoulin64))
- Publish to npm with provenance ([#4724])(https://github.com/thelounge/thelounge/pull/4724) by [@xPaw](https://github.com/xPaw))
### Dependency updates
_Aka the boring bits... It's the last section too, so feel free to gloss over it_
- fix(deps): update dependency got to v11.8.5 [security] ([#4596](https://github.com/thelounge/thelounge/pull/4596) by [@renovate](https://github.com/apps/renovate))
- `sqlite3` ([#4541](https://github.com/thelounge/thelounge/pull/4541))
- chore(deps): update dependency sqlite3 to v5.0.6 ([`da02350`](https://github.com/thelounge/thelounge/commit/da02350725291be79c0d6c5d15261a2e0ef72313) by [@renovate-bot](https://github.com/renovate-bot))
- chore(deps): update dependency @textcomplete/core to v0.1.11 (#4555) ([`99c48db`](https://github.com/thelounge/thelounge/commit/99c48dbcea2ebe08d64a38946d81301fbfe66ee2) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update babel monorepo (#4554) ([`38f1352`](https://github.com/thelounge/thelounge/commit/38f13525e6104ee332c64d2df20bfe2694bc7fe5) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency mocha to v9.2.2 (#4581) ([`194b85b`](https://github.com/thelounge/thelounge/commit/194b85be4d93813f763b06264124d5545ba8aa27) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency sqlite3 to v5.0.8 (#4564) ([`ddcee53`](https://github.com/thelounge/thelounge/commit/ddcee5371acfe960c53e85e97405d005953dec3c) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency @textcomplete/textarea to v0.1.12 ([`e972165`](https://github.com/thelounge/thelounge/commit/e97216518adb9ac7d6ef458c362a591a0f56ed14) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency @types/content-disposition to v0.5.5 ([`740618c`](https://github.com/thelounge/thelounge/commit/740618ca499aeb2efb8ffd4f0363b5cf841a49dc) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency @textcomplete/core to v0.1.12 ([`0cb4791`](https://github.com/thelounge/thelounge/commit/0cb4791cd02c0fd2e578edc1366124117529ac10) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency sqlite3 to v5.0.10 ([`520646a`](https://github.com/thelounge/thelounge/commit/520646a212e08f971c870e6f464712a90e198d66) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- fix(deps): update dependency file-type to v16.5.4 [security] ([`0495761`](https://github.com/thelounge/thelounge/commit/0495761c4485ac86b43ced638a361b905e7ddc60) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): lock file maintenance ([`57ed37c`](https://github.com/thelounge/thelounge/commit/57ed37c1fda4024ae655de2defdf4af68ade69fe) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- Revert "chore(deps): update dependency @textcomplete/core to v0.1.12" ([`3240997`](https://github.com/thelounge/thelounge/commit/32409973478ecb88290447faa7f2639a6d5c4d1f) by [@brunnre8](https://github.com/brunnre8))
- chore(deps): update dependency sqlite3 to v5.1.2 ([`5a803cc`](https://github.com/thelounge/thelounge/commit/5a803ccd239e42fe8853b4c615e82ef2c64bbc14) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency @vue/test-utils to v2.2.1 ([`cb17f8d`](https://github.com/thelounge/thelounge/commit/cb17f8d87f9eac3b3449455d47c5ddaec09c0c5d) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency postcss to v8.4.18 ([`5a4a39b`](https://github.com/thelounge/thelounge/commit/5a4a39b9d1f4a49ddc2f9c5551f9fd28d0307a4b) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency chai to v4.3.7 ([`0ad033f`](https://github.com/thelounge/thelounge/commit/0ad033fe0aac01e0f4512428fda0e93ddefdcfb6) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency @types/is-utf8 to v0.2.1 ([`b5ea7cc`](https://github.com/thelounge/thelounge/commit/b5ea7cceb3ff6a13f0ee20f4ed1c017b983d7d8c) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency @types/lodash to v4.14.188 ([`dfe288e`](https://github.com/thelounge/thelounge/commit/dfe288ef166a0ac07f538ee5a07c2f7b65ee15f9) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency @types/chai to v4.3.4 ([`19307d0`](https://github.com/thelounge/thelounge/commit/19307d05e70f8b7ed9ab3d6177c7c9ae6c93a438) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency postcss to v8.4.19 ([`2218841`](https://github.com/thelounge/thelounge/commit/221884166df61feb43513205c982b271b299f074) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency @types/lodash to v4.14.191 ([`d61ab7e`](https://github.com/thelounge/thelounge/commit/d61ab7e7a084018d68444c4b0ef8d14702142d84) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency sqlite3 to v5.1.4 ([`c854d27`](https://github.com/thelounge/thelounge/commit/c854d27d3d8451ea25051dc356dc8f101542f9a1) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency @types/ws to v8.5.4 ([`502fb7a`](https://github.com/thelounge/thelounge/commit/502fb7a7050edbecd8e34b6c30664e0bdcfc4a6c) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency @vue/test-utils to v2.2.7 ([`6b23b87`](https://github.com/thelounge/thelounge/commit/6b23b87063c893ce588321929598e579401e16ee) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency sinon to v13.0.2 ([`90d17ca`](https://github.com/thelounge/thelounge/commit/90d17cacc155a3a6bafd76411b2e00997347a24b) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency vue-loader to v17.0.1 ([`2f8dc01`](https://github.com/thelounge/thelounge/commit/2f8dc01930f921f4de23dff29abfc703fdbefdbc) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency webpack-dev-middleware to v5.3.3 ([`4742a07`](https://github.com/thelounge/thelounge/commit/4742a077211229191867033320c0efc876a9404c) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency @vue/test-utils to v2.3.1 ([`50e8d2a`](https://github.com/thelounge/thelounge/commit/50e8d2a8903b1c1c826208850f46a5d98dbf6458) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency postcss to v8.4.21 ([`8e249d4`](https://github.com/thelounge/thelounge/commit/8e249d46afb234a4a1def2cbcc0204c4edd52bdc) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency sqlite3 to v5.1.5 [security] ([`bc4c308`](https://github.com/thelounge/thelounge/commit/bc4c3082b852e175e55003c8b91b2a69a7d8283f) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency webpack to v5.76.0 [security] ([`a67cee1`](https://github.com/thelounge/thelounge/commit/a67cee1ee43da01afd8c7584b44d46e6e8dc990d) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency sqlite3 to v5.1.6 ([`34a01c2`](https://github.com/thelounge/thelounge/commit/34a01c2dd164b60d7470b588f7c0e0ed3d3b7647) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- chore(deps): update dependency @types/mousetrap to v1.6.11 ([`5037383`](https://github.com/thelounge/thelounge/commit/5037383c4c9a87a53eaa358ffbe7492ab6ad6365) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- Autocomplete: update to @textcomplete package and close on blur (#4493) ([`bdd6e71`](https://github.com/thelounge/thelounge/commit/bdd6e71049a4ddc65eca8d6acc52ce5c7eb3f6fd) by [@MaxLeiter](https://github.com/MaxLeiter))
- Update sqlite3 to 5.0.3 ([`7db0d46`](https://github.com/thelounge/thelounge/commit/7db0d4619d98ad473eff7a1dbdf41c8b0167d0dd) by [@xPaw](https://github.com/xPaw))
- Merge sqlite3 upgrade to v5.0.6 ([`abf8906`](https://github.com/thelounge/thelounge/commit/abf89067575810339fa3c723af54a7ea670fe4e5) by [@brunnre8](https://github.com/brunnre8))
- bump socket.io to 4.5.2 ([`d4bbd91`](https://github.com/thelounge/thelounge/commit/d4bbd9191cd78f065386fe25c7e8e90b1171a159) by [@brunnre8](https://github.com/brunnre8))
- bump socket.io-client to 4.5.0 ([`4c7337b`](https://github.com/thelounge/thelounge/commit/4c7337b6257af2428e6e9f8af570126da094d266) by [@brunnre8](https://github.com/brunnre8))
- Bump engine.io from 6.2.0 to 6.2.1 ([`f8eb0eb`](https://github.com/thelounge/thelounge/commit/f8eb0ebafdf8824bfe316fd2ad8adb3b8beda2d2) by [@dependabot[bot]](https://github.com/dependabot%5Bbot%5D))
- Bump loader-utils from 2.0.2 to 2.0.4 ([`8924545`](https://github.com/thelounge/thelounge/commit/89245455ceceba157821437a3f8f4e80f3b03268) by [@dependabot[bot]](https://github.com/dependabot%5Bbot%5D))
- Bump loader-utils from 2.0.2 to 2.0.4 ([`21c8b0d`](https://github.com/thelounge/thelounge/commit/21c8b0d17fc7e09d1cad77990fa833fdcad62927) by [@brunnre8](https://github.com/brunnre8))
- update dependency @types/mousetrap to v1.6.11 ([`7ee4b80`](https://github.com/thelounge/thelounge/commit/7ee4b80a6e744b09385fc686cdca1fbf0e7784ac) by [@brunnre8](https://github.com/brunnre8))
- update dependency @types/lodash to v4.14.191 ([`c67df36`](https://github.com/thelounge/thelounge/commit/c67df36a29a04bacc9e3197a32368493ae0a2ae9) by [@brunnre8](https://github.com/brunnre8))
- caniuse-lite: update db ([`efd3b64`](https://github.com/thelounge/thelounge/commit/efd3b645642ff75639ecb27a8ff9d6f6e1c0ccab) by [@brunnre8](https://github.com/brunnre8))
- build(deps): bump json5 from 2.2.1 to 2.2.3 ([`ce3ad56`](https://github.com/thelounge/thelounge/commit/ce3ad56ced3b498def5bb65065b4185a46a20995) by [@dependabot[bot]](https://github.com/dependabot%5Bbot%5D))
- fix(deps): update dependency ua-parser-js to v1.0.33 [security] ([`bde5c3d`](https://github.com/thelounge/thelounge/commit/bde5c3d443dc1e965bdd2641abb94b526600ddec) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
- build(deps): bump http-cache-semantics from 4.1.0 to 4.1.1 ([`7304acd`](https://github.com/thelounge/thelounge/commit/7304acd8e072af33dfdd1ea2f108b91a6e449f65) by [@dependabot[bot]](https://github.com/dependabot%5Bbot%5D))
- update dependency postcss to v8.4.21 ([`95e5630`](https://github.com/thelounge/thelounge/commit/95e56300db48bbb75b3463267eb0809ee9739686) by [@brunnre8](https://github.com/brunnre8))
- update dependency sinon to v13.0.2 ([`0183d89`](https://github.com/thelounge/thelounge/commit/0183d89384405ad944863ecffd783c99f0c36517) by [@brunnre8](https://github.com/brunnre8))
- update dependency vue-loader to v17.0.1 ([`eddcbcc`](https://github.com/thelounge/thelounge/commit/eddcbcc7660e5f51d9b794ab0302abb9790c6b3c) by [@brunnre8](https://github.com/brunnre8))
- update dependency webpack-dev-middleware to v5.3.3 ([`4831c20`](https://github.com/thelounge/thelounge/commit/4831c2080415a72492e97d55be8512c86c4324b3) by [@brunnre8](https://github.com/brunnre8))
- update dependency webpack to v5.76.0 ([`6b00ccf`](https://github.com/thelounge/thelounge/commit/6b00ccf82b60503b31e4fee1e32f2765c234d8cc) by [@brunnre8](https://github.com/brunnre8))
## v4.4.0-pre.2 - 2023-03-19 [Pre-release]
[See the full changelog](https://github.com/thelounge/thelounge/compare/v4.4.0-pre.1...v4.4.0-pre.2)
Hot fix for a bug that lead to channel loss upon restart of TL.
## v4.4.0-pre.1 - 2023-03-19 [Pre-release]
[See the full changelog](https://github.com/thelounge/thelounge/compare/v4.3.1...v4.4.0-pre.1)
This is a pre-release for v4.4.0 to offer latest changes without having to wait for a stable release.
At this stage, features may still be added or modified until the first release candidate for this version gets released.
This is mostly a developer focused release. Max, Eric and others rewrote the whole thing in typescript / vue3,
which should make it much easier to add features and find bugs in the future. So huge kudos from the rest of the team!
Besides that, there's the obvious grab bag of fixes, dependency updates and improvements.
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.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).
4.3 is a smaller release with one major feature: message search! A big thank you to [richrd](https://github.com/richrd) and [Nachtalb](https://github.com/Nachtalb) for working on this. Note that it is somewhat limited at the moment — you cannot jump to messages or see context around them, but this was a major hurdle and we can improve upon it. You can try it out by using `/search` or by clicking or tapping the new icon in the topic bar above channels or queries as long as your `messageStorage` server setting includes `sqlite`. Some other additions are an improved ordering of elements for screen reader users, more context menu options, and new gestures for touchscreen users. You can learn about the gestures and new commands by navigating to the Help page with the `?` button in the bottom of your channel sidebar.
Additionally, support for Node 10 has been removed as it reached its end-of-life and the new minimum supported version is Node 12.0.0.
A huge thank you to the 32 contributors who made this release possible!
### Added
- Classes for channels in list with unread counts and highlights ([#4214](https://github.com/thelounge/thelounge/pull/4214) by [@sha1sum](https://github.com/sha1sum))
- Add proper filename to the content-disposition header ([#4187](https://github.com/thelounge/thelounge/pull/4187) by [@Nachtalb](https://github.com/Nachtalb))
- Add HTML lang and labelled-by field to upload ([#4051](https://github.com/thelounge/thelounge/pull/4051) by [@MaxLeiter](https://github.com/MaxLeiter))
- Improve inline audio file support ([#4210](https://github.com/thelounge/thelounge/pull/4210) by [@Nachtalb](https://github.com/Nachtalb))
- Show give/revoke modes and kick in context menu on other modes than +o ([#4176](https://github.com/thelounge/thelounge/pull/4176) by [@mitaka8](https://github.com/mitaka8), [#4181](https://github.com/thelounge/thelounge/pull/4181) by [@MaxLeiter](https://github.com/MaxLeiter))
- Add prefetchMaxSearchSize to override limit for link previews ([#4135](https://github.com/thelounge/thelounge/pull/4135) by [@brunnre8](https://github.com/brunnre8))
- Skip video/audio embeds if og:type exists but does not specify it ([#4040](https://github.com/thelounge/thelounge/pull/4040) by [@xPaw](https://github.com/xPaw))
- Add version support for packages. ([#4041](https://github.com/thelounge/thelounge/pull/4041) by [@McInkay](https://github.com/McInkay))
- Add enterkeyhint on chat input and topic save ([#4055](https://github.com/thelounge/thelounge/pull/4055) by [@xPaw](https://github.com/xPaw))
- Make `add` and `reset` CLI commands scriptable ([#4090](https://github.com/thelounge/thelounge/pull/4090) by [@supertassu](https://github.com/supertassu))
- Add extended join information to join message ([#4105](https://github.com/thelounge/thelounge/pull/4105) by [@GewoonYorick](https://github.com/GewoonYorick))
- Add ignore option to contextmenu ([#4104](https://github.com/thelounge/thelounge/pull/4104) by [@GewoonYorick](https://github.com/GewoonYorick))
- Add gopher and gemini to the commonSchemes ([#4151](https://github.com/thelounge/thelounge/pull/4151) by [@Willamin](https://github.com/Willamin))
- Add network specific leave message ([#4116](https://github.com/thelounge/thelounge/pull/4116) by [@Nachtalb](https://github.com/Nachtalb))
- Message Search ([#4197](https://github.com/thelounge/thelounge/pull/4197) by [@Nachtalb](https://github.com/Nachtalb), [`69c37a5`](https://github.com/thelounge/thelounge/commit/69c37a535b91226ad744068fb38cdfdea5be167e), [`521426b`](https://github.com/thelounge/thelounge/commit/521426bb05ada1784bc61d157fd0d965fbe5fffc) by [@JeDaYoshi](https://github.com/JeDaYoshi), [`40a5ee7`](https://github.com/thelounge/thelounge/commit/40a5ee70b6b5eaaef8380b430172491a6ae4f7bb) by [@MaxLeiter](https://github.com/MaxLeiter), [#3664](https://github.com/thelounge/thelounge/pull/4197) by [@richrd](https://github.com/richrd))
- Fill inputhistory on channel load and more message load ([#4206](https://github.com/thelounge/thelounge/pull/4206) by [@Nachtalb](https://github.com/Nachtalb), [`af96f77`](https://github.com/thelounge/thelounge/commit/af96f7771cd067b71a9fbe92b7de5640fe9f2087) by [@MaxLeiter](https://github.com/MaxLeiter))
- Allow installation of local packages ([#4251](https://github.com/thelounge/thelounge/pull/4251) by [@brunnre8](https://github.com/brunnre8))
- Toggle recent mentions popup with ctrl/alt+m ([#4258](https://github.com/thelounge/thelounge/pull/4258) by [@bl1nk](https://github.com/bl1nk))
- Add support for SOCKS ([#4211](https://github.com/thelounge/thelounge/pull/4211) by [@Mstrodl](https://github.com/Mstrodl))
- Accessibility improvements (re-order, hide, and label certain DOM elements)([#4201](https://github.com/thelounge/thelounge/pull/4201) by [@MaxLeiter](https://github.com/MaxLeiter), [#4279](https://github.com/thelounge/thelounge/pull/4279) by [@JeDaYoshi](https://github.com/JeDaYoshi))
- Add /umode support ([#4274](https://github.com/thelounge/thelounge/pull/4274) by [@JeDaYoshi](https://github.com/JeDaYoshi))
- Add warning for HTTPS requirement on notifications ([#4280](https://github.com/thelounge/thelounge/pull/4280) by [@JeDaYoshi](https://github.com/JeDaYoshi))
- Allow network list reordering via touch. ([#4326](https://github.com/thelounge/thelounge/pull/4326), [#4332](https://github.com/thelounge/thelounge/pull/4332) by [@itsjohncs](https://github.com/itsjohncs))
- Two-finger swipe now switches windows (#3901) ([#4324](https://github.com/thelounge/thelounge/pull/4324) by [@itsjohncs](https://github.com/itsjohncs))
- Improve responsiveness of channel name and topic. ([#4340](https://github.com/thelounge/thelounge/pull/4340) by [@itsjohncs](https://github.com/itsjohncs))
- Add more plugin functionality ([#4329](https://github.com/thelounge/thelounge/pull/4329) by [@brunnre8](https://github.com/brunnre8))
- Add keyboard shortcut for help screen (#4315) ([`9a0ba1d`](https://github.com/thelounge/thelounge/commit/9a0ba1da6c318e74545d931ec67c67e87071285a) by [@NoahvdAa](https://github.com/NoahvdAa))
### Changed
- Vertically center topic editing input in Safari. (#4325) ([`2ab6716`](https://github.com/thelounge/thelounge/commit/2ab671664e1ac550fbb22b81284c665f72eee1d9) by [@itsjohncs](https://github.com/itsjohncs))
- Do not condense single messages (#4313) ([`7873847`](https://github.com/thelounge/thelounge/commit/7873847a7ebb4c26c0c380c6304f55a431a3872e) by [@supertassu](https://github.com/supertassu))
- MessageSearchForm: do not focus input if search is closed ([#4242](https://github.com/thelounge/thelounge/pull/4242) by [@brunnre8](https://github.com/brunnre8))
- Add new "/search query" command to open the search window ([#4213](https://github.com/thelounge/thelounge/pull/4213) by [@Nachtalb](https://github.com/Nachtalb))
- Add support for JPEG XL image previews ([#4219](https://github.com/thelounge/thelounge/pull/4219) by [@TheDecryptor](https://github.com/TheDecryptor))
- Make esc key close mentions window (#4365) ([`9dbf647`](https://github.com/thelounge/thelounge/commit/9dbf647f7e3248eedd0f237be55ef7244647a005) by [@brunnre8](https://github.com/brunnre8))
- Display server-originated notices to channels in the channel window ([#4260](https://github.com/thelounge/thelounge/pull/4260) by [@BradleyShaw](https://github.com/BradleyShaw))
- Optimise modes based on ISUPPORT ([#4275](https://github.com/thelounge/thelounge/pull/4275) by [@JeDaYoshi](https://github.com/JeDaYoshi))
- Allow wildcards in hostmask ([#4351](https://github.com/thelounge/thelounge/pull/4351) by [@brunnre8](https://github.com/brunnre8))
- Only scroll history when cursor is on first or last row ([#4205](https://github.com/thelounge/thelounge/pull/4205) by [@Nachtalb](https://github.com/Nachtalb))
- Cleanup of SQLite message storage ([#4345](https://github.com/thelounge/thelounge/pull/4345) by [@itsjohncs](https://github.com/itsjohncs))
- Do not generate and send client certificate unless SASL EXTERNAL is requested ([#4093](https://github.com/thelounge/thelounge/pull/4093) by [@xPaw](https://github.com/xPaw))
- NetworkForm: s/away message/leave message/ ([#4193](https://github.com/thelounge/thelounge/pull/4193) by [@brunnre8](https://github.com/brunnre8))
- Settings: show label for nick autocompletion postfix ([#4195](https://github.com/thelounge/thelounge/pull/4195) by [@brunnre8](https://github.com/brunnre8))
- Move font assignment of password reveal icon ([#4342](https://github.com/thelounge/thelounge/pull/4342) by [@deejayy](https://github.com/deejayy))
- Prevent round and white search styling in iOS 15. ([#4352](https://github.com/thelounge/thelounge/pull/4352) by [@itsjohncs](https://github.com/itsjohncs))
- Allow escape key to close search bar and search page ([#4364](https://github.com/thelounge/thelounge/pull/4364) by [@MaxLeiter](https://github.com/MaxLeiter))
- Use SortableJS 1.14.0. (#4330) ([`2b634a6`](https://github.com/thelounge/thelounge/commit/2b634a6ba61bfc4c3b45f620b11396497f2f77a5) by [@itsjohncs](https://github.com/itsjohncs))
- Switch to thelounge/Sortable fork for Sortable.js (#4368) ([`315198a`](https://github.com/thelounge/thelounge/commit/315198ac0ba07400a33e8949ba50cddb774695c4) by [@MaxLeiter](https://github.com/MaxLeiter))
- Update production dependencies to their latest versions:
- `tlds` ([#4046](https://github.com/thelounge/thelounge/pull/4046))
- `commander` ([#4168](https://github.com/thelounge/thelounge/pull/4168), [#4185](https://github.com/thelounge/thelounge/pull/4185))
- `sqlite3` ([#4142](https://github.com/thelounge/thelounge/pull/4142))
- `chalk` ([#4208](https://github.com/thelounge/thelounge/pull/4208))
- `mime-types` ([#4349](https://github.com/thelounge/thelounge/pull/4349))
- `linkify-it` ([#4348](https://github.com/thelounge/thelounge/pull/4348))
### Fixed
- Differentiate WALLOPS from NOTICE ([#4264](https://github.com/thelounge/thelounge/pull/4264) by [@BradleyShaw](https://github.com/BradleyShaw))
- Fix sporadic rounding on message search bar. ([#4333]((https://github.com/thelounge/thelounge/pull/4333), [#4328](<(https://github.com/thelounge/thelounge/pull/4328)>) by [@itsjohncs](https://github.com/itsjohncs))
- Fix missing users in userlist after removing searchinput ([#4221](https://github.com/thelounge/thelounge/pull/4221) by [@Nachtalb](https://github.com/Nachtalb))
- Always use multi-prefix modes ([#4060](https://github.com/thelounge/thelounge/pull/4060) by [@xPaw](https://github.com/xPaw))
- Fix breaking GIFs while removing metadata ([#4110](https://github.com/thelounge/thelounge/pull/4110) by [@Nachtalb](https://github.com/Nachtalb))
- Improved handling of empty userdata ([#4190](https://github.com/thelounge/thelounge/pull/4190) by [@Nachtalb](https://github.com/Nachtalb))
- Restrict what the browser should try to autocomplete ([#4192](https://github.com/thelounge/thelounge/pull/4192) by [@Nachtalb](https://github.com/Nachtalb), [#4337](https://github.com/thelounge/thelounge/commit/3ba7fb6de4270db1310b8624c9f308e858352f4a) by [@brunnre8](https://github.com/brunnre8))
- Render styling for colored host masks ([#4235](https://github.com/thelounge/thelounge/pull/4235) by [@angerson](https://github.com/angerson))
- Fix not overriding config options with -c ([#4262](https://github.com/thelounge/thelounge/pull/4262) by [@MaxLeiter](https://github.com/MaxLeiter))
- Fix nick-less messages from servers ([#4277](https://github.com/thelounge/thelounge/pull/4277) by [@JeDaYoshi](https://github.com/JeDaYoshi))
- Fix authenticated proxy ([#4341](https://github.com/thelounge/thelounge/pull/4341) by [@Nachtalb](https://github.com/Nachtalb))
- Allow text drag & drop into input text field ([#4212](https://github.com/thelounge/thelounge/pull/4212) by [@Nachtalb](https://github.com/Nachtalb))
### Security
- Update dependency ua-parser-js to v0.7.24 ([#4216](https://github.com/thelounge/thelounge/pull/4216) by [@renovate](https://github.com/apps/renovate))
- Update dependency postcss to v8.2.10 ([#4223](https://github.com/thelounge/thelounge/pull/4223) by [@renovate](https://github.com/apps/renovate))
- CSP adjustments ([#4344](https://github.com/thelounge/thelounge/pull/4344) by [@brunnre8](https://github.com/brunnre8))
- Bump required node version to 12.x and add 16.x builds ([#4356](https://github.com/thelounge/thelounge/pull/4356) by [@MaxLeiter](https://github.com/MaxLeiter))
### Documentation
In the main repository:
- Clarify description of prefetchMaxSearchSize. (#4338) ([`21c6abd`](https://github.com/thelounge/thelounge/commit/21c6abdd1d9e7ab09612250857ea418beb2885ec) by [@itsjohncs](https://github.com/itsjohncs))
- `client/views` -> `client/components` in README ([#4196](https://github.com/thelounge/thelounge/pull/4196) by [@MaxLeiter](https://github.com/MaxLeiter))
On the [website repository](https://github.com/thelounge/thelounge.github.io):
- Update commands API docs (#217) ([`9c6a9e4`](https://github.com/thelounge/thelounge.github.io/commit/9c6a9e4b7d31efa37708a2796254f6cbe6e9abdf) by [@McInkay](https://github.com/McInkay))
- Add Caddy v2 examples (#230) ([`5554338`](https://github.com/thelounge/thelounge.github.io/commit/55543386feaf1f41dd845d500458a49be417da39) by [@Jay2k1](https://github.com/Jay2k1))
- Add self hosted pod to community.md (#231) ([`9e658c6`](https://github.com/thelounge/thelounge.github.io/commit/9e658c618daa144c8d757826c54d9bd67c53a133) by [@MaxLeiter](https://github.com/MaxLeiter))
- reword note on daemonizing when installing from npm (#232) ([`6fab4fe`](https://github.com/thelounge/thelounge.github.io/commit/6fab4fe456abed6343b84f21f7caf5a3a0c6fed3) by [@igalic](https://github.com/igalic))
- Add css snippets for hiding account and realname from join messages ([#233](https://github.com/thelounge/thelounge.github.io/pull/233) by [@GewoonYorick](https://github.com/GewoonYorick))
- Add macOS Instructions ([#237](https://github.com/thelounge/thelounge.github.io/pull/237) by [@xnaas](https://github.com/xnaas))
- add "Hide unread counters in sidebar, just show a highlight indicator" ([#235](https://github.com/thelounge/thelounge.github.io/pull/235) by [@Jay2k1](https://github.com/Jay2k1))
- Clarify enabling Advanced settings to access custom CSS ([`cb0a427`](https://github.com/thelounge/thelounge.github.io/commit/cb0a427f49a313d7fc0eb56b0e422c14eb234574) by [@MaxLeiter](https://github.com/MaxLeiter))
- Update outdated CSS snippets in custom-css.md (#238) ([`fe9d09c`](https://github.com/thelounge/thelounge.github.io/commit/fe9d09c5062dd7dbe3563c7e72f82ef0c1a9eeb9) by [@EliteOfGods](https://github.com/EliteOfGods))
- Change the IRC server to Libera.Chat (#242) ([`7b8c010`](https://github.com/thelounge/thelounge.github.io/commit/7b8c0100fc66e368e02ece5e8a62e40f0817b3ae) by [@mhajder](https://github.com/mhajder))
- Fix spaces ([`3a41b12`](https://github.com/thelounge/thelounge.github.io/commit/3a41b121ec0d5e0b93694438dec8a4758b88627b) by [@xPaw](https://github.com/xPaw))
- Update custom-css.md ([#240](https://github.com/thelounge/thelounge.github.io/pull/240) by [@PeGaSuS-Coder](https://github.com/PeGaSuS-Coder))
- Alphabetically sorted unofficial install methods, added Swizzin ([#236](https://github.com/thelounge/thelounge.github.io/pull/236) by [@flying-sausages](https://github.com/flying-sausages))
- Update dependencies and community page (#245) ([`0762606`](https://github.com/thelounge/thelounge.github.io/commit/0762606c3bbfe55a4b053d6a6bddd0129ba1fff8) by [@MaxLeiter](https://github.com/MaxLeiter))
- Update config.js.md (#247) ([`3036977`](https://github.com/thelounge/thelounge.github.io/commit/3036977f3ea7c521cd22f29bfb3425f079ce5ed3) by [@ledakis](https://github.com/ledakis))
- Docs - Adding plugins section on main website (#248) ([`1fbaa17`](https://github.com/thelounge/thelounge.github.io/commit/1fbaa17cd9baa74e8d4c3dfab91b445105a503e5) by [@aab12345](https://github.com/aab12345))
- Docs - Change header links on main website (#249) ([`52eb866`](https://github.com/thelounge/thelounge.github.io/commit/52eb8668577ba9e7a4813831c77440be64c5aac8) by [@aab12345](https://github.com/aab12345))
- Extend theming guide with "files" section (#252) ([`94b8c8d`](https://github.com/thelounge/thelounge.github.io/commit/94b8c8dacea0d8b5941e35ca9a6b0ed30eaa7b2d) by [@deejayy](https://github.com/deejayy))
- Protect The Lounge with HTTPS (#253) ([`c4cfe60`](https://github.com/thelounge/thelounge.github.io/commit/c4cfe60421dc19e530119f63b637991ac0c465d8) by [@aab12345](https://github.com/aab12345))
- Plugin docs (#254) ([`45b32c5`](https://github.com/thelounge/thelounge.github.io/commit/45b32c5bf5282fc207427e9c22bdbc622b947eb0) by [@brunnre8](https://github.com/brunnre8))
### Internals
- Clean up global listener in Sidebar component. (#4331) ([`5d76ed8`](https://github.com/thelounge/thelounge/commit/5d76ed888ce8d328913c15fde0b1026f0d60eb54) by [@itsjohncs](https://github.com/itsjohncs))
- Properly track user modes for context menu (#4267) ([`8fcd079`](https://github.com/thelounge/thelounge/commit/8fcd079204f6c44cadf7fff95c00a44242a61c68) by [@brunnre8](https://github.com/brunnre8))
- Optimise commands processing ([`0d839c5`](https://github.com/thelounge/thelounge/commit/0d839c501efa0cf56bce72263ab5c93535e34cd1) by [@JeDaYoshi](https://github.com/JeDaYoshi))
- Fix linter warnings for aria-label placement ([`d05cf5f`](https://github.com/thelounge/thelounge/commit/d05cf5fe628596a55a8aebda03e5692488890d94) by [@MaxLeiter](https://github.com/MaxLeiter))
- Configure server ping timeout to 60 seconds ([#4171](https://github.com/thelounge/thelounge/pull/4171) by [@emilyst](https://github.com/emilyst))
- Fix test for production build ([`c2e8eaf`](https://github.com/thelounge/thelounge/commit/c2e8eaf9dfed3720657b80619397f6d037d1c835) by [@xPaw](https://github.com/xPaw))
- Add node 15 to test matrix ([`69986b3`](https://github.com/thelounge/thelounge/commit/69986b3ee5727cee9ecd274efcfcfe5137116857) by [@xPaw](https://github.com/xPaw))
- Add .vscode settings and suggested extensions ([#4042](https://github.com/thelounge/thelounge/pull/4042) by [@xPaw](https://github.com/xPaw))
- Change the IRC server to Libera.Chat ([#4238](https://github.com/thelounge/thelounge/pull/4238) by [@mhajder](https://github.com/mhajder))
- Update prettier and apply formatting ([`b74b692`](https://github.com/thelounge/thelounge/commit/b74b6923912ec7c498a8fbcd0a6f53c44c7a3f25) by [@xPaw](https://github.com/xPaw))
- Update dependencies ([[`#4155`](https://github.com/thelounge/thelounge/pulls/4155), [`#4252`](https://github.com/thelounge/thelounge/pulls/4252), [`#4265`](https://github.com/thelounge/thelounge/pulls/4265), [`#4281`](https://github.com/thelounge/thelounge/pulls/4281), [`#4312`](https://github.com/thelounge/thelounge/pulls/4312) by [@MaxLeiter], [#4087](https://github.com/thelounge/thelounge/pulls/4087) by [@xPaw](https://github.com/xPaw))
- Change renovate to monthly ([`7ee0732`](https://github.com/thelounge/thelounge/commit/7ee0732f56644f4f337cfdc5244f44e3e27dc8bc) by [@xPaw](https://github.com/xPaw))
- Add depTypeList to renovate ([`61ebd65`](https://github.com/thelounge/thelounge/commit/61ebd65367fa4d829b84ef2a48ad185cb2c8a385) by [@xPaw](https://github.com/xPaw))
- Update mini-css-extract-plugin ([`a9fb563`](https://github.com/thelounge/thelounge/commit/a9fb563c01a3c4ff9520e5017c42b28911eda38f) by [@xPaw](https://github.com/xPaw))
- Upgrade to webpack 5 ([`41831d1`](https://github.com/thelounge/thelounge/commit/41831d18b1507275de61bf79bb32cb25a3b590eb) by [@xPaw](https://github.com/xPaw))
- Update development dependencies to their latest versions:
- `pretty-quick` ([#4045](https://github.com/thelounge/thelounge/pull/4045))
- `@babel/core` ([#4043](https://github.com/thelounge/thelounge/pull/4043), [#4167](https://github.com/thelounge/thelounge/pull/4167), [#4182](https://github.com/thelounge/thelounge/pull/4182), [#4207](https://github.com/thelounge/thelounge/pull/4207))
- `@vue/server-test-utils` ([#4094](https://github.com/thelounge/thelounge/pull/4094))
- `@vue/test-utils` ([#4094](https://github.com/thelounge/thelounge/pull/4094))
- `vue-loader` ([#4094](https://github.com/thelounge/thelounge/pull/4094))
- `eslint-plugin-vue` ([#4141](https://github.com/thelounge/thelounge/pull/4141))
- `eslint` ([#4140](https://github.com/thelounge/thelounge/pull/4140), [#4170](https://github.com/thelounge/thelounge/pull/4170), [#4076](https://github.com/thelounge/thelounge/pull/4076))
- `dayjs` ([#4139](https://github.com/thelounge/thelounge/pull/4139))
- `copy-webpack-plugin` ([#4138](https://github.com/thelounge/thelounge/pull/4138))
- `css-loader` ([#4169](https://github.com/thelounge/thelounge/pull/4169))
- `@babel/preset-env` ([#4167](https://github.com/thelounge/thelounge/pull/4167), [#4182](https://github.com/thelounge/thelounge/pull/4182), [#4207](https://github.com/thelounge/thelounge/pull/4207))
- `@fortawesome/fontawesome-free` ([#4183](https://github.com/thelounge/thelounge/pull/4183))
- `chai` ([#4184](https://github.com/thelounge/thelounge/pull/4184))
In the [deb repository](https://github.com/thelounge/thelounge-deb):
- Add node 14 to GitHub action ([`56c7ba6`](https://github.com/thelounge/thelounge-deb/commit/56c7ba6cc598ccf9da1e04876b4e107f98cc3ed2) by [@xPaw](https://github.com/xPaw))
- Upgrade TravisCI to Bionic ([#77](https://github.com/thelounge/thelounge-deb/pull/77) by [@maxpoulin64](https://github.com/maxpoulin64))
- systemd: Don't force enable units ([#74](https://github.com/thelounge/thelounge-deb/pull/74) by [@brunnre8](https://github.com/brunnre8))
- Use dedicated npm cache dir ([#76](https://github.com/thelounge/thelounge-deb/pull/76) by [@brunnre8](https://github.com/brunnre8))
- Make all files root owned ([#75](https://github.com/thelounge/thelounge-deb/pull/75) by [@brunnre8](https://github.com/brunnre8))
## v4.3.0-rc.2 - 2021-11-18 [Pre-release]
[See the full changelog](https://github.com/thelounge/thelounge/compare/v4.3.0-rc.1...v4.3.0-rc.2)
This is a release candidate (RC) for v4.3.0 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-rc.1 - 2021-11-17 [Pre-release]
[See the full changelog](https://github.com/thelounge/thelounge/compare/v4.3.0-pre.6...v4.3.0-rc.1)

View file

@ -16,6 +16,8 @@
<a href="https://thelounge.chat/docs">Docs</a>
<a href="https://demo.thelounge.chat/">Demo</a>
<a href="https://github.com/thelounge/thelounge-docker">Docker</a>
</strong>
</p>
<p align="center">
@ -28,9 +30,6 @@
<a href="https://github.com/thelounge/thelounge/actions"><img
alt="Build Status"
src="https://github.com/thelounge/thelounge/workflows/Build/badge.svg"></a>
<a href="https://npm-stat.com/charts.html?package=thelounge&from=2016-02-12"><img
alt="Total downloads on npm"
src="https://img.shields.io/npm/dy/thelounge.svg?colorA=333a41&colorB=007dc7&maxAge=3600&label=Downloads"></a>
</p>
<p align="center">
@ -52,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
@ -84,6 +83,13 @@ fork.
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 test suite
- Run `yarn build` if you change or add anything in `client/js` or `client/components`
- Run `yarn test` to execute linters and the test suite
- Run `yarn format:prettier` if linting fails
- 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
To ensure that you don't commit files that fail the linting, you can install a pre-commit git hook.
Execute `yarn githooks-install` to do so.

4
babel.config.cjs Normal file
View file

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

View file

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

View file

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

View file

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

View file

@ -3,10 +3,10 @@
<div
id="chat"
:class="{
'hide-motd': !$store.state.settings.motd,
'colored-nicks': $store.state.settings.coloredNicks,
'time-seconds': $store.state.settings.showSeconds,
'time-12h': $store.state.settings.use12hClock,
'hide-motd': !store.state.settings.motd,
'time-seconds': store.state.settings.showSeconds,
'time-12h': store.state.settings.use12hClock,
'colored-nicks': true, // TODO temporarily fixes themes, to be removed in next major version
}"
>
<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="this.$store.state.currentUserVisibleError"
v-if="store.state.currentUserVisibleError"
id="user-visible-error"
@click="hideUserVisibleError"
>
{{ this.$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,12 @@ 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";
import {SpecialChanType, ChanType} from "../../shared/types/chan";
export default {
export default defineComponent({
name: "Chat",
components: {
ParsedMessage,
@ -145,93 +149,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) {
case "list_bans":
return ListBans;
case "list_invites":
return ListInvites;
case "list_channels":
return ListChannels;
case "list_ignored":
return ListIgnored;
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 SpecialChanType.BANLIST:
return ListBans as Component;
case SpecialChanType.INVITELIST:
return ListInvites as Component;
case SpecialChanType.CHANNELLIST:
return ListChannels as Component;
case SpecialChanType.IGNORELIST:
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 === ChanType.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() {
};
const openMentions = (event: any) => {
eventbus.emit("mentions:toggle", {
event: event,
});
},
};
watch(
() => props.channel,
() => {
channelChanged();
}
);
watch(
() => props.channel.editTopic,
(newTopic) => {
if (newTopic) {
void nextTick(() => {
topicInput.value?.focus();
});
}
}
);
onMounted(() => {
channelChanged();
if (props.channel.editTopic) {
void nextTick(() => {
topicInput.value?.focus();
});
}
});
return {
store,
messageList,
topicInput,
specialComponent,
hideUserVisibleError,
editTopic,
saveTopic,
openContextMenu,
openMentions,
};
},
};
});
</script>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,30 +1,35 @@
<template>
<span class="inline-channel" dir="auto" role="button" tabindex="0" @click="onClick"
<span
class="inline-channel"
dir="auto"
role="button"
tabindex="0"
@click.prevent="openContextMenu"
@contextmenu.prevent="openContextMenu"
><slot></slot
></span>
</template>
<script>
import socket from "../js/socket";
<script lang="ts">
import {defineComponent} from "vue";
import eventbus from "../js/eventbus";
export default {
export default defineComponent({
name: "InlineChannel",
props: {
channel: String,
},
methods: {
onClick() {
const existingChannel = this.$store.getters.findChannelOnCurrentNetwork(this.channel);
if (existingChannel) {
this.$root.switchToChannel(existingChannel);
}
socket.emit("input", {
target: this.$store.state.activeChannel.channel.id,
text: "/join " + this.channel,
setup(props) {
const openContextMenu = (event) => {
eventbus.emit("contextmenu:inline-channel", {
event: event,
channel: props.channel,
});
},
};
return {
openContextMenu,
};
},
};
});
</script>

View file

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

View file

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

View file

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

View file

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

View file

@ -20,20 +20,20 @@
<p v-if="isLoading">Loading</p>
<p v-else>You have no recent mentions.</p>
</template>
<template v-for="message in resolvedMessages" v-else>
<div :key="message.msgId" :class="['msg', message.type]">
<template v-for="message in resolvedMessages" v-else :key="message.msgId">
<div :class="['msg', message.type]">
<div class="mentions-info">
<div>
<span class="from">
<Username :user="message.from" />
<Username :user="(message.from as any)" />
<template v-if="message.channel">
in {{ message.channel.channel.name }} on
{{ message.channel.network.name }}
</template>
<template v-else> in unknown channel </template>
</span>
<template v-else> in unknown channel </template> </span
>{{ ` ` }}
<span :title="message.localetime" class="time">
{{ messageTime(message.time) }}
{{ messageTime(message.time.toString()) }}
</span>
</div>
<div>
@ -50,7 +50,7 @@
</div>
</div>
<div class="content" dir="auto">
<ParsedMessage :network="null" :message="message" />
<ParsedMessage :message="(message as any)" />
</div>
</div>
</template>
@ -144,7 +144,7 @@
}
</style>
<script>
<script lang="ts">
import Username from "./Username.vue";
import ParsedMessage from "./ParsedMessage.vue";
import socket from "../js/socket";
@ -152,73 +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;
},
},
watch: {
"$store.state.mentions"() {
this.isLoading = false;
},
},
mounted() {
eventbus.on("mentions:toggle", this.openPopup);
},
destroyed() {
eventbus.off("mentions:toggle", this.openPopup);
},
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;
}
},
openPopup() {
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,
};
},
};
});
</script>

View file

@ -17,21 +17,22 @@
aria-hidden="true"
:aria-label="messageTimeLocale"
class="time tooltipped tooltipped-e"
>
{{ messageTime }}
>{{ `${messageTime}&#32;` }}
</span>
<template v-if="message.type === 'unhandled'">
<span class="from">[{{ message.command }}]</span>
<span class="content">
<span v-for="(param, id) in message.params" :key="id">{{ param }} </span>
<span v-for="(param, id) in message.params" :key="id">{{
`&#32;${param}&#32;`
}}</span>
</span>
</template>
<template v-else-if="isAction()">
<span class="from"><span class="only-copy">*** </span></span>
<span class="from"><span class="only-copy">***&nbsp;</span></span>
<component :is="messageComponent" :network="network" :message="message" />
</template>
<template v-else-if="message.type === 'action'">
<span class="from"><span class="only-copy">* </span></span>
<span class="from"><span class="only-copy">*&nbsp;</span></span>
<span class="content" dir="auto">
<Username
:user="message.from"
@ -53,21 +54,21 @@
<template v-if="message.from && message.from.nick">
<span class="only-copy" aria-hidden="true">&lt;</span>
<Username :user="message.from" :network="network" :channel="channel" />
<span class="only-copy" aria-hidden="true">&gt; </span>
<span class="only-copy" aria-hidden="true">&gt;&nbsp;</span>
</template>
</span>
<span v-else-if="message.type === 'plugin'" class="from">
<template v-if="message.from && message.from.nick">
<span class="only-copy" aria-hidden="true">[</span>
{{ message.from.nick }}
<span class="only-copy" aria-hidden="true">] </span>
<span class="only-copy" aria-hidden="true">]&nbsp;</span>
</template>
</span>
<span v-else class="from">
<template v-if="message.from && message.from.nick">
<span class="only-copy" aria-hidden="true">-</span>
<Username :user="message.from" :network="network" :channel="channel" />
<span class="only-copy" aria-hidden="true">- </span>
<span class="only-copy" aria-hidden="true">-&nbsp;</span>
</template>
</span>
<span class="content" dir="auto">
@ -96,56 +97,77 @@
</div>
</template>
<script>
const constants = require("../js/constants");
import localetime from "../js/helpers/localetime";
<script lang="ts">
import {computed, defineComponent, PropType} from "vue";
import dayjs from "dayjs";
import constants from "../js/constants";
import localetime from "../js/helpers/localetime";
import Username from "./Username.vue";
import LinkPreview from "./LinkPreview.vue";
import ParsedMessage from "./ParsedMessage.vue";
import MessageTypes from "./MessageTypes";
import type {ClientChan, ClientMessage, ClientNetwork} from "../js/types";
import {useStore} from "../js/store";
MessageTypes.ParsedMessage = ParsedMessage;
MessageTypes.LinkPreview = LinkPreview;
MessageTypes.Username = Username;
export default {
export default defineComponent({
name: "Message",
components: MessageTypes,
props: {
message: Object,
channel: Object,
network: Object,
keepScrollPosition: Function,
message: {type: Object as PropType<ClientMessage>, required: true},
channel: {type: Object as PropType<ClientChan>, required: false},
network: {type: Object as PropType<ClientNetwork>, required: true},
keepScrollPosition: Function as PropType<() => void>,
isPreviousSource: Boolean,
focused: Boolean,
},
computed: {
timeFormat() {
let format;
setup(props) {
const store = useStore();
if (this.$store.state.settings.use12hClock) {
format = this.$store.state.settings.showSeconds ? "msg12hWithSeconds" : "msg12h";
const timeFormat = computed(() => {
let format: keyof typeof constants.timeFormats;
if (store.state.settings.use12hClock) {
format = store.state.settings.showSeconds ? "msg12hWithSeconds" : "msg12h";
} else {
format = this.$store.state.settings.showSeconds ? "msgWithSeconds" : "msgDefault";
format = store.state.settings.showSeconds ? "msgWithSeconds" : "msgDefault";
}
return constants.timeFormats[format];
},
messageTime() {
return dayjs(this.message.time).format(this.timeFormat);
},
messageTimeLocale() {
return localetime(this.message.time);
},
messageComponent() {
return "message-" + this.message.type;
},
});
const messageTime = computed(() => {
return dayjs(props.message.time).format(timeFormat.value);
});
const messageTimeLocale = computed(() => {
return localetime(props.message.time);
});
const messageComponent = computed(() => {
return "message-" + (props.message.type || "invalid"); // TODO: force existence of type in sharedmsg
});
const isAction = () => {
if (!props.message.type) {
return false;
}
return typeof MessageTypes["message-" + props.message.type] !== "undefined";
};
return {
timeFormat,
messageTime,
messageTimeLocale,
messageComponent,
isAction,
};
},
methods: {
isAction() {
return typeof MessageTypes["message-" + this.message.type] !== "undefined";
},
},
};
});
</script>

View file

@ -17,48 +17,78 @@
</div>
</template>
<script>
const constants = require("../js/constants");
<script lang="ts">
import {computed, defineComponent, PropType, ref} from "vue";
import {condensedTypes} from "../../shared/irc";
import {MessageType} from "../../shared/types/msg";
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,
};
},
computed: {
condensedText() {
const obj = {};
setup(props) {
const isCollapsed = ref(true);
constants.condensedTypes.forEach((type) => {
const onCollapseClick = () => {
isCollapsed.value = !isCollapsed.value;
props.keepScrollPosition();
};
const condensedText = computed(() => {
const obj: Record<string, number> = {};
condensedTypes.forEach((type) => {
obj[type] = 0;
});
for (const message of this.messages) {
obj[message.type]++;
for (const message of props.messages) {
// special case since one MODE message can change multiple modes
if (message.type === MessageType.MODE) {
// syntax: +vv-t maybe-some targets
// we want the number of mode changes in the message, so count the
// number of chars other than + and - before the first space
const text = message.text ? message.text : "";
const modeChangesCount = text
.split(" ")[0]
.split("")
.filter((char) => char !== "+" && char !== "-").length;
obj[message.type] += modeChangesCount;
} else {
if (!message.type) {
/* eslint-disable no-console */
console.log(`empty message type, this should not happen: ${message.id}`);
continue;
}
obj[message.type]++;
}
}
// Count quits as parts in condensed messages to reduce information density
obj.part += obj.quit;
const strings = [];
constants.condensedTypes.forEach((type) => {
const strings: string[] = [];
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")
@ -66,18 +96,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")
@ -85,33 +116,50 @@ 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;
case "away":
strings.push(
"marked away " +
(obj[type] > 1 ? String(obj[type]) + " times" : "once")
);
break;
case "back":
strings.push(
"marked back " +
(obj[type] > 1 ? String(obj[type]) + " times" : "once")
);
break;
}
}
});
let text = strings.pop();
if (strings.length) {
text = strings.join(", ") + ", and " + text;
let text = strings.pop();
if (strings.length) {
text = strings.join(", ") + ", and " + text!;
}
return text;
}
return text;
},
return "";
});
return {
isCollapsed,
condensedText,
onCollapseClick,
};
},
methods: {
onCollapseClick() {
this.isCollapsed = !this.isCollapsed;
this.keepScrollPosition();
},
},
};
});
</script>

View file

@ -3,7 +3,7 @@
<div v-show="channel.moreHistoryAvailable" class="show-more">
<button
ref="loadMoreButton"
:disabled="channel.historyLoading || !$store.state.isConnected"
:disabled="channel.historyLoading || !store.state.isConnected"
class="btn"
@click="onShowMoreClick"
>
@ -22,11 +22,11 @@
<DateMarker
v-if="shouldDisplayDateMarker(message, id)"
:key="message.id + '-date'"
:message="message"
:focused="message.id == focused"
:message="message as any"
:focused="message.id === focused"
/>
<div
v-if="shouldDisplayUnreadMarker(message.id)"
v-if="shouldDisplayUnreadMarker(Number(message.id))"
:key="message.id + '-unread'"
class="unread-marker"
>
@ -39,7 +39,7 @@
:network="network"
:keep-scroll-position="keepScrollPosition"
:messages="message.messages"
:focused="message.id == focused"
:focused="message.id === focused"
/>
<Message
v-else
@ -49,7 +49,7 @@
:message="message"
:keep-scroll-position="keepScrollPosition"
:is-previous-source="isPreviousSource(message, id)"
:focused="message.id == focused"
:focused="message.id === focused"
@toggle-link-preview="onLinkPreviewToggle"
/>
</template>
@ -57,18 +57,42 @@
</div>
</template>
<script>
const constants = require("../js/constants");
<script lang="ts">
import {condensedTypes} from "../../shared/irc";
import {ChanType} from "../../shared/types/chan";
import {MessageType, SharedMsg} from "../../shared/types/msg";
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";
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,39 +100,108 @@ 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) {
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 !== ChanType.CHANNEL && props.channel.type !== ChanType.QUERY) {
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(
(message) => !constants.condensedTypes.has(message.type)
if (store.state.settings.statusMessages === "hidden") {
return props.channel.messages.filter(
(message) => !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 (
message.self ||
message.highlight ||
!constants.condensedTypes.has(message.type)
) {
if (message.self || message.highlight || !condensedTypes.has(message.type || "")) {
lastCondensedContainer = null;
condensed.push(message);
@ -116,7 +209,7 @@ export default {
continue;
}
if (lastCondensedContainer === null) {
if (!lastCondensedContainer) {
lastCondensedContainer = {
time: message.time,
type: "condensed",
@ -126,14 +219,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 +240,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: SharedMsg | CondensedMessageContainer,
id: number
) => {
const previousMessage = condensedMessages.value[id - 1];
if (!previousMessage) {
return true;
@ -224,135 +260,181 @@ 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];
};
const isPreviousSource = (currentMessage: ClientMessage, id: number) => {
const previousMessage = condensedMessages.value[id - 1];
return (
previousMessage &&
currentMessage.type === "message" &&
previousMessage.type === "message" &&
currentMessage.type === MessageType.MESSAGE &&
previousMessage.type === MessageType.MESSAGE &&
currentMessage.from &&
previousMessage.from &&
currentMessage.from.nick === previousMessage.from.nick
);
},
onCopy() {
clipboard(this.$el);
},
onLinkPreviewToggle(preview, message) {
this.keepScrollPosition();
};
const onCopy = () => {
if (chat.value) {
clipboard(chat.value);
}
};
const keepScrollPosition = async () => {
// If we are already waiting for the next tick to force scroll position,
// we have no reason to perform more checks and set it again in the next tick
if (isWaitingForNextTick.value) {
return;
}
const el = chat.value;
if (!el) {
return;
}
if (!props.channel.scrolledToBottom) {
if (props.channel.historyLoading) {
const heightOld = el.scrollHeight - el.scrollTop;
isWaitingForNextTick.value = true;
await nextTick();
isWaitingForNextTick.value = false;
skipNextScrollEvent.value = true;
el.scrollTop = el.scrollHeight - heightOld;
}
return;
}
isWaitingForNextTick.value = true;
await nextTick();
isWaitingForNextTick.value = false;
jumpToBottom();
};
const onLinkPreviewToggle = async (preview: ClientLinkPreview, message: ClientMessage) => {
await keepScrollPosition();
// Tell the server we're toggling so it remembers at page reload
socket.emit("msg:preview:toggle", {
target: this.channel.id,
target: props.channel.id,
msgId: message.id,
link: preview.link,
shown: preview.shown,
});
},
onShowMoreClick() {
if (!this.$store.state.isConnected) {
return;
}
};
let lastMessage = -1;
// Find the id of first message that isn't showInActive
// If showInActive is set, this message is actually in another channel
for (const message of this.channel.messages) {
if (!message.showInActive) {
lastMessage = message.id;
break;
}
}
this.channel.historyLoading = true;
socket.emit("more", {
target: this.channel.id,
lastId: lastMessage,
condensed: this.$store.state.settings.statusMessages !== "shown",
});
},
onLoadButtonObserved(entries) {
entries.forEach((entry) => {
if (!entry.isIntersecting) {
return;
}
this.onShowMoreClick();
});
},
keepScrollPosition() {
// If we are already waiting for the next tick to force scroll position,
// we have no reason to perform more checks and set it again in the next tick
if (this.isWaitingForNextTick) {
return;
}
const el = this.$refs.chat;
if (!el) {
return;
}
if (!this.channel.scrolledToBottom) {
if (this.channel.historyLoading) {
const heightOld = el.scrollHeight - el.scrollTop;
this.isWaitingForNextTick = true;
this.$nextTick(() => {
this.isWaitingForNextTick = false;
this.skipNextScrollEvent = true;
el.scrollTop = el.scrollHeight - heightOld;
});
}
return;
}
this.isWaitingForNextTick = true;
this.$nextTick(() => {
this.isWaitingForNextTick = false;
this.jumpToBottom();
});
},
handleScroll() {
const handleScroll = () => {
// Setting scrollTop also triggers scroll event
// We don't want to perform calculations for that
if (this.skipNextScrollEvent) {
this.skipNextScrollEvent = false;
if (skipNextScrollEvent.value) {
skipNextScrollEvent.value = false;
return;
}
const el = this.$refs.chat;
const el = chat.value;
if (!el) {
return;
}
this.channel.scrolledToBottom = el.scrollHeight - el.scrollTop - el.offsetHeight <= 30;
},
handleResize() {
// Keep message list scrolled to bottom on resize
if (this.channel.scrolledToBottom) {
this.jumpToBottom();
}
},
jumpToBottom() {
this.skipNextScrollEvent = true;
this.channel.scrolledToBottom = true;
props.channel.scrolledToBottom = el.scrollHeight - el.scrollTop - el.offsetHeight <= 30;
};
const el = this.$refs.chat;
el.scrollTop = el.scrollHeight;
},
const handleResize = () => {
// Keep message list scrolled to bottom on resize
if (props.channel.scrolledToBottom) {
jumpToBottom();
}
};
onMounted(() => {
chat.value?.addEventListener("scroll", handleScroll, {passive: true});
eventbus.on("resize", handleResize);
void nextTick(() => {
if (historyObserver.value && loadMoreButton.value) {
historyObserver.value.observe(loadMoreButton.value);
}
});
});
watch(
() => props.channel.id,
() => {
props.channel.scrolledToBottom = true;
// Re-add the intersection observer to trigger the check again on channel switch
// Otherwise if last channel had the button visible, switching to a new channel won't trigger the history
if (historyObserver.value && loadMoreButton.value) {
historyObserver.value.unobserve(loadMoreButton.value);
historyObserver.value.observe(loadMoreButton.value);
}
}
);
watch(
() => props.channel.messages,
async () => {
await keepScrollPosition();
},
{
deep: true,
}
);
watch(
() => props.channel.pendingMessage,
async () => {
// Keep the scroll stuck when input gets resized while typing
await keepScrollPosition();
}
);
onBeforeUpdate(() => {
unreadMarkerShown = false;
});
onBeforeUnmount(() => {
eventbus.off("resize", handleResize);
chat.value?.removeEventListener("scroll", handleScroll);
});
onUnmounted(() => {
if (historyObserver.value) {
historyObserver.value.disconnect();
}
});
return {
chat,
store,
onShowMoreClick,
loadMoreButton,
onCopy,
condensedMessages,
shouldDisplayDateMarker,
shouldDisplayUnreadMarker,
keepScrollPosition,
isPreviousSource,
jumpToBottom,
onLinkPreviewToggle,
};
},
};
});
</script>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,55 +4,74 @@
</span>
</template>
<script>
<script lang="ts">
import ParsedMessage from "../ParsedMessage.vue";
import {computed, defineComponent, PropType} from "vue";
import {ClientNetwork, ClientMessage} from "../../js/types";
export default {
export default defineComponent({
name: "MessageTypeError",
components: {
ParsedMessage,
},
props: {
network: Object,
message: Object,
network: {
type: Object as PropType<ClientNetwork>,
required: true,
},
message: {
type: Object as PropType<ClientMessage>,
required: true,
},
},
computed: {
errorMessage() {
switch (this.message.error) {
setup(props) {
const errorMessage = computed(() => {
// TODO: enforce chan and nick fields so that we can get rid of that
const chan = props.message.channel || "!UNKNOWN_CHAN";
const nick = props.message.nick || "!UNKNOWN_NICK";
switch (props.message.error) {
case "bad_channel_key":
return `Cannot join ${this.message.channel} - Bad channel key.`;
return `Cannot join ${chan} - Bad channel key.`;
case "banned_from_channel":
return `Cannot join ${this.message.channel} - You have been banned from the channel.`;
return `Cannot join ${chan} - 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 ${chan}`;
case "channel_is_full":
return `Cannot join ${this.message.channel} - Channel is full.`;
return `Cannot join ${chan} - 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 ${chan} - Channel is invite only.`;
case "no_such_nick":
return `User ${this.message.nick} hasn't logged in or does not exist.`;
return `User ${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 ${chan} - You've already reached the maximum number of channels allowed.`;
case "unknown_command":
return `Unknown command: ${this.message.command}`;
// TODO: not having message.command should never happen, so force existence
return `Unknown command: ${props.message.command || "!UNDEFINED_COMMAND_BUG"}`;
case "user_not_in_channel":
return `User ${this.message.nick} is not on the channel.`;
return `User ${nick} is not on the channel.`;
case "user_on_channel":
return `User ${this.message.nick} is already on the channel.`;
return `User ${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 || "!UNDEFINED_ERR"
})`;
}
return this.message.error;
return props.message.error;
}
},
});
return {
errorMessage,
};
},
};
});
</script>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,24 @@
<template>
<span class="content">
Your user mode is <b>{{ message.raw_modes }}</b>
</span>
</template>
<script lang="ts">
import {defineComponent, PropType} from "vue";
import {ClientNetwork, ClientMessage} from "../../js/types";
export default defineComponent({
name: "MessageChannelMode",
props: {
network: {
type: Object as PropType<ClientNetwork>,
required: true,
},
message: {
type: Object as PropType<ClientMessage>,
required: true,
},
},
});
</script>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -11,18 +11,20 @@
</template>
<template v-else>
Connect
<template v-if="config.lockNetwork && $store.state.serverConfiguration.public">
<template
v-if="config?.lockNetwork && store?.state.serverConfiguration?.public"
>
to {{ defaults.name }}
</template>
</template>
</h1>
<template v-if="!config.lockNetwork">
<template v-if="!config?.lockNetwork">
<h2>Network settings</h2>
<div class="connect-row">
<label for="connect:name">Name</label>
<input
id="connect:name"
v-model="defaults.name"
v-model.trim="defaults.name"
class="input"
name="name"
maxlength="100"
@ -33,7 +35,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"
@ -120,7 +122,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"
@ -145,7 +147,7 @@
<input
id="connect:proxyUsername"
ref="proxyUsernameInput"
v-model="defaults.proxyUsername"
v-model.trim="defaults.proxyUsername"
class="input username"
name="proxyUsername"
maxlength="100"
@ -173,13 +175,13 @@
</div>
</template>
</template>
<template v-else-if="config.lockNetwork && !$store.state.serverConfiguration.public">
<template v-else-if="config.lockNetwork && !store.state.serverConfiguration?.public">
<h2>Network settings</h2>
<div class="connect-row">
<label for="connect:name">Name</label>
<input
id="connect:name"
v-model="defaults.name"
v-model.trim="defaults.name"
class="input"
name="name"
maxlength="100"
@ -218,13 +220,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"
@ -235,7 +237,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"
@ -245,14 +247,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
@ -281,15 +283,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">
@ -343,7 +345,7 @@ the server tab on new connection"
Username + password (SASL PLAIN)
</label>
<label
v-if="!$store.state.serverConfiguration.public && defaults.tls"
v-if="!store.state.serverConfiguration?.public && defaults.tls"
class="opt"
>
<input
@ -362,7 +364,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"
@ -435,89 +437,136 @@ the server tab on new connection"
}
</style>
<script>
<script lang="ts">
import RevealPassword from "./RevealPassword.vue";
import SidebarToggle from "./SidebarToggle.vue";
import {defineComponent, nextTick, PropType, ref, watch} from "vue";
import {useStore} from "../js/store";
import {ClientNetwork} from "../js/types";
export default {
export type NetworkFormDefaults = Partial<ClientNetwork> & {
join?: string;
};
export default defineComponent({
name: "NetworkForm",
components: {
RevealPassword,
SidebarToggle,
},
props: {
handleSubmit: Function,
defaults: Object,
handleSubmit: {
type: Function as PropType<(network: ClientNetwork) => void>,
required: true,
},
defaults: {
type: Object as PropType<NetworkFormDefaults>,
required: true,
},
disabled: Boolean,
},
data() {
return {
config: this.$store.state.serverConfiguration,
previousUsername: this.defaults.username,
displayPasswordField: false,
};
},
watch: {
displayPasswordField(value) {
if (value) {
this.$nextTick(() => this.$refs.publicPassword.focus());
}
},
"defaults.commands"() {
this.$nextTick(this.resizeCommandsInput);
},
"defaults.tls"(isSecureChecked) {
const ports = [6667, 6697];
const newPort = isSecureChecked ? 0 : 1;
setup(props) {
const store = useStore();
const config = ref(store.state.serverConfiguration);
const previousUsername = ref(props.defaults?.username);
const displayPasswordField = ref(false);
// If you disable TLS and current port is 6697,
// set it to 6667, and vice versa
if (this.defaults.port === ports[newPort]) {
this.defaults.port = ports[1 - newPort];
}
},
},
methods: {
setSaslAuth(type) {
this.defaults.sasl = type;
},
onNickChanged(event) {
// Username input is not available when useHexIp is set
if (!this.$refs.usernameInput) {
return;
}
const publicPassword = ref<HTMLInputElement | null>(null);
if (
!this.$refs.usernameInput.value ||
this.$refs.usernameInput.value === this.previousUsername
) {
this.$refs.usernameInput.value = event.target.value;
watch(displayPasswordField, (newValue) => {
if (newValue) {
void nextTick(() => {
publicPassword.value?.focus();
});
}
});
this.previousUsername = event.target.value;
},
onSubmit(event) {
const formData = new FormData(event.target);
const data = {};
const commandsInput = ref<HTMLInputElement | null>(null);
for (const item of formData.entries()) {
data[item[0]] = item[1];
}
this.handleSubmit(data);
},
resizeCommandsInput() {
if (!this.$refs.commandsInput) {
const resizeCommandsInput = () => {
if (!commandsInput.value) {
return;
}
// Reset height first so it can down size
this.$refs.commandsInput.style.height = "";
commandsInput.value.style.height = "";
// 2 pixels to account for the border
this.$refs.commandsInput.style.height =
Math.ceil(this.$refs.commandsInput.scrollHeight + 2) + "px";
},
commandsInput.value.style.height = `${Math.ceil(
commandsInput.value.scrollHeight + 2
)}px`;
};
watch(
// eslint-disable-next-line
() => props.defaults?.commands,
() => {
void nextTick(() => {
resizeCommandsInput();
});
}
);
watch(
// eslint-disable-next-line
() => props.defaults?.tls,
(isSecureChecked) => {
const ports = [6667, 6697];
const newPort = isSecureChecked ? 0 : 1;
// If you disable TLS and current port is 6697,
// set it to 6667, and vice versa
if (props.defaults?.port === ports[newPort]) {
props.defaults.port = ports[1 - newPort];
}
}
);
const setSaslAuth = (type: string) => {
if (props.defaults) {
props.defaults.sasl = type;
}
};
const usernameInput = ref<HTMLInputElement | null>(null);
const onNickChanged = (event: Event) => {
if (!usernameInput.value) {
return;
}
const usernameRef = usernameInput.value;
if (!usernameRef.value || usernameRef.value === previousUsername.value) {
usernameRef.value = (event.target as HTMLInputElement)?.value;
}
previousUsername.value = (event.target as HTMLInputElement)?.value;
};
const onSubmit = (event: Event) => {
const formData = new FormData(event.target as HTMLFormElement);
const data: Partial<ClientNetwork> = {};
formData.forEach((value, key) => {
data[key] = value;
});
props.handleSubmit(data as ClientNetwork);
};
return {
store,
config,
displayPasswordField,
publicPassword,
commandsInput,
resizeCommandsInput,
setSaslAuth,
usernameInput,
onNickChanged,
onSubmit,
};
},
};
});
</script>

View file

@ -1,6 +1,6 @@
<template>
<div
v-if="$store.state.networks.length === 0"
v-if="store.state.networks.length === 0"
class="empty"
role="navigation"
aria-label="Network and Channel list"
@ -55,7 +55,7 @@
</div>
<Draggable
v-else
:list="$store.state.networks"
:list="store.state.networks"
:delay="LONG_TOUCH_DURATION"
:delay-on-touch-only="true"
:touch-start-threshold="10"
@ -65,71 +65,79 @@
drag-class="ui-sortable-dragging"
group="networks"
class="networks"
item-key="uuid"
@change="onNetworkSort"
@choose="onDraggableChoose"
@unchoose="onDraggableUnchoose"
>
<div
v-for="network in $store.state.networks"
:id="'network-' + network.uuid"
:key="network.uuid"
:class="{
collapsed: network.isCollapsed,
'not-connected': !network.status.connected,
'not-secure': !network.status.secure,
}"
class="network"
role="region"
aria-live="polite"
@touchstart="onDraggableTouchStart"
@touchmove="onDraggableTouchMove"
@touchend="onDraggableTouchEnd"
@touchcancel="onDraggableTouchEnd"
>
<NetworkLobby
:network="network"
:is-join-channel-shown="network.isJoinChannelShown"
:active="
$store.state.activeChannel &&
network.channels[0] === $store.state.activeChannel.channel
"
@toggle-join-channel="network.isJoinChannelShown = !network.isJoinChannelShown"
/>
<JoinChannel
v-if="network.isJoinChannelShown"
:network="network"
:channel="network.channels[0]"
@toggle-join-channel="network.isJoinChannelShown = !network.isJoinChannelShown"
/>
<Draggable
draggable=".channel-list-item"
ghost-class="ui-sortable-ghost"
drag-class="ui-sortable-dragging"
:group="network.uuid"
:list="network.channels"
:delay="LONG_TOUCH_DURATION"
:delay-on-touch-only="true"
:touch-start-threshold="10"
class="channels"
@change="onChannelSort"
@choose="onDraggableChoose"
@unchoose="onDraggableUnchoose"
<template v-slot:item="{element: network}">
<div
:id="'network-' + network.uuid"
:key="network.uuid"
:class="{
collapsed: network.isCollapsed,
'not-connected': !network.status.connected,
'not-secure': !network.status.secure,
}"
class="network"
role="region"
aria-live="polite"
@touchstart="onDraggableTouchStart"
@touchmove="onDraggableTouchMove"
@touchend="onDraggableTouchEnd"
@touchcancel="onDraggableTouchEnd"
>
<template v-for="(channel, index) in network.channels">
<Channel
v-if="index > 0"
:key="channel.id"
:channel="channel"
:network="network"
:active="
$store.state.activeChannel &&
channel === $store.state.activeChannel.channel
"
/>
</template>
</Draggable>
</div>
<NetworkLobby
:network="network"
:is-join-channel-shown="network.isJoinChannelShown"
:active="
store.state.activeChannel &&
network.channels[0] === store.state.activeChannel.channel
"
@toggle-join-channel="
network.isJoinChannelShown = !network.isJoinChannelShown
"
/>
<JoinChannel
v-if="network.isJoinChannelShown"
:network="network"
:channel="network.channels[0]"
@toggle-join-channel="
network.isJoinChannelShown = !network.isJoinChannelShown
"
/>
<Draggable
draggable=".channel-list-item"
ghost-class="ui-sortable-ghost"
drag-class="ui-sortable-dragging"
:group="network.uuid"
:list="network.channels"
:delay="LONG_TOUCH_DURATION"
:delay-on-touch-only="true"
:touch-start-threshold="10"
class="channels"
item-key="name"
@change="onChannelSort"
@choose="onDraggableChoose"
@unchoose="onDraggableUnchoose"
>
<template v-slot:item="{element: channel, index}">
<Channel
v-if="index > 0"
:key="channel.id"
:data-item="channel.id"
:channel="channel"
:network="network"
:active="
store.state.activeChannel &&
channel === store.state.activeChannel.channel
"
/>
</template>
</Draggable>
</div>
</template>
</Draggable>
</div>
</template>
@ -195,21 +203,27 @@
}
</style>
<script>
<script lang="ts">
import {computed, watch, defineComponent, nextTick, onBeforeUnmount, onMounted, ref} from "vue";
import Mousetrap from "mousetrap";
import Draggable from "vuedraggable";
import Draggable from "./Draggable.vue";
import {filter as fuzzyFilter} from "fuzzy";
import NetworkLobby from "./NetworkLobby.vue";
import Channel from "./Channel.vue";
import JoinChannel from "./JoinChannel.vue";
import socket from "../js/socket";
import collapseNetwork from "../js/helpers/collapseNetwork";
import collapseNetworkHelper from "../js/helpers/collapseNetwork";
import isIgnoredKeybind from "../js/helpers/isIgnoredKeybind";
import distance from "../js/helpers/distance";
import eventbus from "../js/eventbus";
import {ClientChan, NetChan} from "../js/types";
import {useStore} from "../js/store";
import {switchToChannel} from "../js/router";
import Sortable from "sortablejs";
export default {
export default defineComponent({
name: "NetworkList",
components: {
JoinChannel,
@ -217,120 +231,135 @@ 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;
}
socket.emit("sort", {
type: "networks",
order: this.$store.state.networks.map((n) => n.uuid),
moveItemInArray(store.state.networks, oldIndex, newIndex);
socket.emit("sort:networks", {
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;
}
socket.emit("sort", {
type: "channels",
target: channel.network.uuid,
order: channel.network.channels.map((c) => c.id),
const id = parseInt(unparsedId);
const netChan = store.getters.findChannel(id);
if (!netChan) {
return;
}
moveItemInArray(netChan.network.channels, oldIndex, newIndex);
socket.emit("sort:channel", {
network: 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 +367,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() {
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 +523,54 @@ export default {
currentIndex -= channels.length;
}
this.activeSearchItem = channels[currentIndex];
this.scrollToActive();
},
scrollToActive() {
// Scroll the list if needed after the active class is applied
this.$nextTick(() => {
const el = this.$refs.networklist.querySelector(".channel-list-item.active");
activeSearchItem.value = channels[currentIndex];
scrollToActive();
};
if (el) {
el.scrollIntoView({block: "nearest", inline: "nearest"});
}
});
},
watch(searchText, () => {
setActiveSearchItem();
});
onMounted(() => {
Mousetrap.bind("alt+shift+right", expandNetwork);
Mousetrap.bind("alt+shift+left", collapseNetwork);
Mousetrap.bind("alt+j", toggleSearch);
});
onBeforeUnmount(() => {
Mousetrap.unbind("alt+shift+right");
Mousetrap.unbind("alt+shift+left");
Mousetrap.unbind("alt+j");
});
const networkContainerRef = ref<HTMLDivElement>();
const channelRefs = ref<{[key: string]: HTMLDivElement}>({});
return {
store,
networklist,
searchInput,
searchText,
results,
activeSearchItem,
LONG_TOUCH_DURATION,
activateSearch,
deactivateSearch,
toggleSearch,
setSearchText,
setActiveSearchItem,
scrollToActive,
selectResult,
navigateResults,
onChannelSort,
onNetworkSort,
onDraggableTouchStart,
onDraggableTouchMove,
onDraggableTouchEnd,
onDraggableChoose,
onDraggableUnchoose,
};
},
};
});
</script>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,197 @@
<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"
v-model="old_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"
v-model="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"
v-model="verify_password"
: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,
},
setup() {
const store = useStore();
const passwordErrors = {
missing_fields: "Please fill in all fields",
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 old_password = ref("");
const new_password = ref("");
const verify_password = ref("");
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 data = {
old_password: old_password.value,
new_password: new_password.value,
verify_password: verify_password.value,
};
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,
old_password,
new_password,
verify_password,
};
},
});
</script>

View file

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

View file

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

View file

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

View file

@ -0,0 +1,188 @@
<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>
</div>
</template>
<h2>Browser Notifications</h2>
<div>
<label class="opt">
<input
id="desktopNotifications"
:checked="store.state.settings.desktopNotifications"
:disabled="store.state.desktopNotificationState === 'nohttps'"
type="checkbox"
name="desktopNotifications"
/>
Enable browser notifications<br />
<div v-if="store.state.desktopNotificationState === 'unsupported'" class="error">
<strong>Warning</strong>: Notifications are not supported by your browser.
</div>
<div
v-if="store.state.desktopNotificationState === 'nohttps'"
id="warnBlockedDesktopNotifications"
class="error"
>
<strong>Warning</strong>: Notifications are only supported over HTTPS
connections.
</div>
<div
v-if="store.state.desktopNotificationState === 'blocked'"
id="warnBlockedDesktopNotifications"
class="error"
>
<strong>Warning</strong>: Notifications are blocked by your browser.
</div>
</label>
</div>
<div>
<label class="opt">
<input
:checked="store.state.settings.notification"
type="checkbox"
name="notification"
/>
Enable notification sound
</label>
</div>
<div>
<div class="opt">
<button id="play" @click.prevent="playNotification">Play sound</button>
</div>
</div>
<div>
<label class="opt">
<input
:checked="store.state.settings.notifyAllMessages"
type="checkbox"
name="notifyAllMessages"
/>
Enable notification for all messages
</label>
</div>
<div v-if="!store.state.serverConfiguration?.public">
<label class="opt">
<label for="highlights" class="opt">
Custom highlights
<span
class="tooltipped tooltipped-n tooltipped-no-delay"
aria-label="If a message contains any of these comma-separated
expressions, it will trigger a highlight."
>
<button class="extra-help" />
</span>
</label>
<input
id="highlights"
:value="store.state.settings.highlights"
type="text"
name="highlights"
class="input"
autocomplete="off"
placeholder="Comma-separated, e.g.: word, some more words, anotherword"
/>
</label>
</div>
<div v-if="!store.state.serverConfiguration?.public">
<label class="opt">
<label for="highlightExceptions" class="opt">
Highlight exceptions
<span
class="tooltipped tooltipped-n tooltipped-no-delay"
aria-label="If a message contains any of these comma-separated
expressions, it will not trigger a highlight even if it contains
your nickname or expressions defined in custom highlights."
>
<button class="extra-help" />
</span>
</label>
<input
id="highlightExceptions"
:value="store.state.settings.highlightExceptions"
type="text"
name="highlightExceptions"
class="input"
autocomplete="off"
placeholder="Comma-separated, e.g.: word, some more words, anotherword"
/>
</label>
</div>
</div>
</template>
<script lang="ts">
import {computed, defineComponent} from "vue";
import {useStore} from "../../js/store";
import webpush from "../../js/webpush";
export default defineComponent({
name: "NotificationSettings",
setup() {
const store = useStore();
const isIOS = computed(
() =>
[
"iPad Simulator",
"iPhone Simulator",
"iPod Simulator",
"iPad",
"iPhone",
"iPod",
].includes(navigator.platform) ||
// iPad on iOS 13 detection
(navigator.userAgent.includes("Mac") && "ontouchend" in document)
);
const playNotification = () => {
const pop = new Audio();
pop.src = "audio/pop.wav";
// eslint-disable-next-line
pop.play();
};
const onPushButtonClick = () => {
webpush.togglePushSubscription();
};
return {
isIOS,
store,
playNotification,
onPushButtonClick,
};
},
});
</script>

View file

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

View file

@ -34,172 +34,236 @@
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"
:class="[
'icon',
'help',
{notified: $store.state.serverConfiguration.isUpdateAvailable},
]"
aria-label="Help"
role="tab"
aria-controls="help"
:aria-selected="$route.name === 'Help'"
/></span>
>
<button
:aria-selected="route.name === 'Help'"
:class="[
'icon',
'help',
{notified: store.state.serverConfiguration?.isUpdateAvailable},
{active: isActive},
]"
@click="navigate"
@keypress.enter="navigate"
></button> </router-link
></span>
</footer>
</aside>
</template>
<script>
<script lang="ts">
import {defineComponent, nextTick, 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;
touchStartPos.value = null;
touchCurPos.value = null;
touchStartTime.value = 0;
menuIsMoving.value = false;
void nextTick(() => {
if (sidebar.value) {
sidebar.value.style.transform = "";
}
if (props.overlay) {
props.overlay.style.opacity = "";
}
});
};
this.toggle = (state) => {
this.$store.commit("sidebarOpen", state);
const onTouchStart = (e: TouchEvent) => {
if (!sidebar.value) {
return;
}
touchStartPos.value = touchCurPos.value = e.touches.item(0);
if (e.touches.length !== 1) {
onTouchEnd();
return;
}
const styles = window.getComputedStyle(sidebar.value);
menuWidth.value = parseFloat(styles.width);
menuIsAbsolute.value = styles.position === "absolute";
if (
!store.state.sidebarOpen ||
(touchStartPos.value?.screenX && touchStartPos.value.screenX > menuWidth.value)
) {
touchStartTime.value = Date.now();
document.body.addEventListener("touchmove", onTouchMove, {passive: true});
document.body.addEventListener("touchend", onTouchEnd, {passive: true});
}
};
document.body.addEventListener("touchstart", this.onTouchStart, {passive: true});
onMounted(() => {
document.body.addEventListener("touchstart", onTouchStart, {passive: true});
});
onUnmounted(() => {
document.body.removeEventListener("touchstart", onTouchStart);
});
const isPublic = () => document.body.classList.contains("public");
return {
isDevelopment,
store,
route,
sidebar,
toggle,
onTouchStart,
onTouchMove,
onTouchEnd,
isPublic,
};
},
destroyed() {
document.body.removeEventListener("touchstart", this.onTouchStart, {passive: true});
},
methods: {
isPublic: () => document.body.classList.contains("public"),
},
};
});
</script>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -9,7 +9,7 @@
<h2 class="help-version-title">
<span>About The Lounge</span>
<small>
v{{ $store.state.serverConfiguration.version }} (<router-link
v{{ store.state.serverConfiguration?.version }} (<router-link
id="view-changelog"
to="/changelog"
>release notes</router-link
@ -20,13 +20,13 @@
<div class="about">
<VersionChecker />
<template v-if="$store.state.serverConfiguration.gitCommit">
<template v-if="store.state.serverConfiguration?.gitCommit">
<p>
The Lounge is running from source (<a
:href="`https://github.com/thelounge/thelounge/tree/${$store.state.serverConfiguration.gitCommit}`"
:href="`https://github.com/thelounge/thelounge/tree/${store.state.serverConfiguration?.gitCommit}`"
target="_blank"
rel="noopener"
>commit <code>{{ $store.state.serverConfiguration.gitCommit }}</code></a
>commit <code>{{ store.state.serverConfiguration?.gitCommit }}</code></a
>).
</p>
@ -34,11 +34,11 @@
<li>
Compare
<a
:href="`https://github.com/thelounge/thelounge/compare/${$store.state.serverConfiguration.gitCommit}...master`"
:href="`https://github.com/thelounge/thelounge/compare/${store.state.serverConfiguration?.gitCommit}...master`"
target="_blank"
rel="noopener"
>between
<code>{{ $store.state.serverConfiguration.gitCommit }}</code> and
<code>{{ store.state.serverConfiguration?.gitCommit }}</code> and
<code>master</code></a
>
to see what you are missing
@ -46,12 +46,12 @@
<li>
Compare
<a
:href="`https://github.com/thelounge/thelounge/compare/${$store.state.serverConfiguration.version}...${$store.state.serverConfiguration.gitCommit}`"
:href="`https://github.com/thelounge/thelounge/compare/${store.state.serverConfiguration?.version}...${store.state.serverConfiguration?.gitCommit}`"
target="_blank"
rel="noopener"
>between
<code>{{ $store.state.serverConfiguration.version }}</code> and
<code>{{ $store.state.serverConfiguration.gitCommit }}</code></a
<code>{{ store.state.serverConfiguration?.version }}</code> and
<code>{{ store.state.serverConfiguration?.gitCommit }}</code></a
>
to see your local changes
</li>
@ -179,6 +179,26 @@
</div>
</div>
<div class="help-item">
<div class="subject">
<span v-if="!isApple"><kbd>Alt</kbd> <kbd>Ctrl</kbd> <kbd></kbd></span>
<span v-else><kbd></kbd> <kbd></kbd> <kbd></kbd></span>
</div>
<div class="description">
<p>Switch to the next window with unread messages in the channel list.</p>
</div>
</div>
<div class="help-item">
<div class="subject">
<span v-if="!isApple"><kbd>Alt</kbd> <kbd>Ctrl</kbd> <kbd></kbd></span>
<span v-else><kbd></kbd> <kbd></kbd> <kbd></kbd></span>
</div>
<div class="description">
<p>Switch to the previous window with unread messages in the channel list.</p>
</div>
</div>
<div class="help-item">
<div class="subject">
<span v-if="!isApple"><kbd>Alt</kbd> <kbd>A</kbd></span>
@ -574,22 +594,37 @@
<div class="help-item">
<div class="subject">
<code>/join channel</code>
<code>/join channel [password]</code>
</div>
<div class="description">
<p>Join a channel.</p>
<p>
Join a channel. Password is only needed in protected channels and can
usually be omitted.
</p>
</div>
</div>
<div class="help-item">
<div class="subject">
<code>/kick nick</code>
<code>/kick nick [reason]</code>
</div>
<div class="description">
<p>Kick a user from the current channel.</p>
</div>
</div>
<div class="help-item">
<div class="subject">
<code>/kickban nick [reason]</code>
</div>
<div class="description">
<p>
Kick and ban (<code>+b</code>) a user from the current channel. Unlike
<code>/ban</code>, only nicknames (and not host masks) can be used.
</p>
</div>
</div>
<div class="help-item">
<div class="subject">
<code>/list</code>
@ -633,6 +668,20 @@
</div>
</div>
<div class="help-item">
<div class="subject">
<code>/mute [...channel]</code>
</div>
<div class="description">
<p>
Prevent messages from generating any feedback for a channel. This turns off
the highlight indicator, hides mentions and inhibits push notifications.
Muting a network lobby mutes the entire network. Not specifying any channel
target mutes the current channel. Revert with <code>/unmute</code>.
</p>
</div>
</div>
<div class="help-item">
<div class="subject">
<code>/nick newnick</code>
@ -723,7 +772,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>
@ -768,6 +817,18 @@
</div>
</div>
<div class="help-item">
<div class="subject">
<code>/unmute [...channel]</code>
</div>
<div class="description">
<p>
Un-mutes the given channel(s) or the current channel if no channel is
provided. See <code>/mute</code> for more information.
</p>
</div>
</div>
<div class="help-item">
<div class="subject">
<code>/voice nick [...nick]</code>
@ -791,21 +852,28 @@
</div>
</template>
<script>
<script lang="ts">
import {defineComponent, ref} from "vue";
import {useStore} from "../../js/store";
import SidebarToggle from "../SidebarToggle.vue";
import VersionChecker from "../VersionChecker.vue";
export default {
export default defineComponent({
name: "Help",
components: {
SidebarToggle,
VersionChecker,
},
data() {
setup() {
const store = useStore();
const isApple = navigator.platform.match(/(Mac|iPhone|iPod|iPad)/i) || false;
const isTouch = navigator.maxTouchPoints > 0;
return {
isApple: navigator.platform.match(/(Mac|iPhone|iPod|iPad)/i) || false,
isTouch: navigator.maxTouchPoints > 0,
isApple,
isTouch,
store,
};
},
};
});
</script>

View file

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

View file

@ -3,9 +3,8 @@
<div
id="chat"
:class="{
'colored-nicks': $store.state.settings.coloredNicks,
'time-seconds': $store.state.settings.showSeconds,
'time-12h': $store.state.settings.use12hClock,
'time-seconds': store.state.settings.showSeconds,
'time-12h': store.state.settings.use12hClock,
}"
>
<div
@ -14,12 +13,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 +27,25 @@
@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.messageSearchPendingQuery ||
!store.state.isConnected
"
class="btn"
@click="onShowMoreClick"
>
<span v-if="$store.state.messageSearchInProgress">Loading</span>
<span v-if="store.state.messageSearchPendingQuery">Loading</span>
<span v-else>Show older messages</span>
</button>
</div>
<div
v-if="$store.state.messageSearchInProgress && !offset"
v-if="store.state.messageSearchPendingQuery && !offset"
class="search-status"
>
Searching
@ -55,28 +54,30 @@
No results found.
</div>
<div
v-else
class="messages"
role="log"
aria-live="polite"
aria-relevant="additions"
>
<template v-for="(message, id) in messages">
<div :key="message.id" class="result" @:click="jump(message, id)">
<DateMarker
v-if="shouldDisplayDateMarker(message, id)"
:key="message.date"
:message="message"
/>
<Message
:key="message.id"
:channel="channel"
:network="network"
:message="message"
:data-id="message.id"
/>
</div>
</template>
<div
v-for="(message, id) in messages"
:key="message.id"
class="result"
@click="jump(message, id)"
>
<DateMarker
v-if="shouldDisplayDateMarker(message, id)"
:key="message.id + '-date'"
:message="message"
/>
<Message
:key="message.id"
:channel="channel"
:network="network"
:message="message"
:data-id="message.id"
/>
</div>
</div>
</div>
</div>
@ -91,15 +92,23 @@
}
</style>
<script>
<script lang="ts">
import socket from "../../js/socket";
import eventbus from "../../js/eventbus";
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";
import {SearchQuery} from "../../../shared/types/storage";
export default defineComponent({
name: "SearchResults",
components: {
SidebarToggle,
@ -107,140 +116,206 @@ 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 messages = computed(() => {
const results = store.state.messageSearchResults?.results;
if (!results) {
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 results;
});
const chan = computed(() => {
const chanId = parseInt(String(route.params.id || ""), 10);
return store.getters.findChannel(chanId);
});
const network = computed(() => {
if (!chan.value) {
return null;
}
return this.chan.network;
},
channel() {
if (!this.chan) {
return chan.value.network;
});
const channel = computed(() => {
if (!chan.value) {
return null;
}
return this.chan.channel;
},
},
watch: {
"$route.params.id"() {
this.doSearch();
this.setActiveChannel();
},
"$route.query.q"() {
this.doSearch();
this.setActiveChannel();
},
messages() {
this.moreResultsAvailable = this.messages.length && !(this.messages.length % 100);
return chan.value.channel;
});
if (!this.offset) {
this.jumpToBottom();
} else {
this.$nextTick(() => {
const currentChatHeight = this.$refs.chat.scrollHeight;
this.$refs.chat.scrollTop =
this.oldScrollTop + currentChatHeight - this.oldChatHeight;
});
const setActiveChannel = () => {
if (!chan.value) {
return;
}
},
},
mounted() {
this.setActiveChannel();
this.doSearch();
this.$root.$on("re-search", this.doSearch); // Enable MessageSearchForm to search for the same query again
},
beforeDestroy() {
this.$root.$off("re-search");
},
methods: {
setActiveChannel() {
this.$store.commit("activeChannel", this.chan);
},
closeSearch() {
this.$root.switchToChannel(this.channel);
},
shouldDisplayDateMarker(message, id) {
const previousMessage = this.messages[id - 1];
store.commit("activeChannel", chan.value);
};
const closeSearch = () => {
if (!channel.value) {
return;
}
switchToChannel(channel.value);
};
const shouldDisplayDateMarker = (message: ClientMessage, id: number) => {
const previousMessage = messages.value[id - 1];
if (!previousMessage) {
return true;
}
return new Date(previousMessage.time).getDay() !== new Date(message.time).getDay();
},
doSearch() {
this.offset = 0;
this.$store.commit("messageSearchInProgress", true);
};
if (!this.offset) {
this.$store.commit("messageSearchResults", null); // Only reset if not getting offset
const clearSearchState = () => {
offset.value = 0;
store.commit("messageSearchResults", null);
store.commit("messageSearchPendingQuery", null);
};
const doSearch = () => {
if (!network.value || !channel.value) {
return;
}
socket.emit("search", {
networkUuid: this.network.uuid,
channelName: this.channel.name,
searchTerm: this.$route.query.q,
offset: this.offset,
});
},
onShowMoreClick() {
this.offset += 100;
this.$store.commit("messageSearchInProgress", true);
clearSearchState(); // this is a new search, so we need to clear anything before that
const query: SearchQuery = {
networkUuid: network.value.uuid,
channelName: channel.value.name,
searchTerm: String(route.query.q || ""),
offset: offset.value,
};
store.commit("messageSearchPendingQuery", query);
socket.emit("search", query);
};
this.oldScrollTop = this.$refs.chat.scrollTop;
this.oldChatHeight = this.$refs.chat.scrollHeight;
const onShowMoreClick = () => {
if (!chat.value || !network.value || !channel.value) {
return;
}
socket.emit("search", {
networkUuid: this.network.uuid,
channelName: this.channel.name,
searchTerm: this.$route.query.q,
offset: this.offset + 1,
});
},
jumpToBottom() {
this.$nextTick(() => {
const el = this.$refs.chat;
el.scrollTop = el.scrollHeight;
});
},
jump(message, id) {
offset.value += 100;
oldScrollTop.value = chat.value.scrollTop;
oldChatHeight.value = chat.value.scrollHeight;
const query: SearchQuery = {
networkUuid: network.value.uuid,
channelName: channel.value.name,
searchTerm: String(route.query.q || ""),
offset: offset.value,
};
store.commit("messageSearchPendingQuery", query);
socket.emit("search", query);
};
const jumpToBottom = async () => {
await nextTick();
const el = chat.value;
if (!el) {
return;
}
el.scrollTop = el.scrollHeight;
};
const jump = (message: ClientMessage, id: number) => {
// TODO: Implement jumping to messages!
// This is difficult because it means client will need to handle a potentially nonlinear message set
// (loading IntersectionObserver both before AND after the messages)
this.$router.push({
name: "MessageList",
params: {
id: this.chan.id,
},
query: {
focused: id,
},
});
},
};
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);
clearSearchState();
});
return {
chat,
loadMoreButton,
messages,
moreResultsAvailable,
network,
channel,
route,
offset,
store,
setActiveChannel,
closeSearch,
shouldDisplayDateMarker,
doSearch,
onShowMoreClick,
jumpToBottom,
jump,
};
},
};
});
</script>

View file

@ -3,674 +3,54 @@
<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>
</form>
<div class="container">
<form ref="settingsForm" autocomplete="off" @change="onChange" @submit.prevent>
<router-view></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} 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();
// 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;
}
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();
},
return {
onChange,
};
},
};
});
</script>

View file

@ -19,14 +19,13 @@
<label for="signin-username">Username</label>
<input
id="signin-username"
ref="username"
v-model="username"
class="input"
type="text"
name="username"
autocapitalize="none"
autocorrect="off"
autocomplete="username"
:value="getStoredUser()"
required
autofocus
/>
@ -36,9 +35,8 @@
<RevealPassword v-slot:default="slotProps">
<input
id="signin-password"
ref="password"
v-model="password"
:type="slotProps.isVisible ? 'text' : 'password'"
name="password"
class="input"
autocapitalize="none"
autocorrect="off"
@ -55,51 +53,64 @@
</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(storage.get("user") || "");
const password = ref("");
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,
password: password.value,
};
storage.set("user", values.user);
socket.emit("auth:perform", values);
},
getStoredUser() {
return storage.get("user");
},
};
onMounted(() => {
socket.on("auth:failed", onAuthFailed);
});
onBeforeUnmount(() => {
socket.off("auth:failed", onAuthFailed);
});
return {
inFlight,
errorShown,
username,
password,
onSubmit,
};
},
};
});
</script>

View file

@ -1,6 +1,6 @@
@font-face {
/* We use free solid icons - https://fontawesome.com/icons?s=solid&m=free */
font-family: "FontAwesome";
font-family: FontAwesome;
font-weight: normal;
font-style: normal;
src:

View file

@ -17,7 +17,7 @@
--button-text-color-hover: #fff;
/* Color for sidebar overlay and other things that dim the viewport when something else is on top */
--overlay-bg-color: rgba(0, 0, 0, 0.5);
--overlay-bg-color: rgb(0 0 0 / 50%);
/* Links and link-looking buttons */
--link-color: #50a656;
@ -29,10 +29,10 @@
--window-heading-color: #6c797a;
/* Color of the date marker, text and separator */
--date-marker-color: rgba(0, 107, 59, 0.5);
--date-marker-color: rgb(0 107 59 / 50%);
/* Color of the unread message marker, text and separator */
--unread-marker-color: rgba(231, 76, 60, 0.5);
--unread-marker-color: rgb(231 76 60 / 50%);
/* Background and left-border color of highlight messages */
--highlight-bg-color: #efe8dc;
@ -43,7 +43,7 @@
}
::placeholder {
color: rgba(0, 0, 0, 0.35);
color: rgb(0 0 0 / 35%);
opacity: 1; /* fix opacity in Firefox */
}
@ -168,7 +168,7 @@ pre {
padding: 9.5px;
margin: 0 0 10px;
font-size: 13px;
line-height: 1.42857143;
line-height: 1.4286;
color: #333;
word-break: break-all;
word-wrap: break-word;
@ -187,7 +187,7 @@ kbd {
text-align: center;
text-shadow: 0 1px 0 #fff;
background-color: white;
background-image: linear-gradient(180deg, rgba(0, 0, 0, 0.05), transparent);
background-image: linear-gradient(180deg, rgb(0 0 0 / 5%), transparent);
border: 1px solid #bbb;
border-radius: 4px;
box-shadow: 0 2px 0 #bbb, inset 0 1px 1px #fff, inset 0 -1px 3px #ccc;
@ -229,7 +229,7 @@ p {
.btn:active,
.btn:focus {
outline: 0;
box-shadow: 0 0 0 3px rgba(132, 206, 136, 0.5);
box-shadow: 0 0 0 3px rgb(132 206 136 / 50%);
}
.btn:active {
@ -280,7 +280,8 @@ p {
.only-copy {
font-size: 0;
opacity: 0;
width: 0;
width: 0.01px; /* Must be non-zero to be the first selected character on Firefox */
display: inline-block;
}
/* Icons */
@ -297,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,
@ -304,10 +306,13 @@ p {
#chat .msg[data-type="invite"] .from::before,
#chat .msg[data-type="join"] .from::before,
#chat .msg[data-type="kick"] .from::before,
#chat .msg[data-type="login"] .from::before,
#chat .msg[data-type="logout"] .from::before,
#chat .msg[data-type="part"] .from::before,
#chat .msg[data-type="quit"] .from::before,
#chat .msg[data-type="topic"] .from::before,
#chat .msg[data-type="mode_channel"] .from::before,
#chat .msg[data-type="mode_user"] .from::before,
#chat .msg[data-type="mode"] .from::before,
#chat .msg[data-command="motd"] .from::before,
#chat .msg[data-command="help"] .from::before,
@ -366,6 +371,7 @@ p {
.context-menu-network::before { content: "\f233"; /* https://fontawesome.com/icons/server?style=solid */ }
.context-menu-edit::before { content: "\f303"; /* https://fontawesome.com/icons/pencil-alt?style=solid */ }
.context-menu-clear-history::before { content: "\f1f8"; /* https://fontawesome.com/icons/trash?style=solid */ }
.context-menu-mute::before { content: "\f6a9"; /* https://fontawesome.com/v5.15/icons/volume-mute?style=solid */ }
.channel-list-item .not-secure-icon::before {
content: "\f071"; /* https://fontawesome.com/icons/exclamation-triangle?style=solid */
@ -378,17 +384,23 @@ p {
.context-menu-query::before,
.context-menu-action-query::before,
.channel-list-item[data-type="query"]::before { content: "\f075"; /* https://fontawesome.com/icons/comment?style=solid */ }
.channel-list-item[data-type="query"]::before {
content: "\f075"; /* https://fontawesome.com/icons/comment?style=solid */
}
.context-menu-chan::before,
.channel-list-item[data-type="channel"]::before { content: "\f086"; /* http://fontawesome.io/icon/comments/ */ }
.channel-list-item[data-type="special"]::before { content: "\f03a"; /* http://fontawesome.io/icon/list/ */ }
.channel-list-item.has-draft:not(.active):not([data-type="lobby"])::before { content: "\f304"; /* https://fontawesome.com/icons/pen?style=solid */ }
.channel-list-item.has-draft:not(.active):not([data-type="lobby"])::before {
content: "\f304"; /* https://fontawesome.com/icons/pen?style=solid */
}
#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 */ }
@ -420,6 +432,16 @@ p {
color: #2ecc40;
}
#chat .msg[data-type="login"] .from::before {
content: "\f007"; /* https://fontawesome.com/icons/user?style=solid */
color: #2ecc40;
}
#chat .msg[data-type="logout"] .from::before {
content: "\f007"; /* https://fontawesome.com/icons/user?style=solid */
color: #ff4136;
}
#chat .msg[data-type="part"] .from::before,
#chat .msg[data-type="quit"] .from::before {
content: "\f2f5"; /* https://fontawesome.com/icons/sign-out-alt?style=solid */
@ -434,6 +456,7 @@ p {
}
#chat .msg[data-type="mode_channel"] .from::before,
#chat .msg[data-type="mode_user"] .from::before,
#chat .msg[data-type="mode"] .from::before {
content: "\f05a"; /* http://fontawesome.io/icon/info-circle/ */
color: #2ecc40;
@ -564,6 +587,11 @@ p {
/* End icons */
#app {
height: 100%;
width: 100%;
}
#viewport {
display: flex;
height: 100%;
@ -593,7 +621,6 @@ p {
line-height: 1;
height: 36px;
width: 36px;
margin-top: 6px;
flex-shrink: 0;
}
@ -700,10 +727,10 @@ p {
background on hover (unless active) */
.channel-list-item:hover,
#footer button:hover {
background-color: rgba(48, 62, 74, 0.5); /* #303e4a x 50% alpha */
background-color: rgb(48 62 74 / 50%); /* #303e4a x 50% alpha */
}
/* Darker background and defualt cursor for active channels */
/* Darker background and default cursor for active channels */
#footer button.active,
.channel-list-item.active {
background-color: #303e4a;
@ -799,6 +826,10 @@ background on hover (unless active) */
color: #f1978e;
}
.channel-list-item.is-muted {
opacity: 0.5;
}
.channel-list-item::before {
width: 14px;
margin-right: 12px;
@ -826,7 +857,7 @@ background on hover (unless active) */
}
.channel-list-item .badge {
background: rgba(255, 255, 255, 0.06);
background: rgb(255 255 255 / 6%);
border-radius: 3px;
color: #afb6c0;
font-size: 10px;
@ -1018,7 +1049,7 @@ textarea.input {
}
.window h2 {
border-bottom: 1px solid currentColor;
border-bottom: 1px solid currentcolor;
color: var(--window-heading-color);
font-size: 22px;
margin: 30px 0 10px;
@ -1037,6 +1068,7 @@ textarea.input {
}
.header {
align-items: center;
line-height: 45px;
height: 45px;
padding: 0 6px;
@ -1207,10 +1239,7 @@ textarea.input {
#sidebar .join-form .input {
display: block;
margin-left: auto;
margin-right: auto;
margin-top: 5px;
margin-bottom: 5px;
margin: 5px auto;
}
#sidebar .join-form .btn {
@ -1259,7 +1288,7 @@ textarea.input {
border: 2px solid var(--button-color);
text-align: center;
transition: background 0.2s, color 0.2s;
box-shadow: 0 6px 10px 0 rgba(0, 0, 0, 0.15);
box-shadow: 0 6px 10px 0 rgb(0 0 0 / 15%);
}
.scroll-down:hover .scroll-down-arrow {
@ -1409,42 +1438,42 @@ textarea.input {
/* Nicknames */
#chat .user {
.user {
color: #50a656;
}
#chat.colored-nicks .user.color-1 { color: #107ead; }
#chat.colored-nicks .user.color-2 { color: #a86500; }
#chat.colored-nicks .user.color-3 { color: #008a3c; }
#chat.colored-nicks .user.color-4 { color: #e00096; }
#chat.colored-nicks .user.color-5 { color: #f0000c; }
#chat.colored-nicks .user.color-6 { color: #000094; }
#chat.colored-nicks .user.color-7 { color: #006441; }
#chat.colored-nicks .user.color-8 { color: #00566e; }
#chat.colored-nicks .user.color-9 { color: #e6006b; }
#chat.colored-nicks .user.color-10 { color: #0d8766; }
#chat.colored-nicks .user.color-11 { color: #006b3b; }
#chat.colored-nicks .user.color-12 { color: #00857e; }
#chat.colored-nicks .user.color-13 { color: #00465b; }
#chat.colored-nicks .user.color-14 { color: #eb005a; }
#chat.colored-nicks .user.color-15 { color: #e62600; }
#chat.colored-nicks .user.color-16 { color: #0f8546; }
#chat.colored-nicks .user.color-17 { color: #e60067; }
#chat.colored-nicks .user.color-18 { color: #eb002b; }
#chat.colored-nicks .user.color-19 { color: #eb003f; }
#chat.colored-nicks .user.color-20 { color: #007a56; }
#chat.colored-nicks .user.color-21 { color: #095092; }
#chat.colored-nicks .user.color-22 { color: #000bde; }
#chat.colored-nicks .user.color-23 { color: #008577; }
#chat.colored-nicks .user.color-24 { color: #00367d; }
#chat.colored-nicks .user.color-25 { color: #007e9e; }
#chat.colored-nicks .user.color-26 { color: #006119; }
#chat.colored-nicks .user.color-27 { color: #007ea8; }
#chat.colored-nicks .user.color-28 { color: #3c8500; }
#chat.colored-nicks .user.color-29 { color: #e6007e; }
#chat.colored-nicks .user.color-30 { color: #c75300; }
#chat.colored-nicks .user.color-31 { color: #eb0400; }
#chat.colored-nicks .user.color-32 { color: #e60082; }
.user.color-1 { color: #107ead; }
.user.color-2 { color: #a86500; }
.user.color-3 { color: #008a3c; }
.user.color-4 { color: #e00096; }
.user.color-5 { color: #f0000c; }
.user.color-6 { color: #000094; }
.user.color-7 { color: #006441; }
.user.color-8 { color: #00566e; }
.user.color-9 { color: #e6006b; }
.user.color-10 { color: #0d8766; }
.user.color-11 { color: #006b3b; }
.user.color-12 { color: #00857e; }
.user.color-13 { color: #00465b; }
.user.color-14 { color: #eb005a; }
.user.color-15 { color: #e62600; }
.user.color-16 { color: #0f8546; }
.user.color-17 { color: #e60067; }
.user.color-18 { color: #eb002b; }
.user.color-19 { color: #eb003f; }
.user.color-20 { color: #007a56; }
.user.color-21 { color: #095092; }
.user.color-22 { color: #000bde; }
.user.color-23 { color: #008577; }
.user.color-24 { color: #00367d; }
.user.color-25 { color: #007e9e; }
.user.color-26 { color: #006119; }
.user.color-27 { color: #007ea8; }
.user.color-28 { color: #3c8500; }
.user.color-29 { color: #e6007e; }
.user.color-30 { color: #c75300; }
.user.color-31 { color: #eb0400; }
.user.color-32 { color: #e60082; }
#chat .self .content {
color: var(--body-color-muted);
@ -1589,7 +1618,7 @@ textarea.input {
margin: 0;
margin-top: 6px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
box-shadow: 0 1px 3px rgb(0 0 0 / 20%);
display: inline-flex !important;
align-items: flex-start;
white-space: normal;
@ -1899,7 +1928,6 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
width: 100%;
}
#settings .apple-push-unsupported,
#settings .settings-sync-panel {
padding: 10px;
margin-bottom: 16px;
@ -1926,12 +1954,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
#settings .settings-sync-panel .btn:active,
#settings .settings-sync-panel .btn:focus {
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.5);
}
#settings .apple-push-unsupported a {
color: inherit;
text-decoration: underline;
box-shadow: 0 0 0 3px rgb(0 123 255 / 50%);
}
#settings .opt {
@ -2081,7 +2104,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
.window#changelog h3 {
font-size: 20px;
border-bottom: 1px solid currentColor;
border-bottom: 1px solid currentcolor;
color: var(--window-heading-color);
margin: 30px 0 10px;
padding-bottom: 7px;
@ -2280,8 +2303,8 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
min-width: 180px;
font-size: 14px;
background-color: #fff;
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.15);
border: 1px solid rgba(0, 0, 0, 0.15);
box-shadow: 0 3px 12px rgb(0 0 0 / 15%);
border: 1px solid rgb(0 0 0 / 15%);
border-radius: 5px;
outline: 0;
}
@ -2289,7 +2312,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
.context-menu-divider {
height: 1px;
margin: 6px 0;
background-color: rgba(0, 0, 0, 0.1);
background-color: rgb(0 0 0 / 10%);
}
.context-menu-item,
@ -2310,7 +2333,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
.textcomplete-item:hover,
.textcomplete-menu .active,
#chat .userlist .user.active {
background-color: rgba(0, 0, 0, 0.1);
background-color: rgb(0 0 0 / 10%);
}
.context-menu-item::before,
@ -2651,7 +2674,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
#viewport.menu-open #sidebar,
#viewport.menu-dragging #sidebar {
box-shadow: 0 0 25px 0 rgba(0, 0, 0, 0.5);
box-shadow: 0 0 25px 0 rgb(0 0 0 / 50%);
}
#viewport.menu-open #sidebar-overlay,
@ -2768,20 +2791,20 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
::-webkit-scrollbar {
width: 8px;
background-color: rgba(0, 0, 0, 0);
background-color: rgb(0 0 0 / 0%);
}
::-webkit-scrollbar:hover {
background-color: rgba(0, 0, 0, 0.09);
background-color: rgb(0 0 0 / 9%);
}
::-webkit-scrollbar-thumb:vertical {
background: rgba(0, 0, 0, 0.5);
background: rgb(0 0 0 / 50%);
border-radius: 100px;
}
::-webkit-scrollbar-thumb:vertical:active {
background: rgba(0, 0, 0, 0.6);
background: rgb(0 0 0 / 60%);
}
/* Image viewer and drag-and-drop overlay */
@ -2823,7 +2846,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
#confirm-dialog-overlay,
#image-viewer {
background: rgba(0, 0, 0, 0.9);
background: rgb(0 0 0 / 90%);
}
#image-viewer .close-btn,
@ -2886,14 +2909,12 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
background-position: 0 0, 10px 10px;
background-size: 20px 20px;
background-image:
linear-gradient(45deg, #eee 25%, rgba(0, 0, 0, 0) 25%, rgba(0, 0, 0, 0) 75%, #eee 75%, #eee 100%),
linear-gradient(45deg, #eee 25%, rgb(0 0 0 / 0%) 25%, rgb(0 0 0 / 0%) 75%, #eee 75%, #eee 100%),
linear-gradient(45deg, #eee 25%, #fff 25%, #fff 75%, #eee 75%, #eee 100%);
}
/* 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,
@ -2901,11 +2922,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%;

View file

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

View file

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

View file

@ -1,90 +1,92 @@
"use strict";
const constants = require("./constants");
import constants from "./constants";
import Mousetrap from "mousetrap";
import {Textcomplete, Textarea} from "textcomplete";
import {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";
import {ChanType} from "../../shared/types/chan";
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
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();
@ -292,7 +292,7 @@ function rawNicks() {
const otherUser = store.state.activeChannel.channel.name;
// If this is a query, add their name to autocomplete
if (me !== otherUser && store.state.activeChannel.channel.type === "query") {
if (me !== otherUser && store.state.activeChannel.channel.type === ChanType.QUERY) {
return [otherUser, me];
}
@ -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,18 +321,20 @@ function getCommands() {
return cmds;
}
function completeCommands(word) {
function completeCommands(word: string) {
const commands = getCommands();
return fuzzyGrep(word, commands);
}
function completeChans(word) {
const words = [];
function completeChans(word: string) {
const words: string[] = [];
for (const channel of store.state.activeChannel.network.channels) {
// Push all channels that start with the same CHANTYPE
if (channel.type === "channel" && channel.name[0] === word[0]) {
words.push(channel.name);
if (store.state.activeChannel) {
for (const channel of store.state.activeChannel.network.channels) {
// Push all channels that start with the same CHANTYPE
if (channel.type === ChanType.CHANNEL && channel.name[0] === word[0]) {
words.push(channel.name);
}
}
}

36
client/js/chan.ts Normal file
View file

@ -0,0 +1,36 @@
import {ClientChan, ClientMessage} from "./types";
import {SharedNetworkChan} from "../../shared/types/network";
import {SharedMsg, MessageType} from "../../shared/types/msg";
import {ChanType} from "../../shared/types/chan";
export function toClientChan(shared: SharedNetworkChan): ClientChan {
const history: string[] = [""].concat(
shared.messages
.filter((m) => m.self && m.text && m.type === MessageType.MESSAGE)
// TS is too stupid to see the nil guard on filter... so we monkey patch it
.map((m): string => (m.text ? m.text : ""))
.reverse()
.slice(0, 99)
);
// filter the unused vars
const {messages, totalMessages: _, ...props} = shared;
const channel: ClientChan = {
...props,
editTopic: false,
pendingMessage: "",
inputHistoryPosition: 0,
historyLoading: false,
scrolledToBottom: true,
users: [],
usersOutdated: shared.type === ChanType.CHANNEL ? true : false,
moreHistoryAvailable: shared.totalMessages > shared.messages.length,
inputHistory: history,
messages: sharedMsgToClientMsg(messages),
};
return channel;
}
function sharedMsgToClientMsg(shared: SharedMsg[]): ClientMessage[] {
// TODO: this is a stub for now, we will want to populate client specific stuff here
return shared;
}

View file

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

View file

@ -1,15 +1,17 @@
"use strict";
import socket from "../socket";
import store from "../store";
import {store} from "../store";
function input() {
const messageIds = [];
export function input(): boolean {
if (!store.state.activeChannel) {
return false;
}
const messageIds: number[] = [];
for (const message of store.state.activeChannel.channel.messages) {
let toggled = false;
for (const preview of message.previews) {
for (const preview of message.previews || []) {
if (preview.shown) {
preview.shown = false;
toggled = true;
@ -22,9 +24,9 @@ function input() {
}
// Tell the server we're toggling so it remembers at page reload
if (messageIds.length > 0) {
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,
});
@ -32,5 +34,3 @@ function input() {
return true;
}
export default {input};

View file

@ -1,15 +1,17 @@
"use strict";
import socket from "../socket";
import store from "../store";
import {store} from "../store";
function input() {
const messageIds = [];
export function input(): boolean {
if (!store.state.activeChannel) {
return false;
}
const messageIds: number[] = [];
for (const message of store.state.activeChannel.channel.messages) {
let toggled = false;
for (const preview of message.previews) {
for (const preview of message.previews || []) {
if (!preview.shown) {
preview.shown = true;
toggled = true;
@ -22,9 +24,9 @@ function input() {
}
// Tell the server we're toggling so it remembers at page reload
if (messageIds.length > 0) {
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,
});
@ -32,5 +34,3 @@ function input() {
return true;
}
export default {input};

View file

@ -1,21 +0,0 @@
"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$/);
export default commands.keys().reduce((acc, path) => {
const command = path.substring(2, path.length - 3);
if (command === "index") {
return acc;
}
acc[command] = commands(path).default;
return acc;
}, {});

View file

@ -0,0 +1,11 @@
import {input as collapse} from "./collapse";
import {input as expand} from "./expand";
import {input as join} from "./join";
import {input as search} from "./search";
export const commands = {
collapse: collapse,
expand: expand,
join: join,
search: search,
};

View file

@ -1,15 +1,14 @@
"use strict";
import socket from "../socket";
import store from "../store";
import {store} from "../store";
import {switchToChannel} from "../router";
import {ChanType} from "../../../shared/types/chan";
function input(args) {
export function input(args: string[]): boolean {
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 +26,17 @@ function input(args) {
if (chan) {
switchToChannel(chan);
} else {
socket.emit("input", {
text: `/join ${channels} ${args.length > 1 ? args[1] : ""}`,
target: store.state.activeChannel.channel.id,
});
if (store.state.activeChannel) {
socket.emit("input", {
text: `/join ${channels} ${args.length > 1 ? args[1] : ""}`,
target: store.state.activeChannel.channel.id,
});
}
return true;
}
}
} else if (store.state.activeChannel.channel.type === "channel") {
} else if (store.state.activeChannel?.channel.type === ChanType.CHANNEL) {
// If `/join` command is used without any arguments, re-join current channel
socket.emit("input", {
target: store.state.activeChannel.channel.id,
@ -44,6 +45,6 @@ function input(args) {
return true;
}
}
export default {input};
return false;
}

View file

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

View file

@ -0,0 +1,25 @@
import {store} from "../store";
import {router} from "../router";
export function input(args: string[]): boolean {
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;
}

View file

@ -1,5 +1,3 @@
"use strict";
const colorCodeMap = [
["00", "White"],
["01", "Black"],
@ -19,8 +17,6 @@ const colorCodeMap = [
["15", "Light Grey"],
];
const condensedTypes = new Set(["chghost", "join", "part", "quit", "nick", "kick", "mode"]);
const timeFormats = {
msgDefault: "HH:mm",
msgWithSeconds: "HH:mm:ss",
@ -28,11 +24,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: [],
condensedTypes,
commands: [] as string[],
timeFormats,
// Same value as media query in CSS that forces sidebars to become overlays
mobileViewportPixels: 768,

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