Compare commits

...

1319 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
Max Leiter 54d1be6b29
v4.3.0-rc.1 2021-11-17 20:28:23 -08:00
Max Leiter 1199183157
Add changelog entry for v4.3.0-rc.1 2021-11-17 20:27:59 -08:00
Max Leiter 40a5ee70b6
Disable /search and hide help item if searching is disabled 2021-11-17 20:23:51 -08:00
Max Leiter 3cec329e3b
Merge branch 'master' into fix-search-query 2021-11-15 12:42:56 -08:00
Max Leiter 25d493453e
Merge pull request #4356 from thelounge/maxleiter/rmNode10
Bump required node version to 12.x and add 16.x builds
2021-11-04 17:11:31 -07:00
Max Leiter f3af454c9e
Add Node 16 LTS tests and bump mac/windows tests 2021-11-04 16:59:38 -07:00
Max Leiter 186f8f68cd
Merge pull request #4351 from brunnre8/glob
Allow wildcards in hostmask
2021-11-04 14:36:27 -07:00
Max Leiter 59280cfdfd
Merge pull request #4329 from brunnre8/plugins
Add more plugin functionality
2021-11-04 14:12:02 -07:00
Reto Brunner 67503efd21 Allow wildcards in hostmask
According to https://modern.ircdocs.horse/#wildcard-expressions
masks should support "*" and "?" wildcards.
Within TL this only impacts the /ignore functionality.

The reasoning for doing this is to ignore say GuestNNNN!*@* with
guest*!*@* and be done with it if someone spams a gateway.
2021-11-04 20:16:20 +01:00
Max Leiter 7ba977d56a
Bump required node version to 12.x
Node 10 was deprecated in April 2021.
2021-11-04 02:36:06 -07:00
Max Leiter 2a901b3475
v4.3.0-pre.6 2021-11-04 02:06:21 -07:00
Max Leiter 2777cc2db9
Add changelog entry for v4.3.0-pre.6 2021-11-04 02:06:09 -07:00
Max Leiter 979dfaf3eb
v4.3.0-pre.5 2021-11-03 15:52:46 -07:00
Max Leiter 9592563a27
Add changelog entry for v4.3.0-pre.5 2021-11-03 15:52:37 -07:00
Max Leiter 0381cd11bf
Merge pull request #4345 from itsjohncs/small-db-cleanups
Small cleanup of messageStorage/sqlite.
2021-11-03 15:45:48 -07:00
Max Leiter b5e99c0489
Merge pull request #4352 from itsjohncs/very-rounded-search
Prevent round and white search styling in iOS 15.
2021-11-03 15:45:33 -07:00
itsjohncs ea619f5463 Prevent round and white search styling in iOS 15. 2021-11-03 01:50:11 -07:00
Max Leiter 3cab39c59b
Merge pull request #4340 from itsjohncs/long-channel-names
Improve responsiveness of channel name and topic.
2021-11-02 13:18:39 -07:00
Max Leiter fd730eeeb1
Merge pull request #4344 from brunnre8/csp
Force CSP headers for all requests
2021-11-02 12:51:39 -07:00
Max Leiter a8d438261a
Merge pull request #4332 from itsjohncs/android-context-menu
Enable Android's context menus in network list.
2021-11-02 11:57:24 -07:00
Max Leiter 3bb8d2f4b8
Merge pull request #4348 from thelounge/renovate/linkify-it-3.x
Update dependency linkify-it to v3.0.3
2021-11-01 17:56:00 -07:00
Max Leiter 80e0e0fd16
Merge pull request #4349 from thelounge/renovate/mime-types-2.x
Update dependency mime-types to v2.1.33
2021-11-01 17:55:50 -07:00
Renovate Bot 3da5e8e8ca
Update dependency mime-types to v2.1.33 2021-11-01 00:21:57 +00:00
Renovate Bot 411ce5d2f8
Update dependency linkify-it to v3.0.3 2021-11-01 00:21:41 +00:00
itsjohncs 602de668ee Use patched Sortable JS from itsjohncs/Sortable.
This is a (hopefully) temporary solution while we wait for SortableJS
to merge in SortableJS/Sortable#2095 and make a release.
2021-10-30 02:06:35 -07:00
itsjohncs 393d4fe591 Enable Android's context menus in network list.
After #4326 Android users could no longer long-touch to bring up the
context menu for channels in the network list. Now they can again.
2021-10-30 02:06:35 -07:00
Max Leiter a3a9a2cdd9
Merge pull request #4342 from deejayy/password-reveal-icon
Move font assignment of password reveal icon
2021-10-28 09:53:36 -07:00
itsjohncs 044cd2403b Small cleanup of messageStorage/sqlite.
* Extend test coverage to the `search` function.
* Test sort order of messages from `getMessages` and `search`
* Move reversal of `search` results from Vue to messageStorage.
* Remove unnecessary uses of `sqlite.serialize` in tests.
* Return promises from test functions where possible.
2021-10-28 00:48:11 -07:00
Reto Brunner 544146d9aa Force CSP header for all requests
Currently styles / plugins were not actually under the CSP
header protection.
There's no real reason to not have them for all requests, so
add them as a root middleware.
2021-10-26 22:20:06 +02:00
Max Leiter 97f3800785
Merge pull request #4343 from hom3chuk/master
bump vulnerable ua-parser-js version
2021-10-26 12:50:46 -07:00
Evgeniy Chekan 8ab486ef0f bump vulnerable ua-parser-js version 2021-10-25 21:09:02 +03:00
deejayy cf18d04f06 Move font assignment of password reveal icon 2021-10-25 19:39:59 +02:00
Max Leiter 5d7e62ed67
Merge pull request #4341 from Nachtalb/na/fix-proxy-password
* Fix authenticated proxy
* Save the proxy password so you don't have to reenter it after restart
2021-10-24 14:06:53 -07:00
Nachtalb 206d554ce1
Save the proxy password so you don't have to reenter it after restart 2021-10-24 21:27:43 +02:00
Nachtalb 578b1947e2
Fix authenticated proxy 2021-10-23 01:11:54 +02:00
itsjohncs 56d4a6afde Improve responsiveness of channel name and topic.
This commit makes two changes:

1. Long channel names are truncated.
2. Topics cannot be shrinked into non-existence.
2021-10-22 13:57:13 -07:00
Reto 3ba7fb6de4
Prevent autocomplete for highlight settings (#4337)
Chrome seems to somewhat often auto fill the text input of the
highlight exception list with my username as the next field that
follows is of type password.
Try to work around that by telling chrome not to autofill either of
those.

Do note that this is only a hint... The broser vendors apply some
$magic heuristics and if they trigger they ignore the hint.
2021-10-20 18:34:31 -07:00
John Sullivan 21c6abdd1d
Clarify description of prefetchMaxSearchSize. (#4338)
This is a comments only change that fixes two problems:

1. The previous comment described it as limiting request size, but it
   instead limits the response size.
2. Previously it was unclear _why_ this size was significant. It wasn't
   obvious to me that the entire response would be stored in memory.
2021-10-18 23:20:11 -07:00
John Sullivan 80acbc7c06
Fix sporadic rounding on message search bar. (#4333)
This is fixed in the same way as #4328.
2021-10-17 22:11:08 -07:00
Reto Brunner 1e896a9672 plugins: prefix logger with the plugin name 2021-10-13 23:39:32 +02:00
John Sullivan 5d76ed888c
Clean up global listener in Sidebar component. (#4331)
Every time the component was mounted it would add another listener.
Since old listeners would often error this could cause a lot of log
spam, particularly when using the hotloader on a mobile device.
2021-10-13 13:19:34 -07:00
John Sullivan 2b634a6ba6
Use SortableJS 1.14.0. (#4330)
This should be reverted after SortableJS/Vue.Draggable#1085 is merged
and a new release of Vue.Draggable becomes available.
2021-10-13 13:18:03 -07:00
William Boman 2693db4274
client/Mentions: change button copy to "Dismiss all" (#4322)
* client/Mentions: change button copy to "Dismiss all"

* s/hide/dismiss/g
2021-10-12 15:56:39 -07:00
Max Leiter 1d33e0195a
Merge pull request #4324 from itsjohncs/gestures-next-channel
Two-finger swipe now switches windows (#3901)
2021-10-12 15:53:54 -07:00
Max Leiter fcffab1259
Merge pull request #4326 from itsjohncs/mobile-channel-list-sorting
Allow network list reordering via touch.
2021-10-12 15:53:18 -07:00
Reto Brunner 02ccbc1f69 plugins: expose persistant data dir
Plugins need to be able to store persistant files, say settings or
databases or similar things.
Expose a standard location that gets created when the path is
accessed.
2021-10-12 23:24:06 +02:00
Reto Brunner bb4ab4f168 plugins: add Logger interface
Plugins need to be able to log messasages, say for errors.
2021-10-12 23:24:06 +02:00
John Sullivan 8a57f90b65
Prevent sporadic rounding of search input on iOS. (#4328) 2021-10-11 10:23:20 -07:00
Noah van der Aa 9a0ba1da6c
Add keyboard shortcut for help screen (#4315)
* Add keyboard shortcut for help screen

* Make escape key go back to the previous screen

* Use key instead of which

* Use router for navigating back

* Use alt instead of cmd/ctrl
2021-10-10 20:48:28 -07:00
itsjohncs 5c614785bf Suppress iOS long touch behavior in network list.
When a user long touches on iOS, they will select the nearest
selectable text. This causes a distracting visual bug when reordering
the network list (which also uses a long press).
2021-10-10 17:08:17 -07:00
itsjohncs a48f449c59 Allow network list reordering via touch.
Users can now long touch and drag a channel or network to change its
ordering in the sidebar.
2021-10-10 14:40:08 -07:00
John Sullivan 2ab671664e
Vertically center topic editing input in Safari. (#4325) 2021-10-09 22:23:58 -07:00
itsjohncs 91a0815bb5 Add Gestures section to help window.
This documents the two gestures that The Lounge currently supports.

The section is only visible if your device supports touch.
2021-10-07 13:39:30 -07:00
itsjohncs ebe39b26dc Two-finger swipe now switches windows (#3901)
The Alt+Up and Alt+Down keybindings on Desktop did not have an
equivalent for Mobile users. Now a two-finger swipe left on a
touchscreen is equivalent to Alt+Up (similarly swipe right is
equivalent to Alt+Down).
2021-10-07 13:39:19 -07:00
renovate[bot] 7b28d3c0f8
Update dependency web-push to v3.4.5 (#4320)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-10-02 12:03:13 -07:00
renovate[bot] 324f3aa30f
Update dependency webpack-hot-middleware to v2.25.1 (#4321)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-10-02 12:02:46 -07:00
renovate[bot] e9f0313892
Update dependency ua-parser-js to v0.7.28 (#4319)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-09-30 17:25:05 -07:00
renovate[bot] 969d3e4ec1
Update dependency socket.io-client to v3.1.3 (#4318)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-09-30 17:24:54 -07:00
Taavi Väänänen 7873847a7e
Do not condense single messages (#4313) 2021-09-29 12:33:40 -07:00
Max Leiter cc0dc6266e
Update dependencies (#4312) 2021-09-15 10:12:19 -07:00
renovate[bot] 535ac7ca39
Update dependency stylelint to v13.13.1 (#4307)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-09-02 14:04:21 -07:00
renovate[bot] c8cdadeb02
Update dependency pretty-quick to v3.1.1 (#4304)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-09-02 14:03:16 -07:00
Max Leiter beb5530c65
Revert "Support animated webp images" (#4287)
This reverts pull/4186.
2021-08-31 12:27:43 -07:00
Reto 8fcd079204
Properly track user modes for context menu (#4267)
* properly track user modes for context menu

The RPL_ISUPPORT response contains a PREFIX element, which not only tracks the
prefix chars ("@", "+" etc) but also their corresponding mode chars (+O, +v)
This commit changes the context menu to not rely on a hardcoded list but rather
user the one given in the prefix response by the server.

Co-authored-by: Max Leiter <maxwell.leiter@gmail.com>
2021-07-21 00:30:07 -07:00
JeDaYoshi 0a6c33af57
Create a message for search disabled error 2021-07-07 23:00:44 +00:00
JeDaYoshi 162b42d9b0
Apply changes to getCommands/search 2021-07-07 22:12:28 +00:00
Max Leiter 03d38812e3
Merge pull request #4275 from hellomouse/isupport-modes
Optimise modes based on ISUPPORT
2021-07-06 17:09:31 -07:00
JeDaYoshi 35fcacb767
Add firstCommand and do further checks on mode tests 2021-07-06 18:15:37 +00:00
JeDaYoshi d96704835a
Send all modes in case of no ISUPPORT 2021-07-06 15:48:01 +00:00
JeDaYoshi 0d839c501e
Optimise commands processing 2021-07-06 15:43:02 +00:00
Max Leiter 24316fc304
Merge pull request #4281 from thelounge/updateDependencies
Update dependencies
2021-07-06 01:40:13 -07:00
Max Leiter 11ba27d809
Update emoji map 2021-07-06 01:31:24 -07:00
Max Leiter a59c5d65fb
Update dependencies 2021-07-06 01:29:53 -07:00
Max Leiter 7fdd363ee8
Merge pull request #4205 from Nachtalb/ne/fix-input-history-scroll-behaviour
Only scroll history when cursor is on first or last row
2021-07-06 01:27:06 -07:00
Max Leiter 75cf4445c4
Merge pull request #4277 from hellomouse/server-privmsg-fix
Fix nick-less messages from servers
2021-07-06 01:02:18 -07:00
Max Leiter 18b003db9c
Merge pull request #4280 from hellomouse/restrict-notifs-to-https
Add warning for HTTPS requirement on notifications
2021-07-06 00:58:50 -07:00
JeDaYoshi 372d74db69
Add warning for HTTPS requirement on notifications 2021-07-04 20:22:49 +00:00
JeDaYoshi bbda392c3d
Move clientCommands to client
This approach automatically imports the command names.
2021-07-04 02:15:33 +00:00
JeDaYoshi 23f6886cc1
Add test for ISUPPORT-less networks on /mode shorthands 2021-07-04 01:01:45 +00:00
JeDaYoshi 521426bb05
Add test for /search in getCommands 2021-07-04 00:46:36 +00:00
JeDaYoshi 69c37a535b
Only add /search when there's a message provider 2021-07-04 00:31:43 +00:00
Max Leiter 98e8640932
Merge pull request #4279 from hellomouse/fix-user-list
Fix userlist's wrong position on mobile devices
2021-07-03 17:04:09 -07:00
JeDaYoshi 998f8d2beb
Fix userlist's wrong position on mobile devices 2021-07-03 23:50:51 +00:00
JeDaYoshi 058b3155d0
Display error when /search is not enabled
Fixes thelounge/thelounge#4273
2021-07-03 22:53:45 +00:00
JeDaYoshi e0e12c1960
Fix tests for mode shorthand commands 2021-07-03 21:20:28 +00:00
JeDaYoshi 16177eb9f4
Move server nick code to handleMessage 2021-07-03 21:06:16 +00:00
Max Leiter 5e0a12b124
Merge pull request #4274 from hellomouse/add-umode
Add /umode support
2021-07-03 11:48:38 -07:00
JeDaYoshi 6439afd5c6
Fix nick-less PRIVMSGs from servers 2021-07-03 15:27:08 +00:00
JeDaYoshi 4dacaa46f3
Optimise modes based on ISUPPORT
This will see the maximum allowed of modes that are allowed at once as sent in RPL_ISUPPORT
and will send multiple batches while using /op, /voice, etc.

This also fixes a minor issue where it would try sending an empty voice if it had an extra space on arguments
(such as using '/voice  ')
2021-07-03 03:50:22 +00:00
JeDaYoshi 426841e6b7
Add /umode support 2021-07-03 02:28:21 +00:00
Max Leiter 22801a629e
v4.3.0-pre.4 2021-07-01 12:20:56 -07:00
Max Leiter 47b151ab51
Add changelog entry for v4.3.0-pre.4 2021-07-01 12:20:41 -07:00
Max Leiter d05cf5fe62
Fix linter warnings for aria-label placement 2021-07-01 12:14:02 -07:00
Max Leiter 3e4b22255d
Merge pull request #4201 from thelounge/maxleiter/accessiblityImprovements
Initial accessibility improvements
2021-07-01 12:05:22 -07:00
Max Leiter cc97d91ef8
v4.3.0-pre.3 2021-06-29 23:31:44 -07:00
Max Leiter c5e18e3cdd Add changelog entry for v4.3.0-pre.3 2021-06-29 23:27:16 -07:00
Max Leiter 79c57ebf38
Merge pull request #4211 from Mstrodl/feature/socks-support
Add support for SOCKS (closes #1375)
2021-06-29 22:54:17 -07:00
Max Leiter d106889127
Merge branch 'master' into feature/socks-support 2021-06-29 22:48:00 -07:00
Max Leiter b33fd78ed7
Merge pull request #4266 from thelounge/maxleiter/bump-ircfw
Bump irc-framework to 4.11
2021-06-28 23:33:13 -07:00
Max Leiter bec25f6243 Bump irc-framework to 4.11 2021-06-28 23:22:38 -07:00
Max Leiter 646a98270a
Merge pull request #4265 from thelounge/maxleiter/dependencies
Update dependencies
2021-06-28 21:55:11 -07:00
Max Leiter 5a7781eabc
Merge pull request #4258 from bl1nk/recent-mentions-keybind
Toggle recent mentions popup with alt+m
2021-06-28 21:54:46 -07:00
Max Leiter cbe81968ee Update dependencies
Supersedes #4247, #4263, #4248
2021-06-28 21:50:36 -07:00
Max Leiter a42a1fc6a2
Merge pull request #4264 from BradleyShaw/differentiate-wallops
Differentiate WALLOPS from NOTICE
2021-06-28 21:48:05 -07:00
Bradley Shaw a2d23810bf
Differentiate WALLOPS from NOTICE 2021-06-22 11:50:22 +01:00
Max Leiter aa310fe877
Merge pull request #4260 from BradleyShaw/snotice-channel
Display server-originated notices to channels in the channel window
2021-06-22 00:30:42 -07:00
Max Leiter a046bfe8d1
Merge pull request #4262 from thelounge/maxleiter/fixConfigOverrides
Fix not overriding config options with -c
2021-06-22 00:12:53 -07:00
Max Leiter 6b852d14c8
Fix not overriding config options with -c 2021-06-22 00:00:41 -07:00
Max Leiter 5a9f3c5f70
Aria label for userlist 2021-06-21 22:26:43 -07:00
Max Leiter f23cc0712c
Aria-label improvements for chatuserlist/networklist 2021-06-21 22:21:36 -07:00
Max Leiter 7107372a6f
Fix channel alt text in user list to mention type 2021-06-21 22:14:00 -07:00
Max Leiter 867fff33c0
Adjust chat layout so messages directly above input 2021-06-21 21:55:54 -07:00
Max Leiter e5a6554c9a
Merge branch 'master' of github.com:thelounge/thelounge into maxleiter/accessiblityImprovements 2021-06-21 21:48:30 -07:00
Bradley Shaw 38c0c343c3
Send server->channel notices to the relevant channel 2021-06-19 19:49:04 +01:00
Max Leiter 53b7c46e69
Merge pull request #4259 from thelounge/maxleiter/revertLoadMoreBtn
Revert "Fix load more button hidden behind search form"
2021-06-15 11:27:11 -07:00
Mary Strodl e7a8476cfe
NetworkForm: lint 2021-06-15 13:55:54 -04:00
Mary Strodl a3f0314f6b
NetworkForm: only show proxy options if enabled 2021-06-15 13:52:39 -04:00
Max Leiter 3fdc42350e Revert "Fix load more button hidden behind search form"
This reverts commit 115d970604.
2021-06-13 16:21:27 -07:00
Markus Cisler 243f514243 Only toggle mentions popup if connected to network
The top bar is only shown if the user is connected to at least one
network. Only then it is possible to open the recent mentions popup.

Only toggle the recent mentions popup if the user is connected to at
least one network so the popup will not open over the connect view.
2021-06-13 01:49:23 +02:00
Markus Cisler a93ccd680f Toggle recent mentions popup with alt-m
This adds a keybind to toggle the recent mentions popup using alt+m (or
opt+m on macOS).

Relates to #4175
2021-06-13 01:08:40 +02:00
Max Leiter 53a7227e2e
v4.3.0-pre.2 2021-06-07 20:46:35 -07:00
Max Leiter 8bb2fbbf15
Add changelog entry for v4.3.0-pre.2 2021-06-07 20:46:18 -07:00
Max Leiter 0fa37a6a05
Merge branch 'master' into maxleiter/accessiblityImprovements 2021-06-06 23:49:34 -07:00
Max Leiter d5b6a8521f
Merge pull request #4251 from brunnre8/localPlugins
install: allow installation of local packages
2021-06-05 10:30:18 -07:00
Reto Brunner c5fcc5d72f install: allow installation of local packages
It may not be desirable to host all plugins on npm, allow for local packages to
be installed given a package name with a `file:` prefix.

This is still more restrictive than what yarn would support but allows us to still
verify the thelounge compatibility by reading the package.json file.

`yarn add` messes up with local filepaths and generates a lockfile that is
"outdated" as far as any other yarn commands go, which makes them error out.

For some reason `yarn install` fixes that and hence we run that after an install.
Here's the diff of yarn.lock between the broken state after `yarn add file:$path`
and `yarn install`
	--- yarn.lock.2.afterAdd	2021-06-02 00:10:52.365134018 +0200
	+++ yarn.lock.3.afterinstall	2021-06-02 00:13:27.122760442 +0200
	@@ -2194,7 +2194,7 @@
	     safe-buffer "^5.1.2"
	     yallist "^3.0.3"

	-thelounge-plugin-shortcuts@/home/reto/sourcecode/thelounge-plugin-shortcuts:
	+"thelounge-plugin-shortcuts@file:../../sourcecode/thelounge-plugin-shortcuts":
	   version "1.0.12"
	   dependencies:
	     thelounge "4.2.0"

The only thing it does is switch an absolute path to a relative one for whatever
reason.
2021-06-05 13:01:55 +02:00
Max Leiter 9ec02d1e91
Merge pull request #4252 from thelounge/maxleiter/deps
Update dependencies
2021-06-05 01:06:32 -07:00
Max Leiter beb9bcd8d4
Update dependencies
Closes #4250, #4249, #4248, #4247
2021-06-05 00:57:38 -07:00
Max Leiter 8fc7a6c0df
Merge pull request #4242 from brunnre8/master
MessageSearchForm: do not focus input if search is closed
2021-06-05 00:33:42 -07:00
Max Leiter 6182d23758
Merge pull request #4235 from angerson/patch-1
Render styling for colored host masks
2021-06-02 00:18:40 -07:00
Reto Brunner c369a764ed MessageSearchForm: do not focus input if search is closed
Else whenever the element gets created (switch from server chan to a normal one)
the browser tries to focus the hidden element
2021-05-27 09:44:44 +02:00
Austin Anderson 4d310cd545 Render styling for colored host masks
On some IRC networks, users have vanity host masks with colors or other text styling.
Rizon is one such network.

For example, a user connecting from 127.0.0.1 could instead have the host
angerson@this.is.my.host.mask. this.is.my.host.mask may have IRC color code
characters in it, which without this change would be displayed as a bunch of jumbled
garbage in the /whois response or join/part messages.

Resolves #4232.
2021-05-26 09:24:12 -07:00
Pavel Djundik fa854fde78
Merge pull request #4238 from mhajder/master
Change the IRC server to Libera.Chat
2021-05-26 15:03:23 +03:00
Mateusz Hajder 6f7fd80044
Fix length of the link in tests 2021-05-26 13:59:04 +02:00
Mateusz Hajder 28c413319f
Change IRC server and channels in tests to more generic 2021-05-26 13:43:06 +02:00
Mateusz Hajder af236dd280
Add the default IRC network for tests 2021-05-26 13:41:33 +02:00
Max Leiter 58217cffb1
Merge pull request #4219 from TheDecryptor/jpegxl_support
Add support for JPEG XL image previews
2021-05-25 21:36:37 -07:00
Max Leiter fc6c916e7c
Merge pull request #4213 from Nachtalb/na/search-command
Add new "/search query" command to open the search window
2021-05-25 21:31:17 -07:00
Max Leiter ad8a315cf9
Merge pull request #4206 from Nachtalb/na/fill-inputhistory-onload
Fill inputhistory on channel load and more message load
2021-05-25 21:30:47 -07:00
Mateusz Hajder 42bafe7165
Change the IRC server to Libera.Chat 2021-05-23 16:40:08 +02:00
Mary Strodl df5befb60e
Merge remote-tracking branch 'origin/master' into feature/socks-support 2021-05-19 19:01:42 -04:00
Max Leiter db807d0c56
Merge pull request #4223 from thelounge/renovate/npm-postcss-vulnerability
Update dependency postcss to v8.2.10 [SECURITY]
2021-05-12 16:33:12 -07:00
Renovate Bot ab0d9e6200
Update dependency postcss to v8.2.10 [SECURITY] 2021-05-12 01:05:43 +00:00
Max Leiter adf1b5abec
Merge pull request #4210 from Nachtalb/na/inline-audio-file-support
Improve inline audio file support
2021-05-11 17:05:28 -07:00
Max Leiter 2c30293ad2
Merge pull request #4221 from Nachtalb/na/fix-missing-userslist-entries-after-search
Fix missing users in userlist after removing searchinput
2021-05-11 17:05:04 -07:00
Nachtalb 042cfb7582
Fix missing users in userlist after removing searchinput
Because the "Username" components still had the same ":key" vue tried to in-place update them. This doesn't quite work for objects (in this case "user" or "user.original"). Thus we change the key for the search so that it actually inits a new component and thus evaluates its content correctly.
2021-05-09 23:33:35 +02:00
Alex Jones dbf6ff064b Add support for JPEG XL mimetype 2021-05-08 18:10:45 +10:00
Max Leiter 7b1cb88658
Merge pull request #4216 from thelounge/renovate/npm-ua-parser-js-vulnerability
Update dependency ua-parser-js to v0.7.24 [SECURITY]
2021-05-06 19:19:26 -07:00
Max Leiter 7b298cf439
Merge pull request #4212 from Nachtalb/na/allow-text-drag-n-drop
Allow text drag & drop into text fields
2021-05-06 19:18:48 -07:00
Renovate Bot a985d763d0
Update dependency ua-parser-js to v0.7.24 [SECURITY] 2021-05-07 02:13:33 +00:00
Max Leiter d097370316
Merge pull request #4214 from sha1sum/#4161_classes-on-unread-and-ping
Classes for channels in list with unread counts and highlights
2021-05-06 19:09:53 -07:00
Anthony Atkinson a3229f1cdf classes for unread and highlight 2021-05-05 21:51:35 -04:00
Nachtalb cadcc4b97c
Autofocus search input in case no query is present 2021-05-06 03:24:20 +02:00
Nachtalb 24a738d521
Add new command to open the search window 2021-05-06 03:22:09 +02:00
Nachtalb b95643e1a6
Allow text drag & drop into text fields
We only have to stop the defualt behaviour in case we drag & drop a file (for uploading)
2021-05-06 02:48:07 +02:00
Mary Strodl 3f984fad4b
network: fix test 2021-05-05 20:37:54 -04:00
Mary Strodl 9b4f55bdb6
Update lockfiles 2021-05-05 20:26:01 -04:00
Mary Strodl abcad094d1
network: add support for SOCKS (closes #1375) 2021-05-05 20:06:00 -04:00
Nachtalb 0bfcd955e3
Improve inline audio file support
Tested on latest Chromium / Firefox. In case of .m4a files they want audio/x-m4a and not audio/m4a, in case of .flac files they want audio/flac and not audio/x-flac. The module we useed to detect the types however detects them only as audio/x-m4a and audio/x-flac as they are not offical IANA supported mime types (not in IANA spec == "x-" prefix): https://www.iana.org/assignments/media-types/media-types.xhtml Though flac is not in the IANA spec many programs such as the file command (https://man7.org/linux/man-pages/man1/file.1.html) and Chromium (flac) / Firefox (x-flac and flac) support audio/flac only or both.
2021-05-06 02:02:23 +02:00
Nachtalb 04cf2277d9
Prevent possible error when findChannel can't find the wanted channel
Using ?. (optional chaining) requires ecma version 2020 as it is fairly new. Webpack / Babel can handle it.
2021-05-05 18:09:18 +02:00
Max Leiter 26a38b12ab
Merge pull request #4197 from Nachtalb/richrd/message-search
Message Search: Re-Rebase + Fixes / Adjustments
2021-05-03 16:18:15 -07:00
Max Leiter bc7a920de5
Merge pull request #4207 from thelounge/renovate/babel-monorepo 2021-04-30 20:47:57 -07:00
Max Leiter 78da0eb674
Merge pull request #4208 from thelounge/renovate/chalk-4.x 2021-04-30 20:47:08 -07:00
Renovate Bot db8102b058
Update dependency chalk to v4.1.1 2021-05-01 00:40:11 +00:00
Renovate Bot 4b96682d7f
Update babel monorepo to v7.14.0 2021-05-01 00:39:55 +00:00
Nachtalb 11aa52687c
Fill inputhistory on channel load and more message load 2021-05-01 01:46:55 +02:00
Nachtalb bd4e821614
Improve readability of more.js 2021-05-01 01:36:44 +02:00
Nachtalb c5f6b4617f
Preserve location on first and last line when scrolling through inputs 2021-05-01 00:51:55 +02:00
Nachtalb c66f9c885e
Only scroll history when cursor is on first or last row
Needs to be on first to go up and on last to go down
2021-05-01 00:51:25 +02:00
Nachtalb bb41871873
Add close search button 2021-04-30 01:53:08 +02:00
Nachtalb 115d970604
Fix load more button hidden behind search form 2021-04-30 01:53:08 +02:00
Nachtalb ef710a2631
Revert obsolete changes to vuex store 2021-04-30 01:53:07 +02:00
Nachtalb ddff3ac162
Stay in chan during search by searching on /chat-:id/search 2021-04-30 01:53:07 +02:00
Max Leiter 0aabacd549
Initial accessibility improvements
Set aria-hidden to true for cosmetic and repetitive elements
Improve channel/network aria labels in network list
Experiment with different aria-roles for landmarks
2021-04-20 18:04:40 -07:00
Max Leiter 0fb6dae8a6
Merge pull request #4186 from Nachtalb/na/remove-metadata-without-breaking-files
Support animated webp images
2021-04-13 13:14:55 -07:00
Max Leiter ee43e7bdf4
Merge pull request #4192 from Nachtalb/na/restrict-browser-autocomplete
Restrict what the browser should try to autocomplete
2021-04-13 12:58:24 -07:00
Nachtalb e010fe47cc
Respect metadata removal switch 2021-04-13 20:45:16 +02:00
Nachtalb 89390b3fc5
Ensure proper error handling when processing of file fails 2021-04-13 20:41:30 +02:00
Nachtalb c2c66031c0
Auto rotate images based on exif orientation
This makes sure that the orientation of the images work in all viewrs.
2021-04-13 18:24:33 +02:00
Nachtalb 846da41b01
Rename uploadCanvas to a more appropriate removeImageMetadata
Including backwards compatibility
2021-04-13 18:24:33 +02:00
Nachtalb 3a6ac4e5ec
Support animated webp images
We need to remove the metadata without breaking the animation.
For that we use sharp which incooperates libvips (binaries for most common distros included).

This also decreases client side upload complexity as we remove the metadata on the serverside.

Sharp: https://sharp.pixelplumbing.com/
libvips: https://libvips.github.io/libvips/
2021-04-13 18:24:32 +02:00
Nachtalb 1b13905195
Improved search header
- Highlight both channel and search query
- By moving search query to topic we can ensure nice behaviour for long search queries (eg. when searching for an url)
2021-04-13 01:56:53 +02:00
Nachtalb 13d4f035df
More consistent color scheme and usage
Only show search by click on search icon (desktop as well)
Improved color scheme
Keep search open search page
2021-04-13 01:56:53 +02:00
Nachtalb 3fb9c8523a
Enable searching for the same query again
Previously we got an error instead, but in the meantime new messages could have come in. As such we should allow to search again
2021-04-13 01:23:44 +02:00
Nachtalb 544594a7ad
Keep search term in search input after commiting 2021-04-13 00:43:52 +02:00
Nachtalb e36ae64c83
Replace search term path with query
A search term is dynamic and not a pointer to a resource such as a channel as such it should be a query.

For now the network as well as the channels are still in the path even though we should take them out of there as well (in the case we want a global / network search later on). As for now we can keep in as there is no such filter / facet yet.
2021-04-13 00:01:00 +02:00
Nachtalb be141bea65
Fix 2 line wrapped message timestamp 2021-04-12 23:46:44 +02:00
Nachtalb 40aaa17c9b
Fix user context menus in in search results view 2021-04-12 23:40:29 +02:00
Nachtalb d6a23061fc
Remove searchNicks for the time being
We should not have search implementation without a specced filter /
facet setup.
2021-04-12 23:23:04 +02:00
Nachtalb de86c144b5
Disable search if we have no message provider
If we have no message provider:
- Search input field not renderd
- Search endpoint retuns empty resultset

Also removed redundancy by setting a main message provider.
2021-04-12 23:12:11 +02:00
Mary Strodl fe0178c0d2
Remove stuff from gitignore 2021-04-12 22:00:51 +02:00
Mary Strodl 49cd90d0e9
SearchResults: lint 2021-04-12 22:00:51 +02:00
Mary Strodl e6856a9e7d
client: lay some groundwork for jump to message 2021-04-12 22:00:51 +02:00
Mary Strodl 283ef445e5
Merge remote-tracking branch 'origin/master' into richrd/message-search 2021-04-12 22:00:49 +02:00
Nachtalb 08f45eabb2
Restrict what the browser should try to autocomplete
Browsers often autocomplete into wrong fields. Eg. wanting to put the password in a fields in the settings screen and then use a sudo random other fields for username etc.
This is rather annoying and can break someones configuration, thus we should only enable it on fields where it somewhat makes sense (name, server & password fields).
2021-04-12 14:10:33 +02:00
Max Leiter db9eb05dfa
Merge pull request #4187 from Nachtalb/na/filename-in-contentDisposition
Add proper filename to the content-disposition header
2021-04-11 20:29:23 -07:00
Max Leiter df4f78098c
Merge pull request #4190 from Nachtalb/na/improved-handling-of-empty-userdata
Improved handling of empty userdata
2021-04-11 20:12:35 -07:00
Nachtalb 0ccbb90d98
Improved handling of empty userdata
Does not show gecos and account data in join message if they are an empty string
2021-04-11 15:59:42 +02:00
Nachtalb 3a42b5385e
Support inline flac audio 2021-04-11 15:43:42 +02:00
Nachtalb 14d76f8023
Add proper filename to the content-disposition header
By default we take the slug given in the request, if this is not set we try to give a filename from known types.
If we still have no filename we fallback to the previous method of setting no filename.

If the filename is non ascii we will only create the encoded "filename*" and not the ascii only "filename". This is to prevent other applications to save a file like "?????.png" if the filename contains non ascii chars.

For the browsers nothing will really change comapred to the behaviour before this change as good fallbacks if no content-disposition filename is set. But that is not the case for all application, thus it makes sense to include the proper way to set the filename.
2021-04-11 15:41:21 +02:00
Max Leiter a5e9463431
Merge pull request #4196 from thelounge/maxleiter/readme
`client/views` -> `client/components` in README
2021-04-06 15:39:08 -07:00
Max Leiter f213a8973c
client/views -> client/components in README 2021-04-06 14:56:23 -07:00
Max Leiter 9382beb3b1
Merge pull request #4195 from brunnre8/nickPostfix
Settings: show label for nick autocompletion postfix
2021-04-06 14:46:34 -07:00
Reto Brunner c6d7bd4b4a Settings: show label for nick autocompletion postfix 2021-04-06 15:27:23 +02:00
Max Leiter 8dd9bc0e98
Merge pull request #4185 from thelounge/renovate/commander-7.x
Update dependency commander to v7.2.0
2021-04-05 16:25:56 -07:00
Renovate Bot 7df94f01a7
Update dependency commander to v7.2.0 2021-04-05 23:18:45 +00:00
Max Leiter d94d09f4ba
Merge pull request #4076 from thelounge/renovate/eslint-7.x
Update dependency eslint to v7.23.0
2021-04-05 16:17:34 -07:00
Max Leiter d248f98618
Merge pull request #4182 from thelounge/renovate/babel-monorepo
Update babel monorepo
2021-04-05 16:17:06 -07:00
Max Leiter 5cef511469
Merge pull request #4184 from thelounge/renovate/chai-4.x
Update dependency chai to v4.3.4
2021-04-05 16:16:37 -07:00
Max Leiter c6282b0a50
Merge pull request #4193 from brunnre8/labelFix
NetworkForm: s/away message/leave message/
2021-04-05 16:16:20 -07:00
Renovate Bot 1913a3ade6
Update babel monorepo 2021-04-05 20:40:02 +00:00
Renovate Bot 386f90614b
Update dependency eslint to v7.23.0 2021-04-05 20:39:11 +00:00
Renovate Bot d1995a0f7d
Update dependency chai to v4.3.4 2021-04-05 20:38:41 +00:00
Max Leiter aede86bc98
Merge pull request #4183 from thelounge/renovate/font-awesome
Update dependency @fortawesome/fontawesome-free to v5.15.3
2021-04-05 13:37:40 -07:00
Reto Brunner a496ba8cfc NetworkForm: s/away message/leave message/
877e4acf7d - Add network specific leave message
introduced the wrong label for the leave message.
2021-04-05 19:19:36 +02:00
Renovate Bot d600a10f48
Update dependency @fortawesome/fontawesome-free to v5.15.3 2021-04-01 00:22:11 +00:00
Max Leiter 500034ff5d
Merge pull request #4181 from thelounge/maxleiter/fixQueryModes
Fix client crash when opening a user context menu in query
2021-03-30 22:49:55 -07:00
Max Leiter e4069f8ce9
Fix client crash when opening a user context menu in query 2021-03-30 22:33:48 -07:00
Max Leiter 4f6659897f
Merge pull request #4176 from mitaka8/feature-admin-actions
Show give/revoke modes and kick on other modes than +o
2021-03-30 22:10:54 -07:00
Max Leiter 5329483a40
Merge pull request #4135 from brunnre8/master
Add prefetchMaxSearchSize to override limit for link previews
2021-03-16 18:18:31 -07:00
Mitaka dc0e233fe0 Show give/revoke modes and kick on other modes than +o
Fix #3965

Signed-off-by: Mitaka <jin@mitaka.nl>
2021-03-12 14:33:51 +01:00
Max Leiter 6b074a6660
Merge pull request #4174 from thelounge/v4.3.0-pre.1_release
v4.3.0-pre.1 release
2021-03-03 00:36:55 -08:00
Max Leiter 57bce195de
v4.3.0-pre.1 2021-03-03 00:24:15 -08:00
Max Leiter 34086369db Add changelog entry for v4.3.0-pre.1 2021-03-02 21:53:10 -08:00
Max Leiter 8ce947130b
Merge pull request #4142 from thelounge/renovate/sqlite3-5.x
Update dependency sqlite3 to v5.0.2
2021-03-02 21:47:32 -08:00
Max Leiter 70fcf6f3ee
Merge pull request #4167 from thelounge/renovate/babel-monorepo
Update babel monorepo
2021-03-02 21:47:15 -08:00
Renovate Bot f0a3611d1e
Update dependency sqlite3 to v5.0.2 2021-03-03 05:37:04 +00:00
Renovate Bot df6226c7ca
Update babel monorepo 2021-03-03 05:36:36 +00:00
Max Leiter 8dfa7305b3
Merge pull request #4168 from thelounge/renovate/commander-7.x
Update dependency commander to v7.1.0
2021-03-02 21:35:02 -08:00
Max Leiter 850b49d802
Merge pull request #4169 from thelounge/renovate/css-loader-5.x
Update dependency css-loader to v5.1.1
2021-03-02 21:34:42 -08:00
Max Leiter 8a14b75a47
Merge pull request #4170 from thelounge/renovate/eslint-7.x
Update dependency eslint to v7.21.0
2021-03-02 21:34:33 -08:00
Max Leiter c94ace5843
Slightly adjust config option wording 2021-03-02 15:03:39 -08:00
Renovate Bot 19c7a513f1
Update dependency eslint to v7.21.0 2021-03-02 22:12:30 +00:00
Renovate Bot fbad88f9da
Update dependency css-loader to v5.1.1 2021-03-02 22:12:15 +00:00
Renovate Bot 2f29089bbf
Update dependency commander to v7.1.0 2021-03-02 22:12:01 +00:00
Max Leiter afe136fee8
Merge pull request #4171 from emilyst/patch-1
Configure server ping timeout to 60 seconds
2021-03-02 13:10:03 -08:00
Max Leiter 9474cd96d3
Merge pull request #4112 from thelounge/socketio
Upgrade to socket.io v3
2021-03-02 13:07:29 -08:00
Emily Strickland c782ca5b93
Configure server ping timeout to 60 seconds
The default socket.io server-side ping timeout was changed from 60 seconds to 5 seconds. In browsers based on Chrome, this is not enough time to respond when the browser is idle. The end result is that the server sets the user away and then back approximately once every minute if the client window is idle, which is undesirable.

This change restores the previous timeout value.

See https://github.com/socketio/socket.io/issues/3259#issuecomment-474523271.
2021-02-28 18:53:36 -08:00
Pavel Djundik e6fc726c91 Upgrade to socket.io v3 2021-02-28 15:30:44 -08:00
Reto Brunner 7c17662fea Add prefetchMaxSearchSize to override limit for link previews
YouTube puts the opengraph tags needed for the preview after ~300KB in the body
instead of the beginning of the <head> tag.
Instead of hardcoding the value, allow the server admin to set the policy as
they prefer.
2021-02-27 00:10:53 +01:00
Max Leiter f99e4eef77
Merge pull request #4116 from Nachtalb/na/network-specific-leave-message 2021-02-13 17:22:12 -08:00
Max Leiter c974ecb14a
Adjust placeholder away message 2021-02-12 14:07:48 -08:00
Max Leiter ab66c3f487
Merge pull request #4129 from thelounge/renovate/actions-setup-node-2.x
Update actions/setup-node action to v2
2021-02-09 16:51:20 -08:00
Renovate Bot 32ec420763
Update actions/setup-node action to v2 2021-02-10 00:12:09 +00:00
Max Leiter 7fef41131a
Merge pull request #4155 from thelounge/depUpdates
Update dependencies
2021-02-09 16:11:14 -08:00
Max Leiter de12699fa6 Update dependencies 2021-02-09 14:52:55 -08:00
Max Leiter d09f6f6144
Merge pull request #4138 from thelounge/renovate/copy-webpack-plugin-6.x
Update dependency copy-webpack-plugin to v6.4.1
2021-02-09 13:19:53 -08:00
Max Leiter 27195dd34a
Merge pull request #4139 from thelounge/renovate/dayjs-1.x
Update dependency dayjs to v1.10.4
2021-02-09 13:19:38 -08:00
Max Leiter b29850b2d0
Merge pull request #4140 from thelounge/renovate/eslint-7.x
Update dependency eslint to v7.19.0
2021-02-09 13:19:07 -08:00
Max Leiter 02c0290ee3
Merge pull request #4141 from thelounge/renovate/eslint-plugin-vue-7.x
Update dependency eslint-plugin-vue to v7.5.0
2021-02-09 13:18:38 -08:00
Renovate Bot 43a70df1b1
Update dependency eslint-plugin-vue to v7.5.0 2021-02-09 00:01:45 +00:00
Renovate Bot 247f20c8ef
Update dependency eslint to v7.19.0 2021-02-09 00:01:29 +00:00
Renovate Bot 6fc72624aa
Update dependency dayjs to v1.10.4 2021-02-09 00:01:13 +00:00
Renovate Bot 3d1834cc5e
Update dependency copy-webpack-plugin to v6.4.1 2021-02-09 00:00:58 +00:00
Pavel Djundik eb056c4997
Merge pull request #4151 from Willamin/patch-1
Add gopher and gemini to the commonSchemes
2021-02-04 11:43:39 +02:00
Will Lewis 9aadf1a739
Add gopher and gemini to the commonSchemes 2021-02-01 10:43:06 -05:00
Nachtalb 03377c6ced
Also use the networks specific leave message on quit 2020-12-18 10:53:40 +01:00
Nachtalb 877e4acf7d
Add network specific leave message 2020-12-18 10:53:37 +01:00
Pavel Djundik aa84e13656
Merge pull request #4110 from Nachtalb/na/fix-breaking-gifs
Fix breaking GIFs while removing metadata
2020-11-25 22:57:55 +02:00
Nick Espig 0e7a5f5c9b Fix breaking GIFs while removing metadata
Closes #4109
GIFs can't contain EXIF data and do not contain any other metadata that isn't operationl
2020-11-25 21:55:33 +01:00
Pavel Djundik 8fa8eed1e5 Upgrade dependencies 2020-11-25 17:45:22 +02:00
Pavel Djundik b2d5cdd4fc
Merge pull request #4104 from GewoonYorick/4097/add-ignore-to-contextmenu
Add ignore option to contextmenu
2020-11-19 16:27:48 +02:00
Pavel Djundik 4529118fd9
Merge pull request #4105 from GewoonYorick/3700/add-extended-join-info
Add extended join information to join message
2020-11-19 16:26:16 +02:00
Yorick Bosman 651a7ac2e9 Add extended join information to join message. 2020-11-19 00:25:28 +01:00
Yorick Bosman 51b0ec1e98 Add ignore option to contextmenu 2020-11-18 23:57:20 +01:00
Pavel Djundik ee16d98a94
Merge pull request #4094 from thelounge/renovate/vue-monorepo
Update vue monorepo
2020-11-04 15:34:55 +02:00
Pavel Djundik 2bbad443c0
Merge pull request #4093 from thelounge/xpaw/fix-4092
Do not generate and send client certificate unless SASL EXTERNAL is requested
2020-11-04 15:30:53 +02:00
Renovate Bot a4f4d23693
Update vue monorepo 2020-11-02 08:20:01 +00:00
Pavel Djundik a76e75f609 Do not generate and send client certificate unless SASL EXTERNAL is requested
Fixes #4092
2020-10-30 23:03:57 +02:00
Pavel Djundik 69986b3ee5 Add node 15 to test matrix 2020-10-30 23:03:45 +02:00
Pavel Djundik c2e8eaf9df Fix test for production build
Webpack 5 minifies it now
2020-10-30 23:02:40 +02:00
Pavel Djundik 41831d18b1
Upgrade to webpack 5 and all deps 2020-10-30 15:10:50 +02:00
Pavel Djundik 800fc95278 Update dependencies 2020-10-23 11:52:04 +03:00
Pavel Djundik 3e9262a345 Update dependencies 2020-10-15 12:22:49 +03:00
Pavel Djundik a9fb563c01 Update mini-css-extract-plugin 2020-10-11 11:06:52 +03:00
Pavel Djundik e7a8258ac0 Update packages 2020-10-11 10:56:13 +03:00
Pavel Djundik 0322c043e3
Merge pull request #4090 from supertassu/scripable-user-password-change
Make `add` and `reset` CLI commands scriptable
2020-10-07 10:03:37 +03:00
Taavi Väänänen e790a72e59 Make add and reset CLI commands scriptable
Add CLI options `--password` and `--save-logs` (for `add` only) in
order to make adding users and changing user passwords scriptable.

Closes #3913
2020-10-07 09:00:00 +03:00
Pavel Djundik 6ca3bae73e
Merge pull request #4087 from thelounge/update-deps
Update dependencies
2020-09-30 17:54:29 +03:00
Pavel Djundik c89b2bb0d6 Update postcss-loader
postcss is required by cssnano, so no need to list is explicitly
2020-09-30 17:50:24 +03:00
Pavel Djundik 1c004cbd17 Upgrade eslint-plugin-vue and fix rules 2020-09-30 17:44:07 +03:00
Pavel Djundik 02357ab9de Upgrade dependencies 2020-09-30 17:41:56 +03:00
Pavel Djundik d4bf0e365f
Merge pull request #4060 from thelounge/xpaw/fix-323
Always use multi-prefix modes
2020-09-29 13:48:21 +03:00
Pavel Djundik 61ebd65367 Add depTypeList 2020-09-18 11:51:12 +03:00
Pavel Djundik e622662c16 Update dependencies 2020-09-18 11:49:09 +03:00
Pavel Djundik 7ee0732f56 Change renovate to monthly 2020-09-18 11:49:01 +03:00
Pavel Djundik 75926432d0 Update dependencies 2020-09-02 10:38:16 +03:00
Pavel Djundik 3fde2aa7b9 Always use multi-prefix modes
Fixes #323
2020-09-01 11:45:08 +03:00
Pavel Djundik 27b3e50a64
Merge pull request #4055 from thelounge/xpaw/enterkeyhint
Add enterkeyhint on chat input and topic save
2020-08-31 13:14:24 +03:00
Pavel Djundik b9540636de Update dependencies 2020-08-29 11:51:01 +03:00
Pavel Djundik eef782fd2c
Merge pull request #4051 from MaxLeiter/accessibilityErrors
Add HTML lang and labelled-by field to upload
2020-08-29 11:47:46 +03:00
Pavel Djundik 5b602c72dc Add enterkeyhint 2020-08-29 11:46:11 +03:00
Max Leiter 570890f2f9 Set lang to unknown for #chat-container 2020-08-28 14:23:02 -07:00
Max Leiter ea5c95ac94 Add HTML lang and labelled-by field to upload 2020-08-27 18:57:12 -07:00
Pavel Djundik b74b692391 Update prettier and apply formatting 2020-08-25 12:49:53 +03:00
Pavel Djundik ac842108f3
Merge pull request #4047 from thelounge/xpaw/avif
Detect `image/avif` as an image
2020-08-24 11:54:14 +03:00
Pavel Djundik 12ceb10c75
Merge pull request #4046 from thelounge/renovate/tlds-1.x
Update dependency tlds to v1.209.0
2020-08-23 20:49:39 +03:00
Pavel Djundik 037f09a22f
Merge pull request #4043 from thelounge/renovate/babel-monorepo
Update dependency @babel/core to v7.11.4
2020-08-23 20:49:29 +03:00
Pavel Djundik f66ee9473a
Merge pull request #4045 from thelounge/renovate/pretty-quick-3.x
Update dependency pretty-quick to v3
2020-08-23 20:49:02 +03:00
Pavel Djundik 2194f91a55
Merge pull request #4042 from thelounge/vscode
Add .vscode settings and suggested extensions
2020-08-23 20:46:57 +03:00
Pavel Djundik df115333ba
Merge pull request #4041 from thelounge/mcinkay/2333/version-compatibility
Add version support for packages.
2020-08-23 20:46:44 +03:00
Pavel Djundik 4307d2da9d
Merge pull request #4040 from thelounge/xpaw/media-embed
Skip video/audio embeds if og:type exists but does not specify it
2020-08-23 20:46:36 +03:00
Pavel Djundik c89dcca449 Detect image/avif as an image 2020-08-23 12:51:52 +03:00
Renovate Bot 1df4dfad4a
Update dependency tlds to v1.209.0 2020-08-22 03:58:34 +00:00
Renovate Bot 8fb6f291f8
Update dependency pretty-quick to v3 2020-08-22 01:26:16 +00:00
Renovate Bot fedaada5a9
Update dependency @babel/core to v7.11.4 2020-08-22 00:13:04 +00:00
Pavel Djundik 381b6904e6 Add .vscode settings and suggested extensions 2020-08-21 19:27:41 +03:00
Al McKinlay 86e570efb2 Add version support for packages. 2020-08-21 15:03:12 +01:00
Pavel Djundik 1e38262d69 Add missing return 2020-08-21 10:18:41 +03:00
Pavel Djundik 9e13694b21 Skip video/audio embeds if og:type exists but does not specify it 2020-08-21 10:16:54 +03:00
Pavel Djundik 7bf4f68ff8 Upgrade packages 2020-08-20 19:43:53 +03:00
Pavel Djundik 19e7017d31 v4.2.0 2020-08-20 19:13:57 +03:00
Pavel Djundik b398a0696b Add v4.2.0 changelog 2020-08-20 19:13:48 +03:00
Pavel Djundik 14ed73ed9b
Merge pull request #3998 from Jay2k1/highlight-exceptions
Highlight exceptions
2020-08-19 21:30:36 +03:00
Jay2k1 b97b145df1 add highlight exceptions 2020-08-19 00:00:56 +02:00
Pavel Djundik c29ae50392
Merge pull request #4037 from thelounge/xpaw/rm-graphql-request
Remove `graphql-request` dependency from changelog script
2020-08-18 11:00:29 +03:00
Pavel Djundik 3557bf00fd
Merge pull request #4038 from thelounge/xpaw/sync-network-name
Sync changed network name to open clients
2020-08-18 11:00:21 +03:00
Pavel Djundik 67e4a4bbb2 Sync changed network name to open clients 2020-08-17 12:57:37 +03:00
Pavel Djundik f63f1abb7c Remove graphql-request dependency from changelog script 2020-08-17 12:46:28 +03:00
Pavel Djundik 1ef7d5ed49 Update dependencies and yarn.lock 2020-08-17 12:21:23 +03:00
Pavel Djundik 928436a9ce
Merge pull request #4032 from thelounge/xpaw/help-unread
Increase unread counter for HELP and INFO messages
2020-08-15 11:34:42 +03:00
Pavel Djundik 5861ffadf2 Increase unread counter for HELP and INFO messages 2020-08-14 14:26:03 +03:00
Pavel Djundik 2d88ae7503
Merge pull request #4029 from thelounge/xpaw/emoji-dahes
Replace dashes to underscores in emoji autocompletion
2020-08-14 10:39:45 +03:00
Pavel Djundik 82c83c5f18
Merge pull request #4028 from thelounge/xpaw/nosync
Disable settings sync for browser notifications and notification sound
2020-08-14 10:39:29 +03:00
Pavel Djundik 19d6b7d98f Replace dashes to underscores in emoji autocompletion 2020-08-13 10:47:50 +03:00
Pavel Djundik d588ecea58 Disable settings sync for browser notifications and notification sound
Closes #3144
2020-08-12 18:36:07 +03:00
Pavel Djundik b6782da837
Merge pull request #4020 from thelounge/xpaw/obj
Use lodash where possible
2020-08-10 15:57:12 +03:00
Pavel Djundik 8bf55527ed Use lodash where possible 2020-08-07 19:52:50 +03:00
Pavel Djundik 2a11c07ba9
Merge pull request #4014 from thelounge/xpaw/no-schema-previews
Disable link prefetching for urls with no schema specified
2020-08-05 12:18:10 +03:00
Pavel Djundik 5720c98869
Merge pull request #4015 from thelounge/renovate/tlds-1.x
Update dependency tlds to v1.208.0
2020-08-04 23:05:03 +03:00
Renovate Bot 7fce28ad90
Update dependency tlds to v1.208.0 2020-08-04 19:32:47 +00:00
Pavel Djundik 8c6460b58a Disable link prefetching for urls with no schema specified 2020-08-04 20:21:12 +03:00
Pavel Djundik 70937d29e0 Update dependencies 2020-08-03 11:36:45 +03:00
Pavel Djundik f1fc7a8968
Merge pull request #4005 from thelounge/xpaw/fix-mode-users
Fix mode message only making last nick clickable
2020-07-29 16:54:58 +03:00
Pavel Djundik 40954c9a3a Fix mode message only making last nick clickable 2020-07-29 10:29:51 +03:00
Pavel Djundik 7d6f98d974
Merge pull request #4004 from thelounge/renovate/uuid-8.x
Update dependency uuid to v8.3.0
2020-07-28 18:01:16 +03:00
Renovate Bot d658b7dfd5
Update dependency uuid to v8.3.0 2020-07-28 11:03:02 +00:00
Pavel Djundik 87299bb893 v4.2.0-pre.2 2020-07-28 14:01:51 +03:00
Pavel Djundik 0dba477eb3
Add changelog entry for v4.2.0-pre.2 2020-07-28 14:01:27 +03:00
Richard Lewis 9a5d80cecc
Merge pull request #4003 from thelounge/xpaw/mentions-wrap
Add break-word on mentions popup content
2020-07-27 18:47:17 +03:00
Pavel Djundik 89165d798b Add break-word on mentions popup content 2020-07-27 17:27:06 +03:00
Pavel Djundik ec65fd17af
Merge pull request #3983 from SRCF/audio
Add option to disable media preview.
2020-07-27 11:04:06 +03:00
Pavel Djundik 5a1963647e
Merge pull request #3962 from thelounge/xpaw/help-info-blocks
Implement generic monospace blocks for INFO and HELP numerics
2020-07-27 11:02:14 +03:00
Pavel Djundik d6cace3959
Merge pull request #3999 from thelounge/xpaw/fix-chrome-perf
Fix layout trashing in Chrome causing typing lag
2020-07-27 11:02:01 +03:00
Pavel Djundik 9502b6adf0
Merge pull request #3987 from thelounge/xpaw/notif-state
Refresh notification permission state when push is enabled
2020-07-27 11:01:53 +03:00
Pavel Djundik a8a2bd7755
Merge pull request #3986 from thelounge/xpaw/upload-keepalive
Fix upload tokens expiring while uploading when TL is proxied
2020-07-27 11:01:44 +03:00
Pavel Djundik a0cfa4900e
Merge pull request #3995 from thelounge/xpaw/router-cleanup
Cleanup vue router route guards
2020-07-27 11:01:37 +03:00
Pavel Djundik 3e26611e9f Fix layout trashing in Chrome
https://bugs.chromium.org/p/chromium/issues/detail?id=1063575#c21
2020-07-27 10:27:52 +03:00
Pavel Djundik aaaf498ada
Merge pull request #4002 from thelounge/renovate/css-loader-4.x
Update dependency css-loader to v4
2020-07-27 10:27:31 +03:00
Renovate Bot bf12d7f4c3
Update dependency css-loader to v4 2020-07-27 07:22:07 +00:00
Pavel Djundik 07e4663e02
Merge pull request #4001 from thelounge/renovate/webpack-4.x
Update dependency webpack to v4.44.0
2020-07-27 10:20:53 +03:00
Pavel Djundik 1de351794d
Merge pull request #4000 from thelounge/renovate/dayjs-1.x
Update dependency dayjs to v1.8.30
2020-07-27 10:20:44 +03:00
Renovate Bot a7f4008ec8
Update dependency webpack to v4.44.0 2020-07-25 00:38:52 +00:00
Renovate Bot 9e77dc3cca
Update dependency dayjs to v1.8.30 2020-07-25 00:38:31 +00:00
Pavel Djundik 1ec728c2b0
Merge pull request #3992 from thelounge/renovate/commander-6.x
Update dependency commander to v6
2020-07-22 10:59:41 +03:00
Pavel Djundik ccea8a35f2
Merge pull request #3996 from thelounge/renovate/ldapjs-2.x
Update dependency ldapjs to v2.1.0
2020-07-22 10:59:31 +03:00
Renovate Bot d63a85a15c
Update dependency ldapjs to v2.1.0 2020-07-21 22:02:09 +00:00
Dexter Chua 2f434be75d Add option to disable media preview.
This disables image previews iff prefetchStorage is disabled. This
stops the client from making any requests to third-party sites.
2020-07-21 08:52:02 +08:00
Pavel Djundik a2c1d1175b Disallow navigating to invalid networks 2020-07-20 11:50:01 +03:00
Pavel Djundik 181a198994 Cleanup vue router route guards 2020-07-20 11:50:01 +03:00
Pavel Djundik 63a420ac21 Implement generic monospace blocks for INFO and MOTD numerics
Fixes #3961
2020-07-20 10:07:49 +03:00
Renovate Bot 3e5933bfd3
Update dependency commander to v6 2020-07-19 19:01:39 +00:00
Pavel Djundik 0ac1fcb471
Merge pull request #3993 from thelounge/xpaw/mentions-fix
Improvements to mentions window
2020-07-19 21:45:46 +03:00
Pavel Djundik 531ea920e0 Improvements to mentions window
- Add hide all button
- Only show overflow scroll when necessary
- Fix key in v-for loop
- Increase window height if browser size allows for it
2020-07-19 17:29:52 +03:00
Pavel Djundik cf4a776a93
Merge pull request #3989 from thelounge/renovate/babel-monorepo
Update dependency @babel/core to v7.10.5
2020-07-19 00:18:14 +03:00
Pavel Djundik 4f7dd37303
Merge pull request #3988 from thelounge/renovate/got-11.x
Update dependency got to v11.5.1
2020-07-19 00:18:05 +03:00
Pavel Djundik c0258b847e
Merge pull request #3991 from thelounge/renovate/eslint-7.x
Update dependency eslint to v7.5.0
2020-07-19 00:17:57 +03:00
Pavel Djundik af65f11b68
Merge pull request #3990 from thelounge/renovate/font-awesome
Update dependency @fortawesome/fontawesome-free to v5.14.0
2020-07-19 00:17:49 +03:00
Renovate Bot 79beff1d8a
Update dependency eslint to v7.5.0 2020-07-18 19:23:18 +00:00
Renovate Bot 0b7c76ef49
Update dependency @fortawesome/fontawesome-free to v5.14.0 2020-07-18 00:14:30 +00:00
Renovate Bot 970208b470
Update dependency @babel/core to v7.10.5 2020-07-18 00:14:11 +00:00
Renovate Bot 21c496d534
Update dependency got to v11.5.1 2020-07-16 12:14:55 +00:00
Pavel Djundik b7c5f2031c Refresh notification permission state when push is enabled 2020-07-15 16:08:36 +03:00
Pavel Djundik b1115475bf Fix upload tokens expiring while uploading
Fixes #3982
2020-07-15 12:29:02 +03:00
Pavel Djundik f979c72ca7
Merge pull request #3984 from thelounge/renovate/irc-framework-4.x
Update dependency irc-framework to v4.9.0
2020-07-13 11:21:55 +03:00
Pavel Djundik 5e6b5f7400 Add certfp to whois 2020-07-13 10:25:54 +03:00
Pavel Djundik 4becb152bb Changes for irc-framework update 2020-07-13 10:25:54 +03:00
Renovate Bot 761d482572
Update dependency irc-framework to v4.9.0 2020-07-12 22:48:47 +00:00
Richard Lewis 189f7d84ba
Merge pull request #3981 from thelounge/xpaw/mixed-content
Change wording of prefetchStorage option
2020-07-10 14:08:05 +03:00
Pavel Djundik 020323ca45
Change wording of prefetchStorage option 2020-07-10 13:52:39 +03:00
Pavel Djundik d0d3a205b9
Merge pull request #3977 from thelounge/xpaw/allow-private-lock-name
Allow changing network name in private mode with lockNetwork
2020-07-09 11:17:31 +03:00
Pavel Djundik d01d39deda Update yarn.lock 2020-07-09 10:50:52 +03:00
Renovate Bot 70f45ab7f4
Update dependency lodash to v4.17.19 2020-07-08 17:17:20 +00:00
Pavel Djundik d7c641ffc7 Add password field for private+locknetwork 2020-07-08 15:42:34 +03:00
Pavel Djundik c21ccad823 Allow changing network name in private mode with lockNetwork 2020-07-08 15:42:34 +03:00
Pavel Djundik 5fcfcf4f23
Merge pull request #3976 from thelounge/node-v14
Test node v14
2020-07-08 15:41:25 +03:00
Pavel Djundik 5f3133a609 Update vuedraggable and yarn.lock 2020-07-08 15:29:17 +03:00
Renovate Bot 6a0708b676
Update dependency lodash to v4.17.17 2020-07-08 12:14:35 +00:00
Richard Lewis fae9d75d6d
Merge pull request #3978 from thelounge/xpaw/fix-date-marker
Fix date marker not displaying sometimes
2020-07-08 14:59:42 +03:00
Pavel Djundik 67d9317f20 Fix date marker not displaying sometimes 2020-07-08 14:43:43 +03:00
Pavel Djundik c5f9ef3e3d Test node v14 2020-07-08 14:19:22 +03:00
Pavel Djundik 9d7888814c Update yarn.lock 2020-07-08 14:17:22 +03:00
Pavel Djundik bba2f21f5e Merge remote-tracking branches 'origin/renovate/got-11.x', 'origin/renovate/lodash-monorepo' and 'origin/renovate/sqlite3-5.x' 2020-07-08 14:14:18 +03:00
Renovate Bot f8448c2521
Update dependency lodash to v4.17.16 2020-07-08 11:08:01 +00:00
Renovate Bot bc862698ea
Update dependency sqlite3 to v5 2020-07-08 10:30:09 +00:00
Renovate Bot cf64de66c9
Update dependency got to v11.5.0 2020-07-08 10:29:50 +00:00
Pavel Djundik dbe1427e7a
Merge pull request #3845 from thelounge/xpaw/native-badging
Implement native app badges for highlights (Chrome 81+)
2020-07-08 12:33:52 +03:00
Pavel Djundik b6bd869d5f
Merge pull request #3918 from ashwinikammar/ashwini/fix_username
Make usernames case-insensitive when logging in
2020-07-08 11:26:58 +03:00
Pavel Djundik a6ee6efb6a
Merge pull request #3967 from thelounge/renovate/babel-monorepo
Update babel monorepo to v7.10.4
2020-07-06 10:40:47 +03:00
Pavel Djundik 6ae3821e12
Merge pull request #3968 from thelounge/renovate/copy-webpack-plugin-6.x
Update dependency copy-webpack-plugin to v6.0.3
2020-07-06 10:40:40 +03:00
Pavel Djundik 9e278dc812
Merge pull request #3969 from thelounge/renovate/dayjs-1.x
Update dependency dayjs to v1.8.29
2020-07-06 10:40:32 +03:00
Pavel Djundik 2323afc0d3
Merge pull request #3970 from thelounge/renovate/eslint-7.x
Update dependency eslint to v7.4.0
2020-07-06 10:40:24 +03:00
Pavel Djundik 2ef16d5f9b
Merge pull request #3971 from thelounge/renovate/vue-monorepo
Update dependency vuex to v3.5.1
2020-07-06 10:40:16 +03:00
Pavel Djundik b605bf3a95
Merge pull request #3972 from thelounge/renovate/got-11.x
Update dependency got to v11.4.0
2020-07-06 10:40:07 +03:00
Renovate Bot 91a377b015
Update dependency got to v11.4.0 2020-07-04 11:53:46 +00:00
Renovate Bot 2544b19525
Update dependency vuex to v3.5.1 2020-07-04 02:47:28 +00:00
Renovate Bot 34abca6af7
Update dependency eslint to v7.4.0 2020-07-04 01:10:00 +00:00
Renovate Bot 111201f212
Update dependency dayjs to v1.8.29 2020-07-04 01:09:41 +00:00
Renovate Bot cb2a7d02ba
Update dependency copy-webpack-plugin to v6.0.3 2020-07-04 00:39:25 +00:00
Renovate Bot a3e4d4c99d
Update babel monorepo to v7.10.4 2020-07-04 00:39:04 +00:00
Pavel Djundik 32d39410da
Put the string in quotes
skip ci

Github rendered it as "\#"
2020-06-29 15:16:15 +03:00
Pavel Djundik 7ddfc63327
Escape channel name
skip ci
2020-06-29 15:15:13 +03:00
Pavel Djundik 21d872519f
Merge pull request #3963 from thelounge/issue-links
Create issues links
2020-06-29 15:14:13 +03:00
Pavel Djundik 8fe9add310 Create issues links 2020-06-29 12:48:14 +03:00
Pavel Djundik 0d9571e43e
Merge pull request #3960 from thelounge/renovate/vue-loader-15.x
Update dependency vue-loader to v15.9.3
2020-06-28 11:46:37 +03:00
Pavel Djundik 47624efd24
Merge pull request #3959 from thelounge/renovate/eslint-7.x
Update dependency eslint to v7.3.1
2020-06-28 11:46:29 +03:00
Pavel Djundik ff52ee58e4
Merge pull request #3957 from thelounge/renovate/uuid-8.x
Update dependency uuid to v8.2.0
2020-06-28 11:46:22 +03:00
Renovate Bot f635292dfe
Update dependency vue-loader to v15.9.3 2020-06-27 01:00:57 +00:00
Renovate Bot 99def3c0ef
Update dependency eslint to v7.3.1 2020-06-27 01:00:36 +00:00
Renovate Bot 91a32d9d51
Update dependency uuid to v8.2.0 2020-06-23 21:10:54 +00:00
Ashwini Kammar 9e8033e36e Fixing thelounge username case-sensitivity - issue#2943
Removing the duplicate user profiles
2020-06-23 13:01:06 +01:00
Pavel Djundik eebfb7a6c5
Merge pull request #3952 from thelounge/renovate/font-awesome
Update dependency @fortawesome/fontawesome-free to v5.13.1
2020-06-22 23:00:12 +03:00
Pavel Djundik 8ec54169f3
Merge pull request #3953 from thelounge/renovate/eslint-7.x
Update dependency eslint to v7.3.0
2020-06-22 23:00:05 +03:00
Pavel Djundik 6f58a875de
Merge pull request #3954 from thelounge/renovate/stylelint-13.x
Update dependency stylelint to v13.6.1
2020-06-22 22:59:58 +03:00
Renovate Bot b7035d3cae
Update dependency stylelint to v13.6.1 2020-06-22 08:58:30 +00:00
Renovate Bot 7effc2f873
Update dependency eslint to v7.3.0 2020-06-22 08:58:14 +00:00
Renovate Bot 2b1d04938a
Update dependency @fortawesome/fontawesome-free to v5.13.1 2020-06-22 08:57:56 +00:00
Pavel Djundik fa0396a764
Merge pull request #3951 from thelounge/renovate/babel-monorepo
Update babel monorepo to v7.10.3
2020-06-22 11:56:18 +03:00
Pavel Djundik d2d3880f23
Merge pull request #3955 from thelounge/renovate/webpack-cli-3.x
Update dependency webpack-cli to v3.3.12
2020-06-22 11:56:04 +03:00
Renovate Bot cf842e8ebf
Update dependency webpack-cli to v3.3.12 2020-06-20 05:18:39 +00:00
Renovate Bot f50a40dfb4
Update babel monorepo to v7.10.3 2020-06-20 01:18:24 +00:00
Pavel Djundik e3f0cd4fc3
Update yarn.lock 2020-06-14 12:38:28 +03:00
Pavel Djundik 8f102f7316 Merge remote-tracking branch 'origin/renovate/chalk-4.x' 2020-06-14 12:34:36 +03:00
Pavel Djundik b904b4875c Merge remote-tracking branch 'origin/renovate/css-loader-3.x' 2020-06-14 12:34:30 +03:00
Pavel Djundik c3f96472c2 Merge remote-tracking branch 'origin/renovate/file-type-14.x' 2020-06-14 12:34:24 +03:00
Pavel Djundik 72b7906949 Merge branch 'renovate/mocha-8.x' 2020-06-14 12:34:07 +03:00
Renovate Bot 612b84ceb7
Update dependency css-loader to v3.6.0 2020-06-14 09:33:52 +00:00
Renovate Bot a4f0add6e1
Update dependency chalk to v4.1.0 2020-06-14 09:33:35 +00:00
Pavel Djundik 3ac2d2c22a
Merge pull request #3946 from thelounge/renovate/textcomplete-0.x
Update dependency textcomplete to v0.18.2
2020-06-14 12:33:23 +03:00
Pavel Djundik fd9863d919
Merge pull request #3906 from thelounge/xpaw/ctcp-server
Reply to the server if that's where CTCP VERSION originated
2020-06-14 12:33:08 +03:00
Pavel Djundik b3ad1b1419
Merge pull request #3947 from thelounge/renovate/vue-monorepo
Update dependency vue-router to v3.3.4
2020-06-14 12:33:00 +03:00
Pavel Djundik d29f2fb251 Got: "options.rejectUnauthorized" is now deprecated, please use "options.https.rejectUnauthorized" 2020-06-14 12:32:08 +03:00
Renovate Bot 1918485d8c
Update dependency file-type to v14.6.2 2020-06-14 08:59:43 +00:00
Renovate Bot eeac12fe49
Update dependency vue-router to v3.3.4 2020-06-13 09:09:06 +00:00
Renovate Bot 78426087d1
Update dependency mocha to v8 2020-06-13 03:37:39 +00:00
Renovate Bot 1eafb231af
Update dependency textcomplete to v0.18.2 2020-06-13 01:32:39 +00:00
Pavel Djundik 154ac3a8fa
Merge pull request #3937 from thelounge/renovate/eslint-7.x
Update dependency eslint to v7.2.0
2020-06-07 17:32:20 +03:00
Pavel Djundik 4888f538d7
Merge pull request #3939 from thelounge/renovate/stylelint-13.x
Update dependency stylelint to v13.6.0
2020-06-07 17:32:13 +03:00
Pavel Djundik d23f7af439
Merge pull request #3938 from thelounge/renovate/nyc-15.x
Update dependency nyc to v15.1.0
2020-06-07 17:32:05 +03:00
Pavel Djundik bbd9bbeb46
Merge pull request #3936 from thelounge/renovate/copy-webpack-plugin-6.x
Update dependency copy-webpack-plugin to v6.0.2
2020-06-07 17:31:58 +03:00
Pavel Djundik 75ec058910
Merge pull request #3935 from thelounge/renovate/got-11.x
Update dependency got to v11.3.0
2020-06-07 17:31:50 +03:00
Pavel Djundik 05feff3d28
Merge pull request #3940 from thelounge/renovate/file-type-14.x
Update dependency file-type to v14.6.1
2020-06-07 17:31:35 +03:00
Renovate Bot 02da351c3c
Update dependency file-type to v14.6.1 2020-06-07 13:43:44 +00:00
Renovate Bot c7b78779e5
Update dependency stylelint to v13.6.0 2020-06-06 02:35:32 +00:00
Renovate Bot bc087ab74d
Update dependency nyc to v15.1.0 2020-06-06 02:35:13 +00:00
Renovate Bot 6315befa32
Update dependency eslint to v7.2.0 2020-06-06 00:36:51 +00:00
Renovate Bot 984bb76c5f
Update dependency copy-webpack-plugin to v6.0.2 2020-06-06 00:36:29 +00:00
Renovate Bot 1299e1e4c9
Update dependency got to v11.3.0 2020-06-05 16:33:22 +00:00
Richard Lewis f3236538a0 Fix searching channels with uppercase letters in name. 2020-06-03 14:17:53 +03:00
Richard Lewis b4d02c3c56 Fix context menus in search results. 2020-06-03 14:04:57 +03:00
Pavel Djundik 3194777b98
Merge pull request #3927 from thelounge/renovate/babel-monorepo
Update babel monorepo to v7.10.2
2020-06-02 17:34:28 +03:00
Pavel Djundik f593af9b12
Merge pull request #3932 from thelounge/renovate/got-11.x
Update dependency got to v11.2.0
2020-06-02 17:34:20 +03:00
Pavel Djundik 889d2f8482
Merge pull request #3934 from thelounge/renovate/file-type-14.x
Update dependency file-type to v14.6.0
2020-06-02 17:34:14 +03:00
Renovate Bot 7ea79d10a2
Update dependency file-type to v14.6.0 2020-06-02 04:36:29 +00:00
Renovate Bot cd1171b4c1
Update dependency got to v11.2.0 2020-06-01 07:38:47 +00:00
Renovate Bot 97a2805a8a
Update babel monorepo to v7.10.2 2020-05-31 19:43:18 +00:00
Pavel Djundik a119e5c4a0
Merge pull request #3931 from thelounge/renovate/ldapjs-2.x
Update dependency ldapjs to v2.0.0
2020-05-31 22:38:59 +03:00
Pavel Djundik ebcbabde95
Merge pull request #3929 from thelounge/renovate/vue-monorepo
Update dependency vue-router to v3.3.2
2020-05-31 22:38:51 +03:00
Pavel Djundik a863c2c553
Merge pull request #3928 from thelounge/renovate/dayjs-1.x
Update dependency dayjs to v1.8.28
2020-05-31 22:38:44 +03:00
Renovate Bot 253d820225
Update dependency ldapjs to v2.0.0 2020-05-31 13:22:05 +00:00
Renovate Bot f1c04be695
Update dependency vue-router to v3.3.2 2020-05-30 07:57:27 +00:00
Renovate Bot 7d418d6cbb
Update dependency dayjs to v1.8.28 2020-05-30 07:55:00 +00:00
Pavel Djundik e7b6fdf0c3
Merge pull request #3886 from thelounge/renovate/sqlite3-4.x
Update dependency sqlite3 to v4.2.0
2020-05-30 10:49:47 +03:00
Renovate Bot 56d5c77c76
Update dependency sqlite3 to v4.2.0 2020-05-26 07:47:03 +00:00
Pavel Djundik 336504c306
Merge pull request #3926 from thelounge/renovate/mocha-7.x
Update dependency mocha to v7.2.0
2020-05-26 10:45:06 +03:00
Renovate Bot c54e7f9f47
Update dependency mocha to v7.2.0 2020-05-23 12:58:24 +00:00
Pavel Djundik a740074fd9 Update eslint and yarn.lock 2020-05-23 12:29:25 +03:00
Pavel Djundik 1de0294642
Merge pull request #3922 from thelounge/renovate/stylelint-13.x
Update dependency stylelint to v13.5.0
2020-05-23 12:04:21 +03:00
Pavel Djundik 9443f4e848
Merge pull request #3923 from thelounge/renovate/vue-monorepo
Update dependency vue-router to v3.2.0
2020-05-23 12:04:02 +03:00
Pavel Djundik f4cbcee1b0
Merge pull request #3920 from thelounge/renovate/file-type-14.x
Update dependency file-type to v14.5.0
2020-05-23 12:03:53 +03:00
Pavel Djundik 56883f655a
Merge pull request #3919 from thelounge/renovate/uuid-8.x
Update dependency uuid to v8.1.0
2020-05-23 12:03:44 +03:00
Pavel Djundik 24b53c9ff6
Merge pull request #3917 from thelounge/renovate/linkify-it-3.x
Update dependency linkify-it to v3
2020-05-23 12:03:36 +03:00
Renovate Bot 230f26156a
Update dependency vue-router to v3.2.0 2020-05-23 00:34:19 +00:00
Renovate Bot 42715720b1
Update dependency stylelint to v13.5.0 2020-05-23 00:34:04 +00:00
Renovate Bot 31a7228509
Update dependency file-type to v14.5.0 2020-05-21 05:46:39 +00:00
Renovate Bot 6322132921
Update dependency uuid to v8.1.0 2020-05-20 19:29:47 +00:00
Renovate Bot 70bff5616d
Update dependency linkify-it to v3 2020-05-20 12:31:33 +00:00
Pavel Djundik 9bb0b02261 v4.2.0-pre.1 2020-05-17 16:23:53 +03:00
Pavel Djundik ff25f43eeb Add changelog entry for v4.2.0-pre.1 2020-05-17 16:23:46 +03:00
Pavel Djundik b8322d6aa7
Merge pull request #3914 from thelounge/renovate/stylelint-13.x
Update dependency stylelint to v13.4.0
2020-05-17 16:22:41 +03:00
Pavel Djundik 23bbdb08aa
Merge pull request #3915 from thelounge/renovate/vue-monorepo
Update vue monorepo to v1.0.3
2020-05-17 16:22:33 +03:00
Renovate Bot ac9bb8442b
Update vue monorepo to v1.0.3 2020-05-17 12:18:52 +00:00
Renovate Bot eadd225363
Update dependency stylelint to v13.4.0 2020-05-17 10:04:24 +00:00
Pavel Djundik a7c9cf5baa
Merge pull request #3909 from thelounge/renovate/file-type-14.x
Update dependency file-type to v14.4.0
2020-05-17 13:02:59 +03:00
Pavel Djundik 5de26dbee9
Merge pull request #3910 from thelounge/renovate/dayjs-1.x
Update dependency dayjs to v1.8.27
2020-05-17 13:02:53 +03:00
Pavel Djundik a9f5f72218
Merge pull request #3911 from thelounge/renovate/vue-monorepo
Update dependency vuex to v3.4.0
2020-05-17 13:02:43 +03:00
Pavel Djundik bd4c4414dd
Merge pull request #3905 from thelounge/renovate/got-11.x
Update dependency got to v11.1.4
2020-05-17 13:02:32 +03:00
Renovate Bot 1453c75c3f
Update dependency vuex to v3.4.0 2020-05-16 18:38:10 +00:00
Renovate Bot 1800c2a892
Update dependency got to v11.1.4 2020-05-16 18:37:49 +00:00
Renovate Bot 7747114b2e
Update dependency file-type to v14.4.0 2020-05-16 18:37:37 +00:00
Renovate Bot 48be6771b2
Update dependency dayjs to v1.8.27 2020-05-16 18:37:25 +00:00
Pavel Djundik abb8566ce7
Merge pull request #3912 from thelounge/renovate/copy-webpack-plugin-6.x
Update dependency copy-webpack-plugin to v6
2020-05-16 21:36:58 +03:00
Pavel Djundik bc7bf9870c Update CopyPlugin options 2020-05-16 21:33:25 +03:00
Renovate Bot c3d8855ec3 Update dependency copy-webpack-plugin to v6 2020-05-16 21:33:24 +03:00
Pavel Djundik dbc829b5f8 Reply to the server if that's where CTCP VERSION originated 2020-05-11 21:55:30 +03:00
Pavel Djundik c4ff314a12
Merge pull request #3903 from thelounge/renovate/eslint-7.x
Update dependency eslint to v7
2020-05-11 16:32:22 +03:00
Pavel Djundik 147d3b2a9d
Merge pull request #3904 from thelounge/renovate/web-push-3.x
Update dependency web-push to v3.4.4
2020-05-11 16:32:12 +03:00
Renovate Bot 0e2e26ef88
Update dependency web-push to v3.4.4 2020-05-10 11:25:47 +00:00
Renovate Bot a439102403
Update dependency eslint to v7 2020-05-09 17:06:10 +00:00
Pavel Djundik e1157642a8
Merge pull request #3902 from thelounge/renovate/vue-monorepo
Update vue monorepo to v1.0.2
2020-05-09 20:04:57 +03:00
Pavel Djundik 91333d9009
Merge pull request #3898 from thelounge/renovate/got-11.x
Update dependency got to v11.1.2
2020-05-09 20:04:47 +03:00
Renovate Bot 9f6c374d36
Update vue monorepo to v1.0.2 2020-05-09 00:34:37 +00:00
Renovate Bot aef952678a
Update dependency got to v11.1.2 2020-05-08 12:29:05 +00:00
Pavel Djundik b73755b0c4
Merge pull request #3869 from thelounge/renovate/got-11.x
Update dependency got to v11
2020-05-06 13:19:37 +03:00
Pavel Djundik 67378c7a48
Merge pull request #3896 from thelounge/renovate/file-type-14.x
Update dependency file-type to v14.3.0
2020-05-06 13:19:29 +03:00
Renovate Bot 10d58f2f19
Update dependency file-type to v14.3.0 2020-05-04 19:27:42 +00:00
Renovate Bot ca23475620
Update dependency got to v11 2020-05-04 15:40:30 +00:00
Pavel Djundik 5aaac56a1a
Merge pull request #3890 from thelounge/renovate/uuid-8.x
Update dependency uuid to v8
2020-05-04 11:55:10 +03:00
Pavel Djundik 6941b3cb7f
Merge pull request #3894 from thelounge/renovate/dayjs-1.x
Update dependency dayjs to v1.8.26
2020-05-04 11:55:00 +03:00
Pavel Djundik b5b5d5e7d4
Merge pull request #3893 from thelounge/renovate/babel-monorepo
Update babel monorepo to v7.9.6
2020-05-04 11:54:51 +03:00
Pavel Djundik 39a8bed4ca
Merge pull request #3895 from thelounge/renovate/vue-monorepo
Update dependency vue-loader to v15.9.2
2020-05-04 11:54:35 +03:00
Renovate Bot 033565bfc5
Update dependency vue-loader to v15.9.2 2020-05-02 01:49:09 +00:00
Renovate Bot b507b340a6
Update dependency dayjs to v1.8.26 2020-05-02 00:35:13 +00:00
Renovate Bot ae276a69eb
Update babel monorepo to v7.9.6 2020-05-02 00:34:56 +00:00
Renovate Bot 5310c90a83
Update dependency uuid to v8 2020-04-29 20:52:32 +00:00
Pavel Djundik 512fc5ca04
Merge pull request #3889 from timmw/timmw/vue3-prep
Changes required for vue 3
2020-04-28 22:10:55 +03:00
Tim Miller-Williams 13a7a4b5c1
Remove vue/no-use-v-if-with-v-for eslint override 2020-04-28 20:05:46 +01:00
Pavel Djundik fb9e3e6a53
Merge pull request #3888 from thelounge/xpaw/id-order
Fix msg id order when loading from sqlite
2020-04-28 21:42:44 +03:00
Tim Miller-Williams 06d6dbe3a3 Fix usage of v-for and v-if on same component 2020-04-28 17:00:08 +01:00
Tim Miller-Williams 8263b17861 Address warning in Vue 3
Property "unreadMarkerShown" was accessed during render but is not
defined on instance
2020-04-28 17:00:08 +01:00
Pavel Djundik 24d4276a7c Fix msg id order when loading from sqlite 2020-04-28 17:45:27 +03:00
Tim Miller-Williams 8d8183eabb Fix 'component' case sensitivity
Component does not work in Vue 3
2020-04-28 15:17:55 +01:00
Pavel Djundik 801c7a07c0
Merge pull request #3885 from thelounge/xpaw/eventbus-multi-arg
Support multiple arguments in eventbus emit
2020-04-28 16:02:24 +03:00
Pavel Djundik 61d8884bef Support multiple arguments in eventbus emit
Fixes confirm dialog callback
2020-04-27 21:45:47 +03:00
Richard Lewis 5d017b09b8 Append new results and keep scroll position. 2020-04-26 22:39:05 +03:00
Richard Lewis 9a1fb0c0a0 Search improvements. 2020-04-26 22:39:05 +03:00
Richard Lewis 88644314ce Use ellipsis
Co-Authored-By: Pavel Djundik <xPaw@users.noreply.github.com>
2020-04-26 22:39:05 +03:00
Richard Lewis 4ba458b9ea Use ellipsis
Co-Authored-By: Pavel Djundik <xPaw@users.noreply.github.com>
2020-04-26 22:39:05 +03:00
Richard Lewis 28c740ab67 Fix toggling search on mobile. 2020-04-26 22:39:05 +03:00
Richard Lewis 2591ae9e8e Disable searching nicks. 2020-04-26 22:39:05 +03:00
Richard Lewis 0f3c292098 Message search WIP. 2020-04-26 22:39:05 +03:00
Pavel Djundik 16646e1586 Fix eventbus 2020-04-26 12:34:22 +03:00
Pavel Djundik 8978be2fd7 Update yarn.lock 2020-04-26 12:21:02 +03:00
Renovate Bot 183226e190 Update dependency webpack to v4.43.0 2020-04-26 12:18:04 +03:00
Renovate Bot 185fcfef33 Update dependency vuex to v3.3.0 2020-04-26 12:18:04 +03:00
Renovate Bot 7a4ee6db27 Update dependency stylelint to v13.3.3 2020-04-26 12:18:04 +03:00
Renovate Bot ea11e5cfd9 Update dependency prettier to v2.0.5 2020-04-26 12:18:04 +03:00
Renovate Bot ae7426b6ff Update dependency mocha to v7.1.2 2020-04-26 12:18:03 +03:00
Renovate Bot f72d29b391 Update dependency mime-types to v2.1.27 2020-04-26 12:18:03 +03:00
Renovate Bot 9a7ae60392 Update dependency file-type to v14.2.0 2020-04-26 12:13:01 +03:00
Renovate Bot fc61500a29 Update dependency eslint-config-prettier to v6.11.0 2020-04-26 12:13:01 +03:00
Renovate Bot bcdd548238 Update dependency dayjs to v1.8.25 2020-04-26 12:13:01 +03:00
Renovate Bot 2e9d375f36 Update dependency css-loader to v3.5.3 2020-04-26 12:13:01 +03:00
Renovate Bot f436dfdd41
Update dependency commander to v5.1.0 2020-04-25 02:31:55 +00:00
Pavel Djundik 480a2576c3
Merge pull request #3872 from thelounge/xpaw/eventbus
Replace vue events with our own event bus
2020-04-24 14:24:29 +03:00
Pavel Djundik f0253075d8 Create an event bus 2020-04-24 14:09:09 +03:00
Pavel Djundik 96a983b310
Merge pull request #3875 from thelounge/xpaw/statusmsg-ui
Add an indicator to statusmsg messages
2020-04-24 14:07:48 +03:00
Pavel Djundik 53bd9c2f68
Merge pull request #3868 from thelounge/xpaw/mentions-fixes
Some fixes in mentions window
2020-04-24 14:07:40 +03:00
Pavel Djundik ad6569cf06 Add an indicator to statusmsg messages 2020-04-24 11:46:39 +03:00
Pavel Djundik 4ac25d4bc5
Merge pull request #3871 from ebardie/ebardie/dont_load_extinct_users
Filter user loading at startup for "advanced" LDAP
2020-04-24 10:21:42 +03:00
Jonathan Sambrook 878ac0d192 Filter user loading at startup for "advanced" LDAP
Users are loaded at startup. Currently when using "advanced" LDAP
authentication this is true even if they no longer have a
valid entry in the LDAP server.

This commit uses the existing LDAP filter (specified in config.js's searchDN
used by the "advanced" LDAP mechanism) to weed out any users that no
longer have the relevant LDAP entry.

Local and "simple" LDAP auth mechanisms continue to use the existing
load all users approach. In the "simple" LDAP case this is because we
only have access to the hashed password, and so can't bind to LDAP.
2020-04-23 15:54:09 +01:00
Jonathan Sambrook a0d10989ad Tidy up the auth plugin API mechanism to hide implementation details
The caller doesn't care which plugin is being used, so this commit
consolidates implementation details within auth.js

The motivation for this work is to prepare for extending the auth API
(to allow "advanced" LDAP to query user entry ontological state at start
up), by tidying up rather than duplicating the existing mechanism.
2020-04-23 15:11:35 +01:00
Pavel Djundik 36844f948c
Merge pull request #3870 from thelounge/xpaw/random-nick-safeguard
Safeguard nick randomizer up to allowed length
2020-04-23 12:47:44 +03:00
Pavel Djundik 2b0afcacf2 Safeguard nick randomizer up to allowed length 2020-04-22 15:18:55 +03:00
Pavel Djundik beb9fbd940 Set native app badge for highlights 2020-04-22 14:16:39 +03:00
Pavel Djundik 0642ae58ce
Merge pull request #3844 from thelounge/xpaw/certfp
CertFP support; separate SASL configuration
2020-04-22 14:05:34 +03:00
Pavel Djundik 635b8b3eef
Merge pull request #3866 from thelounge/renovate/vue-monorepo
Update dependency vuex to v3.2.0
2020-04-22 14:05:15 +03:00
Pavel Djundik bcd2e7cb08 Some fixes in mentions window 2020-04-20 13:40:45 +03:00
Renovate Bot 91b48e061d
Update dependency vuex to v3.2.0 2020-04-20 08:16:43 +00:00
Pavel Djundik 89edc6aa30
Merge pull request #3867 from maxpoulin64/fix-manifest-color
Fix off-by-one color error in webmanifest
2020-04-20 10:22:40 +03:00
Maxime Poulin be78a5809a Fix off-by-one color error in webmanifest 2020-04-19 20:46:37 -04:00
Pavel Djundik ce6f188acc
Merge pull request #3863 from thelounge/renovate/semver-7.x
Update dependency semver to v7.3.2
2020-04-16 14:21:47 +03:00
Pavel Djundik b8eaae3a50
Merge pull request #3864 from thelounge/xpaw/rm-polyfill
Remove intersection-observer polyfill
2020-04-16 14:21:35 +03:00
Pavel Djundik 9105a3db06 Remove intersection-observer polyfill 2020-04-15 16:14:21 +03:00
Renovate Bot 18a10a9efb
Update dependency semver to v7.3.2 2020-04-15 08:54:49 +00:00
Pavel Djundik e772c4eab5
Merge pull request #3861 from MaxLeiter/mentionsLoading
Move mentions loading text out of header
2020-04-15 11:23:35 +03:00
Max Leiter 177d4d78ba Move mentions loading text out of header 2020-04-15 01:20:16 -07:00
Pavel Djundik fce71f4a7c Use v-model in network form, auto size commands 2020-04-15 11:12:07 +03:00
Pavel Djundik 6ee71779d1 Combine displayNetwork into lockNetwork 2020-04-15 10:56:05 +03:00
Pavel Djundik 8a281bacd8 Preliminary SASL UI 2020-04-15 10:56:04 +03:00
Pavel Djundik f8f692af05 Generate client certificates and automatically do SASL EXTERNAL 2020-04-15 10:56:04 +03:00
Pavel Djundik 3900e9dd81 Consolidate irc-framework options 2020-04-15 10:56:03 +03:00
Pavel Djundik 58553d7691
Merge pull request #3798 from thelounge/richrd/image-viewer-navigation
Implement navigation in image viewer
2020-04-13 13:05:29 +03:00
Pavel Djundik f3d2dc1678
Merge pull request #3862 from thelounge/xpaw/mentions-zindex
Render sidebar over the mentions popup on mobile
2020-04-13 13:02:22 +03:00
Pavel Djundik 05ff8530cc Render sidebar over the mentions popup on mobile 2020-04-13 12:31:56 +03:00
Pavel Djundik 0fcaa46095
Merge pull request #3859 from thelounge/renovate/stylelint-13.x
Update dependency stylelint to v13.3.2
2020-04-13 11:45:51 +03:00
Pavel Djundik 1754c77517
Merge pull request #3857 from thelounge/xpaw/optimize-userlist-updates
Optimize user list updates for quit/part/kick events
2020-04-13 11:39:57 +03:00
Pavel Djundik 999095b7df
Merge pull request #3858 from thelounge/xpaw/mentions
Track mentions and add a window to view them
2020-04-13 10:49:06 +03:00
Renovate Bot d39a6dd012
Update dependency stylelint to v13.3.2 2020-04-11 15:25:00 +00:00
Pavel Djundik bc4f9b5f51 Track mentions and add a window to view them 2020-04-11 12:49:42 +03:00
Pavel Djundik 8e00e26054
Merge pull request #3826 from thelounge/xpaw/mocharc
Upgrade to mocha@7 and remove mochapack
2020-04-11 12:32:45 +03:00
Pavel Djundik c1607bd8e7
Merge pull request #3856 from thelounge/macos-10x
Exclude node 10 on macOS from build matrix
2020-04-11 12:31:07 +03:00
Pavel Djundik 4ce2efe86b Upgrade mocha and remove mochapack 2020-04-11 12:27:08 +03:00
Pavel Djundik 99bb58a7a7
Merge pull request #3848 from thelounge/renovate/css-loader-3.x
Update dependency css-loader to v3.5.2
2020-04-11 12:23:11 +03:00
Pavel Djundik 49189d5649
Merge pull request #3849 from thelounge/renovate/dayjs-1.x
Update dependency dayjs to v1.8.24
2020-04-11 12:23:03 +03:00
Pavel Djundik 74181d0783
Merge pull request #3847 from thelounge/renovate/babel-monorepo
Update dependency @babel/preset-env to v7.9.5
2020-04-11 12:22:54 +03:00
Pavel Djundik b885673341
Merge pull request #3843 from thelounge/renovate/semver-7.x
Update dependency semver to v7.2.2
2020-04-11 12:22:47 +03:00
Pavel Djundik 013e55a9a6 Exclude node 10 on macOS from build matrix 2020-04-11 12:19:05 +03:00
Renovate Bot 2e31325de6
Update dependency semver to v7.2.2 2020-04-11 08:59:26 +00:00
Renovate Bot 0ad907982d
Update dependency dayjs to v1.8.24 2020-04-11 08:59:16 +00:00
Renovate Bot 2156c6ba97
Update dependency css-loader to v3.5.2 2020-04-11 08:59:05 +00:00
Renovate Bot e7d0ad93f9
Update dependency @babel/preset-env to v7.9.5 2020-04-11 08:58:54 +00:00
Pavel Djundik a5bb486012
Merge pull request #3850 from thelounge/renovate/husky-4.x
Update dependency husky to v4.2.5
2020-04-11 11:57:29 +03:00
Pavel Djundik a3490af5a3
Merge pull request #3851 from thelounge/renovate/prettier-2.x
Update dependency prettier to v2.0.4
2020-04-11 11:57:19 +03:00
Pavel Djundik 484ec95f24
Merge pull request #3852 from thelounge/renovate/sinon-9.x
Update dependency sinon to v9.0.2
2020-04-11 11:57:12 +03:00
Pavel Djundik d8495bdc54
Merge pull request #3853 from thelounge/renovate/stylelint-13.x
Update dependency stylelint to v13.3.1
2020-04-11 11:57:03 +03:00
Pavel Djundik 2891c1e89c
Merge pull request #3854 from thelounge/renovate/vue-monorepo
Update vue monorepo to v1.0.0-beta.33
2020-04-11 11:56:56 +03:00
Pavel Djundik c8eee85b28
Merge pull request #3855 from thelounge/renovate/emoji-regex-9.x
Update dependency emoji-regex to v9
2020-04-11 11:56:48 +03:00
Renovate Bot f0d985637b
Update dependency emoji-regex to v9 2020-04-11 05:31:07 +00:00
Renovate Bot dc288c4c66
Update vue monorepo to v1.0.0-beta.33 2020-04-11 04:40:14 +00:00
Renovate Bot d810c3aec9
Update dependency stylelint to v13.3.1 2020-04-11 04:40:00 +00:00
Renovate Bot 5f06cfe483
Update dependency sinon to v9.0.2 2020-04-11 03:19:38 +00:00
Renovate Bot 9db3b009f3
Update dependency prettier to v2.0.4 2020-04-11 03:19:23 +00:00
Renovate Bot de6aa7df90
Update dependency husky to v4.2.5 2020-04-11 01:41:04 +00:00
Pavel Djundik b72e49c902
Merge pull request #3764 from thelounge/xpaw/canvas-upload
Render images in canvas before upload to remove exif
2020-04-07 21:51:46 +03:00
Pavel Djundik 63f412aab1
Merge pull request #3842 from thelounge/renovate/prettier-2.x
Update dependency prettier to v2.0.3
2020-04-06 10:23:59 +03:00
Renovate Bot b756de003e
Update dependency prettier to v2.0.3 2020-04-05 21:14:07 +00:00
Pavel Djundik aa96a2ad31
Merge pull request #3841 from thelounge/renovate/stylelint-13.x
Update dependency stylelint to v13.3.0
2020-04-05 10:25:24 +03:00
Pavel Djundik 37cbc1562c
Merge pull request #3840 from thelounge/renovate/nyc-15.x
Update dependency nyc to v15.0.1
2020-04-05 10:25:17 +03:00
Pavel Djundik 5b555835e2
Merge pull request #3839 from thelounge/renovate/chalk-4.x
Update dependency chalk to v4
2020-04-05 10:25:08 +03:00
Renovate Bot 119afbdf2f
Update dependency stylelint to v13.3.0 2020-04-04 00:31:30 +00:00
Renovate Bot 31f814d66d
Update dependency nyc to v15.0.1 2020-04-04 00:31:15 +00:00
Renovate Bot d584dd7e11
Update dependency chalk to v4 2020-04-02 08:25:29 +00:00
Pavel Djundik ee2e2608a3
Merge pull request #3838 from thelounge/renovate/irc-framework-4.x
Update dependency irc-framework to v4.8.1
2020-04-02 10:44:02 +03:00
Renovate Bot 7e321d399c
Update dependency irc-framework to v4.8.1 2020-04-02 07:07:28 +00:00
Pavel Djundik 86d84b70a1
Merge pull request #3837 from thelounge/renovate/uuid-7.x
Update dependency uuid to v7.0.3
2020-04-01 10:51:17 +03:00
Renovate Bot c5596f658e
Update dependency uuid to v7.0.3 2020-03-31 19:49:44 +00:00
Pavel Djundik 16ade38851
Merge pull request #3829 from thelounge/renovate/got-10.x
Update dependency got to v10.7.0
2020-03-30 23:27:06 +03:00
Renovate Bot 87d902d028
Update dependency got to v10.7.0 2020-03-30 20:19:46 +00:00
Pavel Djundik 600313fded
Merge pull request #3835 from thelounge/renovate/webpack-4.x
Update dependency webpack to v4.42.1
2020-03-30 23:18:57 +03:00
Pavel Djundik 4641cb4b8c
Merge pull request #3834 from thelounge/renovate/prettier-2.x
Update dependency prettier to v2.0.2
2020-03-30 23:18:48 +03:00
Pavel Djundik 599c4ce769
Merge pull request #3832 from thelounge/renovate/font-awesome
Update dependency @fortawesome/fontawesome-free to v5.13.0
2020-03-30 23:18:39 +03:00
Pavel Djundik b14a8a267a Render uploaded images in canvas to remove exif 2020-03-30 23:16:26 +03:00
Renovate Bot 55e99b299a
Update dependency webpack to v4.42.1 2020-03-28 01:40:29 +00:00
Renovate Bot 20e47aaadb
Update dependency prettier to v2.0.2 2020-03-28 01:40:14 +00:00
Renovate Bot 55767d733d
Update dependency @fortawesome/fontawesome-free to v5.13.0 2020-03-28 00:20:40 +00:00
Pavel Djundik 56dfa5ef40
Merge pull request #3828 from stevenengler/patch-1
Fix body overscroll and overflow on iOS Safari
2020-03-23 22:01:45 +02:00
Steven Engler 39e70670b5
Fix body overscroll and overflow on iOS Safari
In order to prevent scrolling past the edges of the body (overscroll)
in Safari on iOS, the overflow must be hidden (not only overflow-y).
2020-03-23 15:15:30 -04:00
Pavel Djundik beac893dd0
Merge pull request #3825 from thelounge/renovate/eslint-config-prettier-6.x
Update dependency eslint-config-prettier to v6.10.1
2020-03-23 12:16:12 +02:00
Renovate Bot 6e655d457e
Update dependency eslint-config-prettier to v6.10.1 2020-03-23 08:21:01 +00:00
Pavel Djundik a7db950a52
Merge pull request #3824 from thelounge/renovate/prettier-2.x
Update dependency prettier to v2.0.1
2020-03-23 10:19:54 +02:00
Pavel Djundik f4528e6f00
Merge pull request #3810 from thelounge/xpaw/remove-fs-extra
Replace all uses of `fs-extra` with native methods
2020-03-23 10:19:44 +02:00
Pavel Djundik c35412625e
Merge pull request #3814 from thelounge/xpaw/fix-3813
Do not handle navigation keybinds in inputs if not empty
2020-03-23 10:19:37 +02:00
Renovate Bot 4c3594b832
Update dependency prettier to v2.0.1 2020-03-22 19:20:33 +00:00
Pavel Djundik 52bf7b116e Do not handle keybinds in inputs if not empty
Fix #3813
2020-03-22 20:44:14 +02:00
Pavel Djundik 6de6f8185e Clean up folders in after test runs 2020-03-22 20:43:06 +02:00
Pavel Djundik 487a438f02 Replace all uses of fs-extra with native methods 2020-03-22 20:42:16 +02:00
Pavel Djundik 4bf4b7baf0
Merge pull request #3797 from thelounge/xpaw/fix-changelog-check
Pass in client manager object in update checker
2020-03-22 20:41:39 +02:00
Pavel Djundik 9c2607df89
Merge pull request #3820 from thelounge/renovate/babel-monorepo
Update babel monorepo
2020-03-22 11:48:48 +02:00
Pavel Djundik 05e806d762
Merge pull request #3821 from thelounge/renovate/dayjs-1.x
Update dependency dayjs to v1.8.23
2020-03-22 11:48:41 +02:00
Pavel Djundik 68618da7f1
Merge pull request #3822 from thelounge/renovate/vue-monorepo
Update dependency vue-loader to v15.9.1
2020-03-22 11:48:33 +02:00
Pavel Djundik eb171c01f5
Merge pull request #3823 from thelounge/renovate/prettier-2.x
Update dependency prettier to v2
2020-03-22 11:48:24 +02:00
Pavel Djundik 881b3eda19 Run format after updating to prettier 2.0 2020-03-21 22:55:36 +02:00
Renovate Bot a46c9e8403
Update dependency prettier to v2 2020-03-21 20:10:25 +00:00
Renovate Bot 9da36b9966
Update dependency vue-loader to v15.9.1 2020-03-21 01:33:57 +00:00
Renovate Bot c2e6b13504
Update dependency dayjs to v1.8.23 2020-03-21 00:54:46 +00:00
Renovate Bot 42d568ad2c
Update babel monorepo 2020-03-21 00:54:32 +00:00
Pavel Djundik 4b29cdeb0c
Merge pull request #3817 from thelounge/xpaw/fix-2562
Separate active sessions section
2020-03-20 13:42:49 +02:00
Pavel Djundik a3c204f978
Merge pull request #3816 from thelounge/xpaw/public-connect
Remove "The Lounge" from connect in public
2020-03-19 21:39:29 +02:00
Pavel Djundik 0f1e7d5036
Merge pull request #3815 from thelounge/xpaw/esc-help
Add escape key description to help section
2020-03-19 21:39:10 +02:00
Pavel Djundik a6f70696f3 Separate active sessions section
Fixes #2562
2020-03-17 17:36:13 +02:00
Pavel Djundik 4c177b8d02 Remove "The Lounge" from connect in public 2020-03-17 12:20:32 +02:00
Pavel Djundik ecda9e225e Add escape key to help section 2020-03-17 12:06:50 +02:00
Pavel Djundik a9d2b30d96
Merge pull request #3808 from thelounge/xpaw/fix-part
Fix up first argument not being used as part message
2020-03-16 20:29:46 +02:00
Pavel Djundik 424bc4f7df Fix up first argument not being used as part message 2020-03-15 20:16:53 +02:00
Pavel Djundik 52002c3e22
Merge pull request #3807 from thelounge/renovate/commander-5.x
Update dependency commander to v5
2020-03-15 14:03:35 +02:00
Pavel Djundik 80b0e8ad12 Fix up commander changes 2020-03-15 14:00:02 +02:00
Renovate Bot b000a594f4 Update dependency commander to v5 2020-03-15 14:00:02 +02:00
Pavel Djundik 0ec242738f
Merge pull request #3801 from thelounge/renovate/file-type-14.x
Update dependency file-type to v14.1.4
2020-03-15 13:54:05 +02:00
Pavel Djundik 5e935189a3
Merge pull request #3804 from thelounge/renovate/sinon-9.x
Update dependency sinon to v9.0.1
2020-03-15 13:53:56 +02:00
Pavel Djundik 0035910765
Merge pull request #3805 from thelounge/renovate/stylelint-13.x
Update dependency stylelint to v13.2.1
2020-03-15 13:53:49 +02:00
Renovate Bot 9e6b6c582f
Update dependency stylelint to v13.2.1 2020-03-15 11:25:32 +00:00
Renovate Bot b7562362c1
Update dependency sinon to v9.0.1 2020-03-15 11:25:22 +00:00
Renovate Bot 9926c83ba7
Update dependency file-type to v14.1.4 2020-03-15 11:25:11 +00:00
Pavel Djundik 6c0acfcfb7
Merge pull request #3806 from thelounge/renovate/vue-monorepo
Update vue monorepo
2020-03-15 13:24:40 +02:00
Renovate Bot babadbd955
Update vue monorepo 2020-03-14 01:14:38 +00:00
Pavel Djundik 64aa510abf Bind events in image viewer only once 2020-03-10 15:36:58 +02:00
Pavel Djundik 0b38a88147
Merge pull request #3800 from thelounge/renovate/yarn-1.x
Update dependency yarn to v1.22.4
2020-03-10 15:21:45 +02:00
Pavel Djundik b3fa46ad10
Merge pull request #3799 from thelounge/xpaw/upgrade-lock
Upgrade yarn.lock
2020-03-10 15:02:34 +02:00
Renovate Bot 5e30d3698d
Update dependency yarn to v1.22.4 2020-03-10 12:42:59 +00:00
Pavel Djundik 04de1ebc30 Update yarn.lock 2020-03-10 12:58:49 +02:00
Richard Lewis ef473b0f53 Implement navigation in image viewer. 2020-03-09 18:54:21 +02:00
Pavel Djundik 0e62103010 Pass in client manager object in update checker 2020-03-09 17:58:40 +02:00
Pavel Djundik a4ef328d8d
Merge pull request #3778 from thelounge/xpaw/clear-history
Clear channel history (and a new confirmation dialog)
2020-03-09 15:59:37 +02:00
Pavel Djundik e5596d9d81
Merge pull request #3796 from thelounge/renovate/eslint-plugin-vue-6.x
Update dependency eslint-plugin-vue to v6.2.2
2020-03-09 10:37:13 +02:00
Pavel Djundik e47e54b934
Merge pull request #3787 from thelounge/xpaw/12h
Add an option to display 12h times
2020-03-09 10:36:07 +02:00
Pavel Djundik b8de7e68b5
Merge pull request #3790 from thelounge/xpaw/settings-group
Add role=group to status messages setting
2020-03-09 10:36:00 +02:00
Pavel Djundik 464c54b2cb
Merge pull request #3789 from thelounge/xpaw/fix-unhandled-chan
Fix sending unhandled numerics to target channel
2020-03-09 10:35:49 +02:00
Pavel Djundik 6b46a55def
Merge pull request #3795 from thelounge/renovate/webpack-4.x
Update dependency webpack to v4.42.0
2020-03-09 10:35:03 +02:00
Pavel Djundik 37f4e0ff93
Merge pull request #3792 from thelounge/renovate/yarn-1.x
Update dependency yarn to v1.22.1
2020-03-09 10:34:56 +02:00
Pavel Djundik 9277907c43
Merge pull request #3791 from thelounge/renovate/uuid-7.x
Update dependency uuid to v7.0.2
2020-03-09 10:34:49 +02:00
Pavel Djundik 960862df27
Merge pull request #3786 from thelounge/renovate/vue-monorepo
Update dependency vue-router to v3.1.6
2020-03-09 10:34:42 +02:00
Pavel Djundik 1376177a09
Merge pull request #3785 from thelounge/renovate/dayjs-1.x
Update dependency dayjs to v1.8.22
2020-03-09 10:34:34 +02:00
Pavel Djundik d2994d501f
Merge pull request #3784 from thelounge/renovate/babel-monorepo
Update babel monorepo to v7.8.7
2020-03-09 10:34:26 +02:00
Renovate Bot 1ea9d6c2ac
Update dependency yarn to v1.22.1 2020-03-09 08:06:10 +00:00
Renovate Bot 0a6f8a76ec
Update dependency webpack to v4.42.0 2020-03-09 08:06:00 +00:00
Renovate Bot c57b42c22b
Update dependency vue-router to v3.1.6 2020-03-09 08:05:49 +00:00
Renovate Bot f93061de29
Update dependency uuid to v7.0.2 2020-03-09 08:05:39 +00:00
Renovate Bot 06a15181c3
Update dependency eslint-plugin-vue to v6.2.2 2020-03-09 08:05:30 +00:00
Renovate Bot d11b704f22
Update dependency dayjs to v1.8.22 2020-03-09 08:05:20 +00:00
Renovate Bot 48f96e9ae0
Update babel monorepo to v7.8.7 2020-03-09 08:04:57 +00:00
Pavel Djundik 47b254a29e v4.1.0 2020-03-08 22:53:26 +02:00
Pavel Djundik 7ba2807b01 Add changelog for v4.1.0 2020-03-08 22:53:01 +02:00
Pavel Djundik 6121a3ab0b
Merge pull request #3793 from NotWoods/patch-1
Add maskable purpose & removes monochrome
2020-03-06 23:50:24 +02:00
Tiger Oakes d8ab40d8ee
Add maskable purpose to PNG icons 2020-03-06 13:37:27 -08:00
Pavel Djundik 8d119630eb Add role=group to status messages setting
Fixes #1909
2020-03-03 12:15:42 +02:00
Pavel Djundik 5233fb2dbb Fix sending unhandled numerics to target channel 2020-03-03 11:47:09 +02:00
Pavel Djundik 234938ed4b Fix up time width with AM/PM 2020-02-29 11:51:12 +02:00
Pavel Djundik 3630ab8519 Add an option to display 12h times 2020-02-29 11:37:45 +02:00
Pavel Djundik c463d1ddd3 Emit an event to clear history on all open clients 2020-02-28 17:01:28 +02:00
Pavel Djundik 44a8925b8c Create a generic confirmation dialog 2020-02-28 17:01:28 +02:00
Pavel Djundik 7216b8124b Add context menu to clear channel history 2020-02-28 17:01:28 +02:00
Pavel Djundik eb7f9ab298 Implement channel history clearing on the server 2020-02-28 17:01:28 +02:00
Pavel Djundik 6aabd9bacb Optimize user list updates for quit/part/kick events 2020-02-01 19:15:46 +02:00
427 changed files with 30299 additions and 20500 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,69 +0,0 @@
---
root: true
parserOptions:
ecmaVersion: 2018
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/require-default-prop: off
vue/no-v-html: off
vue/no-use-v-if-with-v-for: off
plugins:
- vue
extends:
- eslint:recommended
- plugin:vue/recommended
- prettier
- prettier/vue

View file

@ -4,7 +4,7 @@ about: Create a bug report
labels: "Type: Bug"
---
<!-- Have a question? Join #thelounge on freenode -->
<!-- Have a question? Join #thelounge on Libera.Chat -->
- _Node version:_
- _Browser version:_

View file

@ -4,7 +4,7 @@ about: Request a new feature
labels: "Type: Feature"
---
<!-- Have a question? Join #thelounge on freenode. -->
<!-- Have a question? Join #thelounge on Libera.Chat. -->
<!-- Make sure to check the existing issues prior to submitting your suggestion. -->
### Feature Description

16
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View file

@ -0,0 +1,16 @@
contact_links:
- name: Docker container issues
url: https://github.com/thelounge/thelounge-docker/issues
about: Report issues related to the Docker container here
- name: Debian package issues
url: https://github.com/thelounge/thelounge-deb/issues
about: Report issues related to the Debian package here
- name: Arch Linux package issues
url: https://github.com/thelounge/thelounge-archlinux/issues
about: Report issues related to the Arch Linux package here
- name: General support
url: https://demo.thelounge.chat/?join=%23thelounge
about: "Join #thelounge on Libera.Chat to ask a question before creating an issue"

2
.github/SUPPORT.md vendored
View file

@ -6,6 +6,6 @@ need help, you have a few options:
- Check out [existing questions on Stack Overflow](https://stackoverflow.com/questions/tagged/thelounge)
to see if yours has been answered before. If not, feel free to [ask for a new question](https://stackoverflow.com/questions/ask?tags=thelounge)
(using `thelounge` tag so that other people can easily find it).
- Find us on the Freenode channel `#thelounge`. You might not get an answer
- Find us on the Libera.Chat channel `#thelounge`. You might not get an answer
right away, but this channel is full of nice people who will be happy to
help you.

View file

@ -1,5 +1,8 @@
name: Build
permissions:
contents: read
on: [push, pull_request]
jobs:
@ -8,11 +11,20 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macOS-latest]
node_version: [
10.x, # EOL: April 2021
12.x, # EOL: April 2022
]
include:
# EOL: April 2025
- os: macOS-latest
node_version: 18.x
- os: windows-latest
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 }}
@ -20,7 +32,7 @@ jobs:
- uses: actions/checkout@master
- name: Setup Node.js
uses: actions/setup-node@v1
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@v1
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,18 +0,0 @@
extends: stylelint-config-standard
ignoreFiles:
- client/css/bootstrap.css
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

10
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,10 @@
{
"recommendations": [
"EditorConfig.EditorConfig",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"Vue.volar",
"Vue.vscode-typescript-vue-plugin"
],
"unwantedRecommendations": []
}

11
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,11 @@
{
"configurations": [
{
"type": "node-terminal",
"name": "Run Dev",
"request": "launch",
"command": "yarn dev",
"cwd": "${workspaceFolder}"
}
]
}

10
.vscode/settings.json vendored Normal file
View file

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

File diff suppressed because it is too large Load diff

View file

@ -16,21 +16,20 @@
<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">
<a href="https://demo.thelounge.chat/"><img
alt="#thelounge IRC channel on freenode"
src="https://img.shields.io/badge/freenode-%23thelounge-415364.svg?colorA=ff9e18"></a>
alt="#thelounge IRC channel on Libera.Chat"
src="https://img.shields.io/badge/Libera.Chat-%23thelounge-415364.svg?colorA=ff9e18"></a>
<a href="https://yarn.pm/thelounge"><img
alt="npm version"
src="https://img.shields.io/npm/v/thelounge.svg?colorA=333a41&maxAge=3600"></a>
<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/views`
- 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.

View file

@ -4,6 +4,6 @@
- Contact us privately first, in a
[responsible disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure)
manner.
- On IRC, send a private message to any voiced user on our Freenode channel,
- On IRC, send a private message to any voiced user on our Libera.Chat channel,
`#thelounge`.
- By email, send us your report at <security@thelounge.chat>.

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,115 +1,132 @@
<template>
<div id="viewport" :class="viewportClasses" role="tablist">
<Sidebar v-if="$store.state.appLoaded" :overlay="$refs.overlay" />
<div id="sidebar-overlay" ref="overlay" @click="$store.commit('sidebarOpen', false)" />
<router-view ref="window"></router-view>
<Sidebar v-if="store.state.appLoaded" :overlay="overlay" />
<div
id="sidebar-overlay"
ref="overlay"
aria-hidden="true"
@click="store.commit('sidebarOpen', false)"
/>
<router-view ref="loungeWindow"></router-view>
<Mentions />
<ImageViewer ref="imageViewer" />
<ContextMenu ref="contextMenu" />
<ConfirmDialog ref="confirmDialog" />
<div id="upload-overlay"></div>
</div>
</template>
<script>
const constants = require("../js/constants");
import Mousetrap from "mousetrap";
<script lang="ts">
import constants from "../js/constants";
import eventbus from "../js/eventbus";
import Mousetrap, {ExtendedKeyboardEvent} from "mousetrap";
import throttle from "lodash/throttle";
import storage from "../js/localStorage";
import isIgnoredKeybind from "../js/helpers/isIgnoredKeybind";
import Sidebar from "./Sidebar.vue";
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,
ImageViewer,
ContextMenu,
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);
});
// Make a single throttled resize listener available to all components
this.debouncedResize = throttle(() => {
this.$root.$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 = () => {
this.$root.$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);
window.removeEventListener("resize", this.debouncedResize);
clearTimeout(this.dayChangeTimeout);
},
methods: {
escapeKey() {
this.$root.$emit("escapekey");
},
toggleSidebar(e) {
// Do not handle this keybind in the chat input because
// it can be used to type letters with umlauts
if (e.target.tagName === "TEXTAREA") {
const toggleSidebar = (e: ExtendedKeyboardEvent) => {
if (isIgnoredKeybind(e)) {
return true;
}
this.$store.commit("toggleSidebar");
store.commit("toggleSidebar");
return false;
},
toggleUserList(e) {
// Do not handle this keybind in the chat input because
// it can be used to type letters with umlauts
if (e.target.tagName === "TEXTAREA") {
};
const toggleUserList = (e: ExtendedKeyboardEvent) => {
if (isIgnoredKeybind(e)) {
return true;
}
this.$store.commit("toggleUserlist");
store.commit("toggleUserlist");
return false;
},
msUntilNextDay() {
};
const toggleMentions = () => {
if (store.state.networks.length !== 0) {
eventbus.emit("mentions:toggle");
}
};
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
@ -118,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

@ -8,11 +8,14 @@
{active: active},
{'parted-channel': channel.type === 'channel' && channel.state === 0},
{'has-draft': channel.pendingMessage},
{'has-unread': channel.unread},
{'has-highlight': channel.highlight},
{
'not-secure':
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()"
@ -20,66 +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 = [];
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) {
extra.push(`${this.channel.unread} unread`);
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(`${props.channel.unread} unread message`);
}
}
if (this.channel.highlight > 0) {
extra.push(`${this.channel.highlight} mention`);
if (props.channel.highlight > 0) {
if (props.channel.highlight > 1) {
extra.push(`${props.channel.highlight} mentions`);
} else {
extra.push(`${props.channel.highlight} mention`);
}
}
if (extra.length > 0) {
return `${this.channel.name} (${extra.join(", ")})`;
}
return `${type}: ${props.channel.name} ${extra.length ? `(${extra.join(", ")})` : ""}`;
};
return this.channel.name;
},
click() {
if (this.isFiltering) {
const click = () => {
if (props.isFiltering) {
return;
}
this.$root.switchToChannel(this.channel);
},
openContextMenu(event) {
this.$root.$emit("contextmenu:channel", {
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

@ -1,11 +1,12 @@
<template>
<div id="chat-container" class="window" :data-current-channel="channel.name">
<div id="chat-container" class="window" :data-current-channel="channel.name" lang="">
<div
id="chat"
:class="{
'hide-motd': !$store.state.settings.motd,
'colored-nicks': $store.state.settings.coloredNicks,
'show-seconds': $store.state.settings.showSeconds,
'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
@ -17,13 +18,16 @@
>
<div class="header">
<SidebarToggle />
<span class="title">{{ channel.name }}</span>
<span class="title" :aria-label="'Currently open ' + channel.type">{{
channel.name
}}</span>
<div v-if="channel.editTopic === true" class="topic-container">
<input
ref="topicInput"
:value="channel.topic"
class="topic-input"
placeholder="Set channel topic"
enterkeyhint="done"
@keyup.enter="saveTopic"
@keyup.esc="channel.editTopic = false"
/>
@ -31,12 +35,29 @@
<span type="button" aria-label="Save topic"></span>
</span>
</div>
<span v-else :title="channel.topic" class="topic" @dblclick="editTopic"
<span
v-else
:title="channel.topic"
:class="{topic: true, empty: !channel.topic}"
@dblclick="editTopic"
><ParsedMessage
v-if="channel.topic"
:network="network"
:text="channel.topic"
/></span>
<MessageSearchForm
v-if="
store.state.settings.searchEnabled &&
['channel', 'query'].includes(channel.type)
"
:network="network"
:channel="channel"
/>
<button
class="mentions"
aria-label="Open your mentions"
@click="openMentions"
/>
<button
class="menu"
aria-label="Open the context menu"
@ -50,7 +71,7 @@
<button
class="rt"
aria-label="Toggle user list"
@click="$store.commit('toggleUserlist')"
@click="store.commit('toggleUserlist')"
/>
</span>
</div>
@ -58,7 +79,7 @@
<div class="chat">
<div class="messages">
<div class="msg">
<Component
<component
:is="specialComponent"
:network="network"
:channel="channel"
@ -74,39 +95,50 @@
{'scroll-down-shown': !channel.scrolledToBottom},
]"
aria-label="Jump to recent messages"
@click="$refs.messageList.jumpToBottom()"
@click="messageList?.jumpToBottom()"
>
<div class="scroll-down-arrow" />
</div>
<MessageList ref="messageList" :network="network" :channel="channel" />
<ChatUserList v-if="channel.type === 'channel'" :channel="channel" />
<MessageList
ref="messageList"
:network="network"
:channel="channel"
:focused="focused"
/>
</div>
</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";
import MessageList from "./MessageList.vue";
import ChatInput from "./ChatInput.vue";
import ChatUserList from "./ChatUserList.vue";
import SidebarToggle from "./SidebarToggle.vue";
import MessageSearchForm from "./MessageSearchForm.vue";
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,
@ -114,89 +146,129 @@ export default {
ChatInput,
ChatUserList,
SidebarToggle,
MessageSearchForm,
},
props: {
network: Object,
channel: Object,
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) {
this.$root.$emit("contextmenu:channel", {
};
const openContextMenu = (event: any) => {
eventbus.emit("contextmenu:channel", {
event: event,
channel: this.channel,
network: this.network,
channel: props.channel,
network: props.network,
});
},
};
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

@ -7,14 +7,16 @@
ref="input"
dir="auto"
class="mousetrap"
enterkeyhint="send"
:value="channel.pendingMessage"
:placeholder="getInputPlaceholder(channel)"
: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"
@ -24,6 +26,7 @@
id="upload-input"
ref="uploadInput"
type="file"
aria-labelledby="upload"
multiple
@change="onUploadInputChange"
/>
@ -31,7 +34,7 @@
id="upload"
type="button"
aria-label="Upload file"
:disabled="!$store.state.isConnected"
:disabled="!store.state.isConnected"
/>
</span>
<span
@ -43,19 +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",
@ -82,186 +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() {
this.$root.$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 {channel} = this;
if (channel.inputHistoryPosition === 0) {
channel.inputHistory[channel.inputHistoryPosition] = channel.pendingMessage;
}
if (key === "up") {
if (channel.inputHistoryPosition < channel.inputHistory.length - 1) {
channel.inputHistoryPosition++;
const setInputSize = () => {
void nextTick(() => {
if (!input.value) {
return;
}
} else if (channel.inputHistoryPosition > 0) {
channel.inputHistoryPosition--;
}
channel.pendingMessage = channel.inputHistory[channel.inputHistoryPosition];
this.$refs.input.value = channel.pendingMessage;
this.setInputSize();
return false;
});
if (this.$store.state.serverConfiguration.fileUpload) {
upload.mounted();
}
},
destroyed() {
this.$root.$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(() => {
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

@ -1,5 +1,10 @@
<template>
<aside ref="userlist" class="userlist" @mouseleave="removeHoverUser">
<aside
ref="userlist"
class="userlist"
:aria-label="'User list for ' + channel.name"
@mouseleave="removeHoverUser"
>
<div class="count">
<input
ref="input"
@ -23,17 +28,19 @@
<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"
:key="user.original.nick + '-search'"
:on-hover="hoverUser"
:active="user.original === activeUser"
:user="user.original"
v-html="user.original.mode + user.string"
v-html="user.string"
/>
<!-- eslint-enable -->
</template>
<template v-else>
<Username
@ -49,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 = {
@ -63,75 +73,89 @@ 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) {
if (!groups[user.original.mode]) {
groups[user.original.mode] = [];
const mode: string = user.original.modes[0] || "";
if (!groups[mode]) {
groups[mode] = [];
}
groups[user.original.mode].push(user);
// Prepend user mode to search result
user.string = mode + user.string;
groups[mode].push(user);
}
} else {
for (const user of this.channel.users) {
if (!groups[user.mode]) {
groups[user.mode] = [user];
for (const user of props.channel.users) {
const mode = user.modes[0] || "";
if (!groups[mode]) {
groups[mode] = [user];
} else {
groups[user.mode].push(user);
groups[mode].push(user);
}
}
}
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,
@ -141,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;
}
@ -188,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

@ -0,0 +1,102 @@
<template>
<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>
<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>
</div>
</div>
</div>
</template>
<style>
#confirm-dialog {
background: var(--body-bg-color);
color: #fff;
margin: 10px;
border-radius: 5px;
max-width: 500px;
}
#confirm-dialog .confirm-text {
padding: 15px;
user-select: text;
}
#confirm-dialog .confirm-text-title {
font-size: 20px;
font-weight: 700;
margin-bottom: 10px;
}
#confirm-dialog .confirm-buttons {
display: flex;
justify-content: flex-end;
padding: 15px;
background: rgba(0, 0, 0, 0.3);
}
#confirm-dialog .confirm-buttons .btn {
margin-bottom: 0;
margin-left: 10px;
}
#confirm-dialog .confirm-buttons .btn-cancel {
border-color: transparent;
}
</style>
<script lang="ts">
import eventbus from "../js/eventbus";
import {defineComponent, onMounted, onUnmounted, ref} from "vue";
type ConfirmDialogData = {
title: string;
text: string;
button: string;
};
type ConfirmDialogCallback = {
(confirmed: boolean): void;
};
export default defineComponent({
name: "ConfirmDialog",
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,
close,
};
},
});
</script>

View file

@ -2,6 +2,7 @@
<div
v-if="isOpen"
id="context-menu-container"
:class="{passthrough}"
@click="containerClick"
@contextmenu.prevent="containerClick"
@keydown.exact.up.prevent="navigateMenu(-1)"
@ -13,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,
@ -37,141 +41,77 @@
</div>
</template>
<script>
<script lang="ts">
import {
generateUserContextMenu,
generateChannelContextMenu,
generateRemoveNetwork,
} from "../js/helpers/contextMenu.js";
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,
previousActiveElement: null,
items: [],
activeItem: -1,
style: {
left: 0,
top: 0,
},
};
},
mounted() {
this.$root.$on("escapekey", this.close);
this.$root.$on("contextmenu:user", this.openUserContextMenu);
this.$root.$on("contextmenu:channel", this.openChannelContextMenu);
this.$root.$on("contextmenu:removenetwork", this.openRemoveNetworkContextMenu);
},
destroyed() {
this.$root.$off("escapekey", this.close);
this.$root.$off("contextmenu:user", this.openUserContextMenu);
this.$root.$off("contextmenu:channel", this.openChannelContextMenu);
this.$root.$off("contextmenu:removenetwork", this.openRemoveNetworkContextMenu);
this.close();
},
methods: {
openRemoveNetworkContextMenu(data) {
const items = generateRemoveNetwork(this.$root, data.lobby);
this.open(data.event, items);
message: {
required: false,
type: Object as PropType<ClientMessage>,
},
openChannelContextMenu(data) {
const items = generateChannelContextMenu(this.$root, data.channel, data.network);
this.open(data.event, items);
},
openUserContextMenu(data) {
const {network, channel} = this.$store.state.activeChannel;
},
setup() {
const store = useStore();
const router = useRouter();
const items = generateUserContextMenu(
this.$root,
channel,
network,
channel.users.find((u) => u.nick === data.user.nick) || {nick: data.user.nick}
);
this.open(data.event, items);
},
open(event, items) {
event.preventDefault();
const isOpen = ref(false);
const passthrough = ref(false);
this.previousActiveElement = document.activeElement;
this.items = items;
this.activeItem = 0;
this.isOpen = true;
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,
});
// 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 {
@ -191,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,50 +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,
},
computed: {
localeDate() {
return dayjs(this.message.time).format("D MMMM YYYY");
message: {
type: Object as PropType<ClientMessage>,
required: true,
},
focused: Boolean,
},
mounted() {
if (this.hoursPassed() < 48) {
this.$root.$on("daychange", this.dayChange);
}
},
beforeDestroy() {
this.$root.$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) {
this.$root.$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

@ -9,6 +9,20 @@
>
<template v-if="link !== null">
<button class="close-btn" aria-label="Close"></button>
<button
v-if="previousImage"
class="previous-image-btn"
aria-label="Previous image"
@click.stop="previous"
></button>
<button
v-if="nextImage"
class="next-image-btn"
aria-label="Next image"
@click.stop="next"
></button>
<a class="open-btn" :href="link.link" target="_blank" rel="noopener"></a>
<img
@ -24,82 +38,125 @@
</div>
</template>
<script>
export default {
<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 defineComponent({
name: "ImageViewer",
data() {
return {
link: null,
position: {
x: 0,
y: 0,
},
transform: {
x: 0,
y: 0,
scale: 0,
},
};
},
computed: {
computeImageStyles() {
setup() {
const viewer = ref<HTMLDivElement>();
const image = ref<HTMLImageElement>();
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() {
// TODO: history.pushState
if (this.link === null) {
});
const closeViewer = () => {
if (link.value === null) {
return;
}
this.$root.$on("resize", this.correctPosition);
},
},
mounted() {
this.$root.$on("escapekey", this.closeViewer);
},
destroyed() {
this.$root.$off("escapekey", this.closeViewer);
},
methods: {
closeViewer() {
if (this.link === null) {
channel.value = null;
previousImage.value = null;
nextImage.value = null;
link.value = null;
};
const setPrevNextImages = () => {
if (!channel.value || !link.value) {
return null;
}
const links = channel.value.messages
.map((msg: SharedMsg) => msg.previews)
.flat()
.filter((preview) => preview && preview.thumb);
const currentIndex = links.indexOf(link.value);
previousImage.value = links[currentIndex - 1] || null;
nextImage.value = links[currentIndex + 1] || null;
};
const previous = () => {
if (previousImage.value) {
link.value = previousImage.value;
}
};
const next = () => {
if (nextImage.value) {
link.value = nextImage.value;
}
};
const prepareImage = () => {
const viewerEl = viewer.value;
const imageEl = image.value;
if (!viewerEl || !imageEl) {
return;
}
this.$root.$off("resize", this.correctPosition);
this.link = null;
},
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);
const width = viewerEl.offsetWidth;
const height = viewerEl.offsetHeight;
const scale = Math.min(1, width / imageEl.width, height / imageEl.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;
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:
@ -111,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;
@ -166,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;
@ -187,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;
@ -211,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) {
@ -234,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;
}
@ -262,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();
@ -287,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

@ -5,7 +5,7 @@
method="post"
action=""
autocomplete="off"
@keydown.esc.prevent="$emit('toggleJoinChannel')"
@keydown.esc.prevent="$emit('toggle-join-channel')"
@submit.prevent="onSubmit"
>
<input
@ -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("toggleJoinChannel");
},
inputChannel.value = "";
inputPassword.value = "";
emit("toggle-join-channel");
};
return {
inputChannel,
inputPassword,
onSubmit,
};
},
};
});
</script>

View file

@ -129,134 +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,
},
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() {
this.$root.$on("resize", this.handleResize);
return friendlysize(props.link.maxSize);
});
this.onPreviewUpdate();
},
beforeDestroy() {
this.$root.$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.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("linkPreviewToggle", 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

@ -0,0 +1,247 @@
<template>
<div
v-if="isOpen"
id="mentions-popup-container"
@click="containerClick"
@contextmenu="containerClick"
>
<div class="mentions-popup">
<div class="mentions-popup-title">
Recent mentions
<button
v-if="resolvedMessages.length"
class="btn dismiss-all-mentions"
@click="dismissAllMentions()"
>
Dismiss all
</button>
</div>
<template v-if="resolvedMessages.length === 0">
<p v-if="isLoading">Loading</p>
<p v-else>You have no recent mentions.</p>
</template>
<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 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
>{{ ` ` }}
<span :title="message.localetime" class="time">
{{ messageTime(message.time.toString()) }}
</span>
</div>
<div>
<span
class="close-tooltip tooltipped tooltipped-w"
aria-label="Dismiss this mention"
>
<button
class="msg-dismiss"
aria-label="Dismiss this mention"
@click="dismissMention(message)"
></button>
</span>
</div>
</div>
<div class="content" dir="auto">
<ParsedMessage :message="(message as any)" />
</div>
</div>
</template>
</div>
</div>
</template>
<style>
#mentions-popup-container {
z-index: 8;
}
.mentions-popup {
background-color: var(--window-bg-color);
position: absolute;
width: 400px;
right: 80px;
top: 55px;
max-height: 400px;
overflow-y: auto;
z-index: 2;
padding: 10px;
}
.mentions-popup > .mentions-popup-title {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
font-size: 20px;
}
.mentions-popup .mentions-info {
display: flex;
justify-content: space-between;
}
.mentions-popup .msg {
margin-bottom: 15px;
user-select: text;
}
.mentions-popup .msg:last-child {
margin-bottom: 0;
}
.mentions-popup .msg .content {
background-color: var(--highlight-bg-color);
border-radius: 5px;
padding: 6px;
margin-top: 2px;
word-wrap: break-word;
word-break: break-word; /* Webkit-specific */
}
.mentions-popup .msg-dismiss::before {
font-size: 20px;
font-weight: normal;
display: inline-block;
line-height: 16px;
text-align: center;
content: "×";
}
.mentions-popup .msg-dismiss:hover {
color: var(--link-color);
}
.mentions-popup .dismiss-all-mentions {
margin: 0;
padding: 4px 6px;
}
@media (min-height: 500px) {
.mentions-popup {
max-height: 60vh;
}
}
@media (max-width: 768px) {
.mentions-popup {
border-radius: 0;
border: 0;
box-shadow: none;
width: 100%;
max-height: none;
right: 0;
left: 0;
bottom: 0;
top: 45px; /* header height */
}
}
</style>
<script lang="ts">
import Username from "./Username.vue";
import ParsedMessage from "./ParsedMessage.vue";
import socket from "../js/socket";
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 defineComponent({
name: "Mentions",
components: {
Username,
ParsedMessage,
},
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 = store.getters.findChannel(message.chanId);
}
return messages.filter((message) => !message.channel?.channel.muted);
});
watch(
() => store.state.mentions,
() => {
isLoading.value = false;
}
);
const messageTime = (time: string) => {
return dayjs(time).fromNow();
};
const dismissMention = (message: ClientMention) => {
store.state.mentions.splice(
store.state.mentions.findIndex((m) => m.msgId === message.msgId),
1
);
socket.emit("mentions:dismiss", message.msgId);
};
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

@ -3,58 +3,72 @@
:id="'msg-' + message.id"
:class="[
'msg',
{self: message.self, highlight: message.highlight, 'previous-source': isPreviousSource},
{
self: message.self,
highlight: message.highlight || focused,
'previous-source': isPreviousSource,
},
]"
:data-type="message.type"
:data-command="message.command"
:data-from="message.from && message.from.nick"
>
<span :aria-label="messageTimeLocale" class="time tooltipped tooltipped-e"
>{{ messageTime }}
<span
aria-hidden="true"
:aria-label="messageTimeLocale"
class="time tooltipped tooltipped-e"
>{{ `${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>
<Component :is="messageComponent" :network="network" :message="message" />
<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" dir="auto" />&#32;<ParsedMessage
:message="message"
/>
<Username
:user="message.from"
:network="network"
:channel="channel"
dir="auto"
/>&#32;<ParsedMessage :message="message" />
<LinkPreview
v-for="preview in message.previews"
:key="preview.link"
:keep-scroll-position="keepScrollPosition"
:link="preview"
:channel="channel"
/>
</span>
</template>
<template v-else>
<span v-if="message.type === 'message'" class="from">
<template v-if="message.from && message.from.nick">
<span class="only-copy">&lt;</span>
<Username :user="message.from" />
<span class="only-copy">&gt; </span>
<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;&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">[</span>
<span class="only-copy" aria-hidden="true">[</span>
{{ message.from.nick }}
<span class="only-copy">] </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">-</span>
<Username :user="message.from" />
<span class="only-copy">- </span>
<span class="only-copy" aria-hidden="true">-</span>
<Username :user="message.from" :network="network" :channel="channel" />
<span class="only-copy" aria-hidden="true">-&nbsp;</span>
</template>
</span>
<span class="content" dir="auto">
@ -64,60 +78,96 @@
class="msg-shown-in-active tooltipped tooltipped-e"
><span></span
></span>
<span
v-if="message.statusmsgGroup"
:aria-label="`This message was only shown to users with ${message.statusmsgGroup} mode`"
class="msg-statusmsg tooltipped tooltipped-e"
><span>{{ message.statusmsgGroup }}</span></span
>
<ParsedMessage :network="network" :message="message" />
<LinkPreview
v-for="preview in message.previews"
:key="preview.link"
:keep-scroll-position="keepScrollPosition"
:link="preview"
:channel="channel"
/>
</span>
</template>
</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: {
messageTime() {
const format = this.$store.state.settings.showSeconds
? constants.timeFormats.msgWithSeconds
: constants.timeFormats.msgDefault;
setup(props) {
const store = useStore();
return dayjs(this.message.time).format(format);
},
messageTimeLocale() {
return localetime(this.message.time);
},
messageComponent() {
return "message-" + this.message.type;
},
const timeFormat = computed(() => {
let format: keyof typeof constants.timeFormats;
if (store.state.settings.use12hClock) {
format = store.state.settings.showSeconds ? "msg12hWithSeconds" : "msg12h";
} else {
format = store.state.settings.showSeconds ? "msgWithSeconds" : "msgDefault";
}
return constants.timeFormats[format];
});
const messageTime = computed(() => {
return dayjs(props.message.time).format(timeFormat.value);
});
const messageTimeLocale = computed(() => {
return localetime(props.message.time);
});
const messageComponent = computed(() => {
return "message-" + (props.message.type || "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,47 +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")
@ -65,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")
@ -84,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,10 +22,11 @@
<DateMarker
v-if="shouldDisplayDateMarker(message, id)"
:key="message.id + '-date'"
:message="message"
: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"
>
@ -38,6 +39,7 @@
:network="network"
:keep-scroll-position="keepScrollPosition"
:messages="message.messages"
:focused="message.id === focused"
/>
<Message
v-else
@ -47,24 +49,50 @@
:message="message"
:keep-scroll-position="keepScrollPosition"
:is-previous-source="isPreviousSource(message, id)"
@linkPreviewToggle="onLinkPreviewToggle"
:focused="message.id === focused"
@toggle-link-preview="onLinkPreviewToggle"
/>
</template>
</div>
</div>
</template>
<script>
require("intersection-observer");
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";
export default {
type CondensedMessageContainer = {
type: "condensed";
time: Date;
messages: ClientMessage[];
id?: number;
};
// TODO; move into component
let unreadMarkerShown = false;
export default defineComponent({
name: "MessageList",
components: {
Message,
@ -72,38 +100,108 @@ export default {
DateMarker,
},
props: {
network: Object,
channel: Object,
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);
@ -111,7 +209,7 @@ export default {
continue;
}
if (lastCondensedContainer === null) {
if (!lastCondensedContainer) {
lastCondensedContainer = {
time: message.time,
type: "condensed",
@ -121,218 +219,222 @@ 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;
}
}
return condensed;
},
},
watch: {
"channel.id"() {
this.channel.scrolledToBottom = true;
return condensed.map((message) => {
// Skip condensing single messages, it doesn't save any
// space but makes useful information harder to see
if (message.type === "condensed" && message.messages.length === 1) {
return message.messages[0];
}
// 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();
return message;
});
},
},
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});
this.$root.$on("resize", this.handleResize);
this.$nextTick(() => {
if (this.historyObserver) {
this.historyObserver.observe(this.$refs.loadMoreButton);
}
});
},
beforeUpdate() {
this.unreadMarkerShown = false;
},
beforeDestroy() {
this.$root.$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;
}
return new Date(previousMessage.time).getDay() !== new Date(message.time).getDay();
},
shouldDisplayUnreadMarker(id) {
if (!this.unreadMarkerShown && id > this.channel.firstUnread) {
this.unreadMarkerShown = true;
const oldDate = new Date(previousMessage.time);
const newDate = new Date(message.time);
return (
oldDate.getDate() !== newDate.getDate() ||
oldDate.getMonth() !== newDate.getMonth() ||
oldDate.getFullYear() !== newDate.getFullYear()
);
};
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

@ -0,0 +1,175 @@
<template>
<form :class="['message-search', {opened: searchOpened}]" @submit.prevent="searchMessages">
<div class="input-wrapper">
<input
ref="searchInputField"
v-model="searchInput"
type="search"
name="search"
class="input"
placeholder="Search messages…"
@blur="closeSearch"
@keyup.esc="closeSearch"
/>
</div>
<button
v-if="!onSearchPage"
class="search"
type="button"
aria-label="Search messages in this channel"
@mousedown.prevent="toggleSearch"
/>
</form>
</template>
<style>
form.message-search {
display: flex;
}
form.message-search .input-wrapper {
display: flex;
}
form.message-search input {
width: 100%;
height: auto !important;
margin: 7px 0;
border: 0;
color: inherit;
background-color: #fafafa;
appearance: none;
}
form.message-search input::placeholder {
color: rgba(0, 0, 0, 0.35);
}
@media (min-width: 480px) {
form.message-search input {
min-width: 140px;
}
form.message-search input:focus {
min-width: 220px;
}
}
form.message-search .input-wrapper {
position: absolute;
top: 45px;
left: 0;
right: 0;
z-index: 1;
height: 0;
overflow: hidden;
background: var(--window-bg-color);
}
form.message-search .input-wrapper input {
margin: 7px;
}
form.message-search.opened .input-wrapper {
height: 50px;
}
#chat form.message-search button {
display: flex;
color: #607992;
}
</style>
<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: {type: Object as PropType<ClientNetwork>, required: true},
channel: {type: Object as PropType<ClientChan>, required: true},
},
setup(props) {
const searchOpened = ref(false);
const searchInput = ref("");
const router = useRouter();
const route = useRoute();
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);
}
});
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;
}
searchOpened.value = true;
searchInputField.value?.focus();
};
const searchMessages = (event: Event) => {
event.preventDefault();
if (!searchInput.value) {
return;
}
router
.push({
name: "SearchResults",
params: {
id: props.channel.id,
},
query: {
q: searchInput.value,
},
})
.catch((err) => {
if (err.name === "NavigationDuplicated") {
// Search for the same query again
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

@ -6,22 +6,33 @@
>username to <b>{{ message.new_ident }}</b></span
>
<span v-if="message.new_host"
>hostname to <i class="hostmask">{{ message.new_host }}</i></span
>
>hostname to
<i class="hostmask"><ParsedMessage :network="network" :text="message.new_host" /></i
></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: "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

@ -2,23 +2,31 @@
<span class="content">
<Username :user="message.from" />
sent a <abbr title="Client-to-client protocol">CTCP</abbr> request:
<span class="ctcp-message"><ParsedMessage :text="message.ctcpMessage"/></span>
<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: "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,22 +1,38 @@
<template>
<span class="content">
<Username :user="message.from" />
<i class="hostmask"> ({{ message.hostmask }})</i>
<i class="hostmask">&#32;(<ParsedMessage :network="network" :text="message.hostmask" />)</i>
<template v-if="message.account">
<i class="account">&#32;[{{ message.account }}]</i>
</template>
<template v-if="message.gecos">
<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

@ -1,29 +1,37 @@
<template>
<span class="content">
<span class="text"><ParsedMessage :network="network" :text="cleanText"/></span>
<span class="text"><ParsedMessage :network="network" :text="cleanText" /></span>
</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 {
name: "MessageTypeMOTD",
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

@ -1,26 +1,35 @@
<template>
<span class="content">
<Username :user="message.from" />
<i class="hostmask"> ({{ message.hostmask }})</i> has left the channel
<i class="hostmask"> (<ParsedMessage :network="network" :text="message.hostmask" />)</i> has
left the channel
<i v-if="message.text" class="part-reason"
>(<ParsedMessage :network="network" :message="message" />)</i
>
</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

@ -1,26 +1,35 @@
<template>
<span class="content">
<Username :user="message.from" />
<i class="hostmask"> ({{ message.hostmask }})</i> has quit
<i class="hostmask"> (<ParsedMessage :network="network" :text="message.hostmask" />)</i> has
quit
<i v-if="message.text" class="quit-reason"
>(<ParsedMessage :network="network" :message="message" />)</i
>
</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

@ -12,7 +12,12 @@
</template>
<dt>Host mask:</dt>
<dd class="hostmask">{{ message.whois.ident }}@{{ message.whois.hostname }}</dd>
<dd class="hostmask">
<ParsedMessage
:network="network"
:text="message.whois.ident + '@' + message.whois.hostname"
/>
</dd>
<template v-if="message.whois.actual_hostname">
<dt>Actual host:</dt>
@ -50,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>
@ -81,6 +86,11 @@
<dd>Yes</dd>
</template>
<template v-if="message.whois.certfp">
<dt>Certificate:</dt>
<dd>{{ message.whois.certfp }}</dd>
</template>
<template v-if="message.whois.server">
<dt>Connected to:</dt>
<dd>
@ -101,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

@ -6,26 +6,27 @@
<form class="container" method="post" action="" @submit.prevent="onSubmit">
<h1 class="title">
<template v-if="defaults.uuid">
<input type="hidden" name="uuid" :value="defaults.uuid" />
<input v-model="defaults.uuid" type="hidden" name="uuid" />
Edit {{ defaults.name }}
</template>
<template v-else>
<template v-if="config.public">The Lounge - </template>
Connect
<template v-if="!config.displayNetwork">
<template v-if="config.lockNetwork"> to {{ defaults.name }} </template>
<template
v-if="config?.lockNetwork && store?.state.serverConfiguration?.public"
>
to {{ defaults.name }}
</template>
</template>
</h1>
<template v-if="config.displayNetwork">
<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.trim="defaults.name"
class="input"
name="name"
:value="defaults.name"
maxlength="100"
/>
</div>
@ -34,41 +35,52 @@
<div class="input-wrap">
<input
id="connect:host"
v-model.trim="defaults.host"
class="input"
name="host"
:value="defaults.host"
aria-label="Server address"
maxlength="255"
required
:disabled="config.lockNetwork ? true : false"
/>
<span id="connect:portseparator">:</span>
<input
id="connect:port"
ref="serverPort"
v-model="defaults.port"
class="input"
type="number"
min="1"
max="65535"
name="port"
:value="defaults.port"
aria-label="Server port"
:disabled="config.lockNetwork ? true : false"
/>
</div>
</div>
<div class="connect-row">
<label for="connect:password">Password</label>
<RevealPassword
v-slot:default="slotProps"
class="input-wrap password-container"
>
<input
id="connect:password"
v-model="defaults.password"
class="input"
:type="slotProps.isVisible ? 'text' : 'password'"
placeholder="Server password (optional)"
name="password"
maxlength="300"
/>
</RevealPassword>
</div>
<div class="connect-row">
<label></label>
<div class="input-wrap">
<label class="tls">
<input
v-model="defaults.tls"
type="checkbox"
name="tls"
:checked="defaults.tls ? true : false"
:disabled="
config.lockNetwork || defaults.hasSTSPolicy ? true : false
"
@change="onSecureChanged"
:disabled="defaults.hasSTSPolicy"
/>
Use secure connection (TLS)
<span
@ -80,15 +92,118 @@
</label>
<label class="tls">
<input
v-model="defaults.rejectUnauthorized"
type="checkbox"
name="rejectUnauthorized"
:checked="defaults.rejectUnauthorized ? true : false"
:disabled="config.lockNetwork ? true : false"
/>
Only allow trusted certificates
</label>
</div>
</div>
<h2>Proxy Settings</h2>
<div class="connect-row">
<label></label>
<div class="input-wrap">
<label for="connect:proxyEnabled">
<input
id="connect:proxyEnabled"
v-model="defaults.proxyEnabled"
type="checkbox"
name="proxyEnabled"
/>
Enable Proxy
</label>
</div>
</div>
<template v-if="defaults.proxyEnabled">
<div class="connect-row">
<label for="connect:proxyHost">SOCKS Address</label>
<div class="input-wrap">
<input
id="connect:proxyHost"
v-model.trim="defaults.proxyHost"
class="input"
name="proxyHost"
aria-label="Proxy host"
maxlength="255"
/>
<span id="connect:proxyPortSeparator">:</span>
<input
id="connect:proxyPort"
v-model="defaults.proxyPort"
class="input"
type="number"
min="1"
max="65535"
name="proxyPort"
aria-label="SOCKS port"
/>
</div>
</div>
<div class="connect-row">
<label for="connect:proxyUsername">Proxy username</label>
<input
id="connect:proxyUsername"
ref="proxyUsernameInput"
v-model.trim="defaults.proxyUsername"
class="input username"
name="proxyUsername"
maxlength="100"
placeholder="Proxy username"
/>
</div>
<div class="connect-row">
<label for="connect:proxyPassword">Proxy password</label>
<RevealPassword
v-slot:default="slotProps"
class="input-wrap password-container"
>
<input
id="connect:proxyPassword"
ref="proxyPassword"
v-model="defaults.proxyPassword"
class="input"
:type="slotProps.isVisible ? 'text' : 'password'"
placeholder="Proxy password"
name="proxyPassword"
maxlength="300"
/>
</RevealPassword>
</div>
</template>
</template>
<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.trim="defaults.name"
class="input"
name="name"
maxlength="100"
/>
</div>
<div class="connect-row">
<label for="connect:password">Password</label>
<RevealPassword
v-slot:default="slotProps"
class="input-wrap password-container"
>
<input
id="connect:password"
v-model="defaults.password"
class="input"
:type="slotProps.isVisible ? 'text' : 'password'"
placeholder="Server password (optional)"
name="password"
maxlength="300"
/>
</RevealPassword>
</div>
</template>
<h2>User preferences</h2>
@ -96,140 +211,362 @@
<label for="connect:nick">Nick</label>
<input
id="connect:nick"
v-model="defaults.nick"
class="input nick"
name="nick"
pattern="[^\s:!@]+"
:value="defaults.nick"
maxlength="100"
required
@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.trim="defaults.username"
class="input username"
name="username"
:value="defaults.username"
maxlength="100"
/>
</div>
</template>
<div class="connect-row">
<label for="connect:password">Password</label>
<RevealPassword v-slot:default="slotProps" class="input-wrap password-container">
<input
id="connect:password"
v-model="defaults.password"
class="input"
:type="slotProps.isVisible ? 'text' : 'password'"
name="password"
maxlength="300"
/>
</RevealPassword>
</div>
<div class="connect-row">
<label for="connect:realname">Real name</label>
<input
id="connect:realname"
v-model.trim="defaults.realname"
class="input"
name="realname"
:value="defaults.realname"
maxlength="300"
/>
</div>
<template v-if="defaults.uuid">
<div class="connect-row">
<label for="connect:leaveMessage">Leave message</label>
<input
id="connect: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">
<div class="connect-row">
<label for="connect:commands">Commands</label>
<label for="connect:commands">
Commands
<span
class="tooltipped tooltipped-ne tooltipped-no-delay"
aria-label="One /command per line.
Each command will be executed in
the server tab on new connection"
>
<button class="extra-help" />
</span>
</label>
<textarea
id="connect:commands"
ref="commandsInput"
autocomplete="off"
:value="defaults.commands ? defaults.commands.join('\n') : ''"
class="input"
name="commands"
placeholder="One /command per line, each command will be executed in the server tab on new connection"
:value="defaults.commands ? defaults.commands.join('\n') : ''"
@input="resizeCommandsInput"
/>
</div>
<div>
<button type="submit" class="btn" :disabled="disabled ? true : false">
Save
</button>
</div>
</template>
<template v-else>
<template v-else-if="!defaults.uuid">
<div class="connect-row">
<label for="connect:channels">Channels</label>
<input id="connect:channels" class="input" name="join" :value="defaults.join" />
</div>
<div>
<button type="submit" class="btn" :disabled="disabled ? true : false">
Connect
</button>
<input
id="connect:channels"
v-model.trim="defaults.join"
class="input"
name="join"
/>
</div>
</template>
<template v-if="store.state.serverConfiguration?.public">
<template v-if="config?.lockNetwork">
<div class="connect-row">
<label></label>
<div class="input-wrap">
<label class="tls">
<input v-model="displayPasswordField" type="checkbox" />
I have a password
</label>
</div>
</div>
<div v-if="displayPasswordField" class="connect-row">
<label for="connect:password">Password</label>
<RevealPassword
v-slot:default="slotProps"
class="input-wrap password-container"
>
<input
id="connect:password"
ref="publicPassword"
v-model="defaults.password"
class="input"
:type="slotProps.isVisible ? 'text' : 'password'"
placeholder="Server password (optional)"
name="password"
maxlength="300"
/>
</RevealPassword>
</div>
</template>
</template>
<template v-else>
<h2 id="label-auth">Authentication</h2>
<div class="connect-row connect-auth" role="group" aria-labelledby="label-auth">
<label class="opt">
<input
:checked="!defaults.sasl"
type="radio"
name="sasl"
value=""
@change="setSaslAuth('')"
/>
No authentication
</label>
<label class="opt">
<input
:checked="defaults.sasl === 'plain'"
type="radio"
name="sasl"
value="plain"
@change="setSaslAuth('plain')"
/>
Username + password (SASL PLAIN)
</label>
<label
v-if="!store.state.serverConfiguration?.public && defaults.tls"
class="opt"
>
<input
:checked="defaults.sasl === 'external'"
type="radio"
name="sasl"
value="external"
@change="setSaslAuth('external')"
/>
Client certificate (SASL EXTERNAL)
</label>
</div>
<template v-if="defaults.sasl === 'plain'">
<div class="connect-row">
<label for="connect:username">Account</label>
<input
id="connect:saslAccount"
v-model.trim="defaults.saslAccount"
class="input"
name="saslAccount"
maxlength="100"
required
/>
</div>
<div class="connect-row">
<label for="connect:password">Password</label>
<RevealPassword
v-slot:default="slotProps"
class="input-wrap password-container"
>
<input
id="connect:saslPassword"
v-model="defaults.saslPassword"
class="input"
:type="slotProps.isVisible ? 'text' : 'password'"
name="saslPassword"
maxlength="300"
required
/>
</RevealPassword>
</div>
</template>
<div v-else-if="defaults.sasl === 'external'" class="connect-sasl-external">
<p>The Lounge automatically generates and manages the client certificate.</p>
<p>
On the IRC server, you will need to tell the services to attach the
certificate fingerprint (certfp) to your account, for example:
</p>
<pre><code>/msg NickServ CERT ADD</code></pre>
</div>
</template>
<div>
<button type="submit" class="btn" :disabled="disabled ? true : false">
<template v-if="defaults.uuid">Save network</template>
<template v-else>Connect</template>
</button>
</div>
</form>
</div>
</template>
<script>
<style>
#connect .connect-auth {
display: block;
margin-bottom: 10px;
}
#connect .connect-auth .opt {
display: block;
width: 100%;
}
#connect .connect-auth input {
margin: 3px 10px 0 0;
}
#connect .connect-sasl-external {
padding: 10px;
border-radius: 2px;
background-color: #d9edf7;
color: #31708f;
}
#connect .connect-sasl-external pre {
margin: 0;
user-select: text;
}
</style>
<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,
};
},
methods: {
onNickChanged(event) {
// Username input is not available when useHexIp is set
if (!this.$refs.usernameInput) {
setup(props) {
const store = useStore();
const config = ref(store.state.serverConfiguration);
const previousUsername = ref(props.defaults?.username);
const displayPasswordField = ref(false);
const publicPassword = ref<HTMLInputElement | null>(null);
watch(displayPasswordField, (newValue) => {
if (newValue) {
void nextTick(() => {
publicPassword.value?.focus();
});
}
});
const commandsInput = ref<HTMLInputElement | null>(null);
const resizeCommandsInput = () => {
if (!commandsInput.value) {
return;
}
if (
!this.$refs.usernameInput.value ||
this.$refs.usernameInput.value === this.previousUsername
) {
this.$refs.usernameInput.value = event.target.value;
// Reset height first so it can down size
commandsInput.value.style.height = "";
// 2 pixels to account for the border
commandsInput.value.style.height = `${Math.ceil(
commandsInput.value.scrollHeight + 2
)}px`;
};
watch(
// 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;
}
this.previousUsername = event.target.value;
},
onSecureChanged(event) {
const ports = ["6667", "6697"];
const newPort = event.target.checked ? 0 : 1;
const usernameRef = usernameInput.value;
// If you disable TLS and current port is 6697,
// set it to 6667, and vice versa
if (this.$refs.serverPort.value === ports[newPort]) {
this.$refs.serverPort.value = ports[1 - newPort];
}
},
onSubmit(event) {
const formData = new FormData(event.target);
const data = {};
for (const item of formData.entries()) {
data[item[0]] = item[1];
if (!usernameRef.value || usernameRef.value === previousUsername.value) {
usernameRef.value = (event.target as HTMLInputElement)?.value;
}
this.handleSubmit(data);
},
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,8 +1,13 @@
<template>
<div v-if="$store.state.networks.length === 0" class="empty">
<div
v-if="store.state.networks.length === 0"
class="empty"
role="navigation"
aria-label="Network and Channel list"
>
You are not connected to any networks yet.
</div>
<div v-else ref="networklist">
<div v-else ref="networklist" role="navigation" aria-label="Network and Channel list">
<div class="jump-to-input">
<input
ref="searchInput"
@ -46,79 +51,93 @@
/>
</div>
</div>
<div v-else class="no-results">
No results found.
</div>
<div v-else class="no-results">No results found.</div>
</div>
<Draggable
v-else
:list="$store.state.networks"
:filter="isCurrentlyInTouch"
:prevent-on-filter="false"
:list="store.state.networks"
:delay="LONG_TOUCH_DURATION"
:delay-on-touch-only="true"
:touch-start-threshold="10"
handle=".channel-list-item[data-type='lobby']"
draggable=".network"
ghost-class="ui-sortable-ghost"
drag-class="ui-sortable-dragged"
drag-class="ui-sortable-dragging"
group="networks"
class="networks"
item-key="uuid"
@change="onNetworkSort"
@start="onDragStart"
@end="onDragEnd"
@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"
>
<NetworkLobby
:network="network"
:is-join-channel-shown="network.isJoinChannelShown"
:active="
$store.state.activeChannel &&
network.channels[0] === $store.state.activeChannel.channel
"
@toggleJoinChannel="network.isJoinChannelShown = !network.isJoinChannelShown"
/>
<JoinChannel
v-if="network.isJoinChannelShown"
:network="network"
:channel="network.channels[0]"
@toggleJoinChannel="network.isJoinChannelShown = !network.isJoinChannelShown"
/>
<Draggable
draggable=".channel-list-item"
ghost-class="ui-sortable-ghost"
drag-class="ui-sortable-dragged"
:group="network.uuid"
:filter="isCurrentlyInTouch"
:prevent-on-filter="false"
:list="network.channels"
class="channels"
@change="onChannelSort"
@start="onDragStart"
@end="onDragEnd"
<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"
>
<Channel
v-for="(channel, index) in network.channels"
v-if="index > 0"
:key="channel.id"
:channel="channel"
<NetworkLobby
:network="network"
:is-join-channel-shown="network.isJoinChannelShown"
:active="
$store.state.activeChannel &&
channel === $store.state.activeChannel.channel
store.state.activeChannel &&
network.channels[0] === store.state.activeChannel.channel
"
@toggle-join-channel="
network.isJoinChannelShown = !network.isJoinChannelShown
"
/>
</Draggable>
</div>
<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>
@ -136,6 +155,7 @@
color: #fff;
background-color: rgba(255, 255, 255, 0.1);
padding-right: 35px;
appearance: none;
}
.jump-to-input .input::placeholder {
@ -183,18 +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,
@ -202,184 +231,283 @@ 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();
},
},
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() {
if (this.$store.state.activeChannel) {
collapseNetwork(this.$store.state.activeChannel.network, false);
}
},
collapseNetwork() {
if (this.$store.state.activeChannel) {
collapseNetwork(this.$store.state.activeChannel.network, true);
}
},
isCurrentlyInTouch(e) {
// TODO: Implement a way to sort on touch devices
return e.pointerType !== "mouse";
},
onDragStart(e) {
e.target.classList.add("ui-sortable-active");
},
onDragEnd(e) {
e.target.classList.remove("ui-sortable-active");
},
onNetworkSort(e) {
if (!e.moved) {
return;
}
return newResults;
});
socket.emit("sort", {
type: "networks",
order: this.$store.state.networks.map((n) => n.uuid),
});
},
onChannelSort(e) {
if (!e.moved) {
return;
}
const channel = this.$store.getters.findChannel(e.moved.element.id);
if (!channel) {
return;
}
socket.emit("sort", {
type: "channels",
target: channel.network.uuid,
order: channel.network.channels.map((c) => c.id),
});
},
toggleSearch(event) {
// Do not handle this keybind in the chat input because
// it can be used to type letters with umlauts
if (event.target.tagName === "TEXTAREA") {
const collapseNetwork = (event: Mousetrap.ExtendedKeyboardEvent) => {
if (isIgnoredKeybind(event)) {
return true;
}
if (this.$refs.searchInput === document.activeElement) {
this.deactivateSearch();
return false;
if (store.state.activeChannel) {
collapseNetworkHelper(store.state.activeChannel.network, true);
}
this.activateSearch();
return false;
},
activateSearch() {
if (this.$refs.searchInput === document.activeElement) {
};
const expandNetwork = (event: Mousetrap.ExtendedKeyboardEvent) => {
if (isIgnoredKeybind(event)) {
return true;
}
if (store.state.activeChannel) {
collapseNetworkHelper(store.state.activeChannel.network, false);
}
return false;
};
const onNetworkSort = (e: Sortable.SortableEvent) => {
const {oldIndex, newIndex} = e;
if (oldIndex === undefined || newIndex === undefined || oldIndex === newIndex) {
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();
moveItemInArray(store.state.networks, oldIndex, newIndex);
if (this.sidebarWasClosed) {
this.$store.commit("sidebarOpen", false);
socket.emit("sort:networks", {
order: store.state.networks.map((n) => n.uuid),
});
};
const onChannelSort = (e: Sortable.SortableEvent) => {
let {oldIndex, newIndex} = e;
if (oldIndex === undefined || newIndex === undefined || oldIndex === newIndex) {
return;
}
},
setSearchText(e) {
this.searchText = e.target.value;
},
setActiveSearchItem(channel) {
if (!this.results.length) {
// Indexes are offset by one due to the lobby
oldIndex += 1;
newIndex += 1;
const unparsedId = e.item.getAttribute("data-item");
if (!unparsedId) {
return;
}
const id = parseInt(unparsedId);
const netChan = store.getters.findChannel(id);
if (!netChan) {
return;
}
moveItemInArray(netChan.network.channels, oldIndex, newIndex);
socket.emit("sort:channel", {
network: netChan.network.uuid,
order: netChan.network.channels.map((c) => c.id),
});
};
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 !!(
(event.touches && event.touches[0]) ||
(event.pointerType && event.pointerType === "touch")
);
};
const onDraggableChoose = (event: any) => {
const original = event.originalEvent;
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
// to tell the user they've held the touch long enough.
event.item.classList.add("ui-sortable-dragging-touch-cue");
if (original instanceof TouchEvent && original.touches.length > 0) {
startDrag.value = [original.touches[0].clientX, original.touches[0].clientY];
} else if (original instanceof PointerEvent) {
startDrag.value = [original.clientX, original.clientY];
}
}
};
const onDraggableUnchoose = (event: any) => {
event.item.classList.remove("ui-sortable-dragging-touch-cue");
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");
}
};
const onDraggableTouchMove = (event: TouchEvent) => {
if (startDrag.value && event.touches.length > 0) {
const touch = event.touches[0];
const currentPosition = [touch.clientX, touch.clientY];
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");
}
}
};
const onDraggableTouchEnd = (event: TouchEvent) => {
if (event.touches.length === 0) {
document.body.classList.remove("force-no-select");
}
};
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 (searchInput.value === document.activeElement) {
deactivateSearch();
return false;
}
activateSearch();
return false;
};
const setSearchText = (e: Event) => {
searchText.value = (e.target as HTMLInputElement).value;
};
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;
}
@ -395,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

@ -39,46 +39,63 @@
:class="['add-channel', {opened: isJoinChannelShown}]"
:aria-controls="'join-channel-' + channel.id"
:aria-label="joinChannelLabel"
@click.stop="$emit('toggleJoinChannel')"
@click.stop="$emit('toggle-join-channel')"
/>
</span>
</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

@ -1,35 +1,66 @@
<template>
<Chat v-if="activeChannel" :network="activeChannel.network" :channel="activeChannel.channel" />
<Chat
v-if="activeChannel"
:network="activeChannel.network"
:channel="activeChannel.channel"
: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

@ -7,14 +7,12 @@
session.ip
}}</a>
<template v-if="!session.current">
<p v-if="session.active">
<em>Currently active</em>
</p>
<p v-else>
Last used on <time>{{ lastUse }}</time>
</p>
</template>
<p v-if="session.active > 1" class="session-usage">
Active in {{ session.active }} browsers
</p>
<p v-else-if="!session.current && !session.active" class="session-usage">
Last used on <time>{{ lastUse }}</time>
</p>
</div>
<div class="session-item-btn">
<button class="btn" @click.prevent="signOut">
@ -25,30 +23,61 @@
</div>
</template>
<script>
<style>
.session-list .session-item {
display: flex;
font-size: 14px;
}
.session-list .session-item-info {
display: flex;
flex-direction: column;
flex-grow: 1;
}
.session-list .session-item-btn {
flex-shrink: 0;
}
.session-list .session-usage {
font-style: italic;
color: var(--body-color-muted);
}
</style>
<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

@ -6,11 +6,13 @@
:src="`img/logo-${isPublic() ? 'horizontal-' : ''}transparent-bg.svg`"
class="logo"
alt="The Lounge"
role="presentation"
/>
<img
:src="`img/logo-${isPublic() ? 'horizontal-' : ''}transparent-bg-inverted.svg`"
class="logo-inverted"
alt="The Lounge"
role="presentation"
/>
<span
v-if="isDevelopment"
@ -32,169 +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,
};
},
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

@ -9,7 +9,7 @@
</thead>
<tbody>
<tr v-for="ban in channel.data" :key="ban.hostmask">
<td class="hostmask">{{ ban.hostmask }}</td>
<td class="hostmask"><ParsedMessage :network="network" :text="ban.hostmask" /></td>
<td class="banned_by">{{ ban.banned_by }}</td>
<td class="banned_at">{{ localetime(ban.banned_at) }}</td>
</tr>
@ -17,19 +17,29 @@
</table>
</template>
<script>
import localetime from "../../js/helpers/localetime";
<script lang="ts">
import ParsedMessage from "../ParsedMessage.vue";
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

@ -8,26 +8,32 @@
</thead>
<tbody>
<tr v-for="user in channel.data" :key="user.hostmask">
<td class="hostmask">{{ user.hostmask }}</td>
<td class="hostmask"><ParsedMessage :network="network" :text="user.hostmask" /></td>
<td class="when">{{ localetime(user.when) }}</td>
</tr>
</tbody>
</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

@ -9,7 +9,9 @@
</thead>
<tbody>
<tr v-for="invite in channel.data" :key="invite.hostmask">
<td class="hostmask">{{ invite.hostmask }}</td>
<td class="hostmask">
<ParsedMessage :network="network" :text="invite.hostmask" />
</td>
<td class="invitened_by">{{ invite.invited_by }}</td>
<td class="invitened_at">{{ localetime(invite.invited_at) }}</td>
</tr>
@ -17,19 +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,40 +1,84 @@
<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} : {}"
@click.prevent="openContextMenu"
@contextmenu.prevent="openContextMenu"
><slot>{{ user.mode }}{{ user.nick }}</slot></span
><slot>{{ mode }}{{ user.nick }}</slot></span
>
</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,
},
computed: {
nickColor() {
return colorClass(this.user.nick);
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},
},
methods: {
hover() {
return this.onHover(this.user);
},
openContextMenu(event) {
this.$root.$emit("contextmenu:user", {
setup(props) {
const mode = computed(() => {
// Message objects have a singular mode, but user objects have modes array
if (props.user.modes) {
return props.user.modes[0];
}
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,
user: props.user,
network: props.network,
channel: props.channel,
});
},
};
const store = useStore();
return {
mode,
nickColor,
hover,
openContextMenu,
store,
};
},
};
});
</script>

View file

@ -1,31 +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'">
<p>
The Lounge is up to date!
</p>
<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"
@ -33,32 +27,40 @@
Check now
</button>
</template>
<template v-if="$store.state.versionStatus === 'error'">
<p>
Information about latest release could not be retrieved.
</p>
<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>
</template>
</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,31 +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
@ -44,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");
@ -80,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,17 +58,12 @@ export default {
// When the network is locked, URL overrides should not affect disabled fields
if (
this.$store.state.serverConfiguration.lockNetwork &&
["host", "port", "tls", "rejectUnauthorized"].includes(key)
store.state.serverConfiguration?.lockNetwork &&
["name", "host", "port", "tls", "rejectUnauthorized"].includes(key)
) {
continue;
}
// When the network is not displayed, its name in the UI is not customizable
if (!this.$store.state.serverConfiguration.displayNetwork && key === "name") {
continue;
}
if (key === "join") {
value = value
.split(",")
@ -83,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;
@ -102,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,15 +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>
@ -36,13 +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
@ -50,14 +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>
@ -93,6 +87,36 @@
</p>
</div>
<h2 v-if="isTouch">Gestures</h2>
<div v-if="isTouch" class="help-item">
<div class="subject gesture">Single-Finger Swipe Left</div>
<div class="description">
<p>Hide sidebar.</p>
</div>
</div>
<div v-if="isTouch" class="help-item">
<div class="subject gesture">Single-Finger Swipe Right</div>
<div class="description">
<p>Show sidebar.</p>
</div>
</div>
<div v-if="isTouch" class="help-item">
<div class="subject gesture">Two-Finger Swipe Left</div>
<div class="description">
<p>Switch to the next window in the channel list.</p>
</div>
</div>
<div v-if="isTouch" class="help-item">
<div class="subject gesture">Two-Finger Swipe Right</div>
<div class="description">
<p>Switch to the previous window in the channel list.</p>
</div>
</div>
<h2>Keyboard Shortcuts</h2>
<div class="help-item">
@ -155,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>
@ -195,6 +239,38 @@
</div>
</div>
<div class="help-item">
<div class="subject">
<span v-if="!isApple"><kbd>Alt</kbd> <kbd>M</kbd></span>
<span v-else><kbd></kbd> <kbd>M</kbd></span>
</div>
<div class="description">
<p>Toggle recent mentions popup.</p>
</div>
</div>
<div class="help-item">
<div class="subject">
<span v-if="!isApple"><kbd>Alt</kbd> <kbd>/</kbd></span>
<span v-else><kbd></kbd> <kbd>/</kbd></span>
</div>
<div class="description">
<p>Switch to the help menu.</p>
</div>
</div>
<div class="help-item">
<div class="subject">
<span><kbd>Esc</kbd></span>
</div>
<div class="description">
<p>
Close current contextual window (context menu, image viewer, topic edit,
etc) and remove focus from input.
</p>
</div>
</div>
<h2>Formatting Shortcuts</h2>
<div class="help-item">
@ -312,9 +388,7 @@
<kbd></kbd> keys to highlight an item, and insert it by pressing <kbd>Tab</kbd> or
<kbd>Enter</kbd> (or by clicking the desired item).
</p>
<p>
Autocompletion can be disabled in settings.
</p>
<p>Autocompletion can be disabled in settings.</p>
<div class="help-item">
<div class="subject">
@ -468,9 +542,7 @@
<code>/disconnect [message]</code>
</div>
<div class="description">
<p>
Disconnect from the current network with an optionally-provided message.
</p>
<p>Disconnect from the current network with an optionally-provided message.</p>
</div>
</div>
@ -522,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>
@ -581,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>
@ -604,9 +705,7 @@
<code>/op nick [...nick]</code>
</div>
<div class="description">
<p>
Give op (<code>+o</code>) to one or several users in the current channel.
</p>
<p>Give op (<code>+o</code>) to one or several users in the current channel.</p>
</div>
</div>
@ -650,9 +749,7 @@
<code>/quit [message]</code>
</div>
<div class="description">
<p>
Disconnect from the current network with an optional message.
</p>
<p>Disconnect from the current network with an optional message.</p>
</div>
</div>
@ -675,6 +772,15 @@
</div>
</div>
<div v-if="store.state.settings.searchEnabled" class="help-item">
<div class="subject">
<code>/search query</code>
</div>
<div class="description">
<p>Search for messages in the current channel / user</p>
</div>
</div>
<div class="help-item">
<div class="subject">
<code>/topic [newtopic]</code>
@ -711,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>
@ -727,29 +845,35 @@
<code>/whois nick</code>
</div>
<div class="description">
<p>
Retrieve information about the given user on the current network.
</p>
<p>Retrieve information about the given user on the current network.</p>
</div>
</div>
</div>
</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,
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

@ -0,0 +1,321 @@
<template>
<div id="chat-container" class="window">
<div
id="chat"
:class="{
'time-seconds': store.state.settings.showSeconds,
'time-12h': store.state.settings.use12hClock,
}"
>
<div
class="chat-view"
data-type="search-results"
aria-label="Search results"
role="tabpanel"
>
<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>
<MessageSearchForm :network="network" :channel="channel" />
<button
class="close"
aria-label="Close search window"
title="Close search window"
@click="closeSearch"
/>
</div>
<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.messageSearchPendingQuery ||
!store.state.isConnected
"
class="btn"
@click="onShowMoreClick"
>
<span v-if="store.state.messageSearchPendingQuery">Loading</span>
<span v-else>Show older messages</span>
</button>
</div>
<div
v-if="store.state.messageSearchPendingQuery && !offset"
class="search-status"
>
Searching
</div>
<div v-else-if="!messages.length && !offset" class="search-status">
No results found.
</div>
<div
class="messages"
role="log"
aria-live="polite"
aria-relevant="additions"
>
<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>
</div>
</div>
</div>
</template>
<style>
.channel-name {
font-weight: 700;
}
</style>
<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";
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,
Message,
DateMarker,
MessageSearchForm,
},
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 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 chan.value.network;
});
const channel = computed(() => {
if (!chan.value) {
return null;
}
return chan.value.channel;
});
const setActiveChannel = () => {
if (!chan.value) {
return;
}
store.commit("activeChannel", chan.value);
};
const closeSearch = () => {
if (!channel.value) {
return;
}
switchToChannel(channel.value);
};
const shouldDisplayDateMarker = (message: ClientMessage, id: number) => {
const previousMessage = messages.value[id - 1];
if (!previousMessage) {
return true;
}
return new Date(previousMessage.time).getDay() !== new Date(message.time).getDay();
};
const clearSearchState = () => {
offset.value = 0;
store.commit("messageSearchResults", null);
store.commit("messageSearchPendingQuery", null);
};
const doSearch = () => {
if (!network.value || !channel.value) {
return;
}
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);
};
const onShowMoreClick = () => {
if (!chat.value || !network.value || !channel.value) {
return;
}
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)
};
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,569 +3,54 @@
<div class="header">
<SidebarToggle />
</div>
<form ref="settingsForm" class="container" @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"
/>
Show seconds in timestamp
</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>
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>
<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="sr-only">
Nick autocomplete postfix (e.g. <code>, </code>)
</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>
<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"
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 === '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="sr-only">
Custom highlights (comma-separated keywords)
</label>
<input
id="highlights"
:value="$store.state.settings.highlights"
type="text"
name="highlights"
class="input"
placeholder="Custom highlights (comma-separated keywords)"
/>
</label>
</div>
<div
v-if="
!$store.state.serverConfiguration.public &&
!$store.state.serverConfiguration.ldapEnabled
"
id="change-password"
>
<h2>Change password</h2>
<div class="password-container">
<label for="old_password_input" class="sr-only">
Enter current password
</label>
<RevealPassword v-slot:default="slotProps">
<input
id="old_password_input"
:type="slotProps.isVisible ? 'text' : 'password'"
name="old_password"
class="input"
placeholder="Enter current password"
/>
</RevealPassword>
</div>
<div class="password-container">
<label for="new_password_input" class="sr-only">
Enter desired new password
</label>
<RevealPassword v-slot:default="slotProps">
<input
id="new_password_input"
:type="slotProps.isVisible ? 'text' : 'password'"
name="new_password"
class="input"
placeholder="Enter desired new password"
/>
</RevealPassword>
</div>
<div class="password-container">
<label for="verify_password_input" class="sr-only">
Repeat new password
</label>
<RevealPassword v-slot:default="slotProps">
<input
id="verify_password_input"
:type="slotProps.isVisible ? 'text' : 'password'"
name="verify_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">
<h2>Sessions</h2>
<h3>Current session</h3>
<Session
v-if="$store.getters.currentSession"
:session="$store.getters.currentSession"
/>
<h3>Other sessions</h3>
<p v-if="$store.state.sessions.length === 0">Loading</p>
<p v-else-if="$store.getters.otherSessions.length === 0">
<em>You are not currently logged in to any other device.</em>
</p>
<Session
v-for="session in $store.getters.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>
<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;
},
},
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 */
}
@ -104,7 +104,11 @@ body {
* Disable pull-to-refresh on mobile that conflicts with scrolling the message list.
* See http://stackoverflow.com/a/29313685/1935861
*/
overflow-y: hidden;
overflow: hidden; /* iOS Safari requires overflow rather than overflow-y */
}
body.force-no-select * {
user-select: none !important;
}
a,
@ -144,7 +148,7 @@ button {
code,
pre,
#chat .msg[data-type="motd"] .text,
#chat .msg[data-type="monospace_block"] .text,
.irc-monospace,
textarea#user-specified-css-input {
font-family: Consolas, Menlo, Monaco, "Lucida Console", "DejaVu Sans Mono", "Courier New", monospace;
@ -164,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;
@ -183,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;
@ -225,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 {
@ -276,19 +280,25 @@ 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 */
#viewport .lt::before,
#viewport .rt::before,
#chat button.mentions::before,
#chat button.close::before,
#chat button.menu::before,
#chat button.search::before,
.channel-list-item::before,
#footer .icon,
#chat .count::before,
#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,
@ -296,12 +306,17 @@ 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-type="motd"] .from::before,
#chat .msg[data-command="motd"] .from::before,
#chat .msg[data-command="help"] .from::before,
#chat .msg[data-command="info"] .from::before,
#chat .msg[data-type="ctcp"] .from::before,
#chat .msg[data-type="ctcp_request"] .from::before,
#chat .msg[data-type="whois"] .from::before,
@ -309,6 +324,7 @@ p {
#chat .msg[data-type="action"] .from::before,
#chat .msg[data-type="plugin"] .from::before,
#chat .msg[data-type="raw"] .from::before,
#chat .msg-statusmsg span::before,
#chat .msg-shown-in-active span::before,
#chat .toggle-button::after,
#chat .toggle-content .more-caret::before,
@ -326,6 +342,7 @@ p {
.channel-list-item .not-connected-icon::before,
.channel-list-item .parted-channel-icon::before,
.jump-to-input::before,
.password-container .reveal-password span,
#sidebar .collapse-network-icon::before {
font: normal normal normal 14px/1 FontAwesome;
font-size: inherit; /* Can't have font-size inherit on line above, so need to override */
@ -336,6 +353,9 @@ p {
#viewport .lt::before { content: "\f0c9"; /* http://fontawesome.io/icon/bars/ */ }
#viewport .rt::before { content: "\f0c0"; /* https://fontawesome.com/icons/users?style=solid */ }
#chat button.menu::before { content: "\f142"; /* http://fontawesome.io/icon/ellipsis-v/ */ }
#chat button.mentions::before { content: "\f1fa"; /* https://fontawesome.com/icons/at?style=solid */ }
#chat button.search::before { content: "\f002"; /* https://fontawesome.com/icons/search?style=solid */ }
#chat button.close::before { content: "\f00d"; /* https://fontawesome.com/icons/times?style=solid */ }
.context-menu-join::before { content: "\f067"; /* http://fontawesome.io/icon/plus/ */ }
.context-menu-user::before { content: "\f007"; /* http://fontawesome.io/icon/user/ */ }
@ -344,11 +364,14 @@ p {
.context-menu-disconnect::before { content: "\f127"; /* https://fontawesome.com/icons/unlink?style=solid */ }
.context-menu-connect::before { content: "\f0c1"; /* https://fontawesome.com/icons/link?style=solid */ }
.context-menu-action-whois::before { content: "\f05a"; /* http://fontawesome.io/icon/info-circle/ */ }
.context-menu-action-ignore::before { content: "\f506"; /* https://fontawesome.com/icons/user-slash?style=solid */ }
.context-menu-action-kick::before { content: "\f05e"; /* http://fontawesome.io/icon/ban/ */ }
.context-menu-action-op::before { content: "\f1fa"; /* http://fontawesome.io/icon/at/ */ }
.context-menu-action-voice::before { content: "\f067"; /* http://fontawesome.io/icon/plus/ */ }
.context-menu-action-set-mode::before { content: "\f067"; /* http://fontawesome.io/icon/plus/ */ }
.context-menu-action-revoke-mode::before { content: "\f068"; /* http://fontawesome.io/icon/minus/ */ }
.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 */
@ -361,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 */ }
@ -398,26 +427,21 @@ p {
#help .documentation-link::before { content: "\f19d"; /* http://fontawesome.io/icon/graduation-cap/ */ }
#help .report-issue-link::before { content: "\f188"; /* http://fontawesome.io/icon/bug/ */ }
.session-list .session-item {
display: flex;
font-size: 14px;
}
.session-list .session-item-info {
display: flex;
flex-direction: column;
flex-grow: 1;
}
.session-list .session-item-btn {
flex-shrink: 0;
}
#chat .msg[data-type="invite"] .from::before {
content: "\f0e0"; /* https://fontawesome.com/icons/envelope?style=solid */
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 */
@ -432,16 +456,27 @@ 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;
}
#chat .msg[data-type="motd"] .from::before {
#chat .msg[data-command="motd"] .from::before {
content: "\f02e"; /* https://fontawesome.com/icons/bookmark?style=solid */
color: var(--body-color-muted);
}
#chat .msg[data-command="help"] .from::before {
content: "\f059"; /* https://fontawesome.com/icons/question-circle?style=solid */
color: var(--body-color-muted);
}
#chat .msg[data-command="info"] .from::before {
content: "\f05a"; /* https://fontawesome.com/icons/info-circle?style=solid */
color: var(--body-color-muted);
}
#chat .msg[data-type="ctcp"] .from::before,
#chat .msg[data-type="ctcp_request"] .from::before {
content: "\f15c"; /* https://fontawesome.com/icons/file-alt?style=solid */
@ -488,16 +523,25 @@ p {
padding: 1px;
}
#chat .msg-statusmsg,
#chat .msg-shown-in-active {
cursor: help;
margin-right: 5px;
}
#chat .msg-statusmsg span::before,
#chat .msg-shown-in-active span::before {
font-size: 10px;
content: "\f06e"; /* https://fontawesome.com/icons/eye?style=solid */
}
#chat .msg-statusmsg {
border-radius: 2px;
padding: 2px 4px;
background-color: #ff9e18;
color: #222;
}
#chat .toggle-button {
display: inline-block;
transition: opacity 0.2s, transform 0.2s;
@ -519,6 +563,7 @@ p {
line-height: 45px;
}
#connect .extra-help::before,
#settings .extra-help::before {
content: "\f059"; /* http://fontawesome.io/icon/question-circle/ */
}
@ -542,6 +587,11 @@ p {
/* End icons */
#app {
height: 100%;
width: 100%;
}
#viewport {
display: flex;
height: 100%;
@ -561,20 +611,25 @@ p {
#viewport .lt,
#viewport .rt,
#chat button.menu {
#chat button.mentions,
#chat button.search,
#chat button.menu,
#chat button.close {
color: #607992;
display: flex;
font-size: 14px;
line-height: 1;
height: 36px;
width: 36px;
margin-top: 6px;
flex-shrink: 0;
}
#viewport .lt::before,
#viewport .rt::before,
#chat button.menu::before {
#chat button.mentions::before,
#chat button.search::before,
#chat button.menu::before,
#chat button.close::before {
width: 36px;
line-height: 36px; /* Fix alignment in Microsoft Edge */
}
@ -672,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;
@ -684,14 +739,19 @@ background on hover (unless active) */
/* Remove background on hovered/active channel when sorting/drag-and-dropping */
.ui-sortable-ghost,
.channel-list-item.ui-sortable-dragged,
.ui-sortable-dragged .channel-list-item,
.ui-sortable-active .channel-list-item:hover,
.ui-sortable-active .channel-list-item.active {
.ui-sortable-dragging .channel-list-item,
.ui-sortable-dragging,
.ui-sortable-dragging:hover,
.ui-sortable-dragging.active,
.ui-sortable-dragging-touch-cue .channel-list-item,
.ui-sortable-dragging-touch-cue,
.ui-sortable-dragging-touch-cue:hover,
.ui-sortable-dragging-touch-cue.active {
background: transparent;
}
.ui-sortable-ghost::after {
.ui-sortable-ghost::after,
.ui-sortable-dragging-touch-cue:not(.ui-sortable-dragging)::after {
background: var(--body-bg-color);
border: 1px dashed #99a2b4;
border-radius: 6px;
@ -704,6 +764,10 @@ background on hover (unless active) */
right: 10px;
}
.ui-sortable-dragging-touch-cue:not(.ui-sortable-ghost)::after {
background: transparent;
}
#sidebar .network {
position: relative;
margin-bottom: 20px;
@ -762,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;
@ -789,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;
@ -948,7 +1016,6 @@ background on hover (unless active) */
textarea.input {
resize: vertical;
height: 100px;
min-height: 35px;
padding: 6px 10px;
line-height: 1.5;
@ -982,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;
@ -1001,6 +1068,7 @@ textarea.input {
}
.header {
align-items: center;
line-height: 45px;
height: 45px;
padding: 0 6px;
@ -1016,7 +1084,10 @@ textarea.input {
.header .title {
font-size: 15px;
padding-left: 6px;
flex-shrink: 0;
flex-shrink: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.topic-container {
@ -1032,6 +1103,12 @@ textarea.input {
flex-grow: 1;
overflow: hidden;
font-size: 14px;
flex-shrink: 99999999;
min-width: 25px;
}
.header .topic.empty {
min-width: 0;
}
.header .topic-input {
@ -1045,6 +1122,7 @@ textarea.input {
height: 35px;
overflow: hidden;
font-size: 14px;
line-height: normal;
outline: none;
}
@ -1125,6 +1203,7 @@ textarea.input {
#chat .chat-content {
display: flex;
flex-direction: row-reverse;
flex-grow: 1;
overflow: hidden;
position: relative;
@ -1160,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 {
@ -1212,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 {
@ -1309,12 +1385,18 @@ textarea.input {
padding-left: 10px;
width: 55px;
font-variant-numeric: tabular-nums;
box-sizing: content-box; /* highlights have a border-left */
}
#chat.show-seconds .time {
#chat.time-12h .time,
#chat.time-seconds .time {
width: 75px;
}
#chat.time-seconds.time-12h .time {
width: 90px;
}
#chat .from {
padding-right: 10px;
text-align: right;
@ -1356,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);
@ -1447,11 +1529,11 @@ textarea.input {
width: 50px;
}
#chat.hide-motd .msg[data-type="motd"] {
#chat.hide-motd .msg[data-command="motd"] {
display: none !important;
}
#chat .msg[data-type="motd"] .text {
#chat .msg[data-type="monospace_block"] .text {
background: #f6f6f6;
display: inline-block;
border-radius: 4px;
@ -1479,8 +1561,11 @@ textarea.input {
}
#chat .msg[data-type="notice"] .time,
#chat .msg[data-type="wallops"] .time,
#chat .msg[data-type="notice"] .content,
#chat .msg[data-type="notice"] .user {
#chat .msg[data-type="wallops"] .content,
#chat .msg[data-type="notice"] .user,
#chat .msg[data-type="wallops"] .user {
color: #0074d9;
}
@ -1488,6 +1573,10 @@ textarea.input {
content: "Notice: ";
}
#chat .msg[data-type="wallops"] .from .user::before {
content: "Wallops: ";
}
#chat .msg[data-type="error"],
#chat .msg[data-type="error"] .from {
color: #e74c3c;
@ -1500,14 +1589,9 @@ textarea.input {
#chat .chat-view[data-type="channel"] .msg.highlight .time {
padding-left: 5px;
width: 50px;
color: #696969;
}
#chat.show-seconds .chat-view[data-type="channel"] .msg.highlight .time {
width: 70px;
}
#chat .chat-view[data-type="channel"] .msg.highlight .content {
border-left: 1px solid var(--highlight-bg-color);
}
@ -1534,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;
@ -1658,6 +1742,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
#chat .userlist .search {
color: var(--body-color);
appearance: none;
border: 0;
background: none;
font: inherit;
@ -1816,30 +1901,33 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
margin-top: 6px;
}
#connect .tls input {
#connect .tls input,
#connect input[name="proxyEnabled"] {
margin: 3px 10px 0 0;
}
#connect\:host {
#connect\:host,
#connect\:proxyHost {
width: 70%;
}
#connect\:port {
#connect\:port,
#connect\:proxyPort {
width: 25%;
}
#connect\:portseparator {
#connect\:portseparator,
#connect\:proxyPortSeparator {
width: 5%;
text-align: center;
display: inline-block;
}
#connect .btn {
margin-left: 25%;
margin-top: 15px;
width: 100%;
}
#settings .apple-push-unsupported,
#settings .settings-sync-panel {
padding: 10px;
margin-bottom: 16px;
@ -1866,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 {
@ -1883,6 +1966,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
margin-right: 6px;
}
#connect .extra-help,
#settings .extra-help {
cursor: help;
}
@ -1943,7 +2027,6 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
}
.password-container .reveal-password span {
font: normal normal normal 14px/1 FontAwesome;
font-size: 16px;
color: #607992;
width: 35px;
@ -1988,6 +2071,10 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
padding-right: 15px;
}
#help .help-item .subject.gesture {
font-weight: bold;
}
#help .help-item .description p {
margin-bottom: 0;
}
@ -2017,12 +2104,20 @@ 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;
}
.window#chat-container {
/*
Chat has its own scrollbar, so remove the one on parent
This caused a performance issue in Chrome
*/
overflow: hidden;
}
#version-checker {
display: flex;
align-items: center;
@ -2179,6 +2274,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
opacity: 0.5;
}
#mentions-popup-container,
#context-menu-container {
position: absolute;
top: 0;
@ -2189,6 +2285,15 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
background: transparent;
}
#context-menu-container.passthrough {
pointer-events: none;
}
#context-menu-container.passthrough > * {
pointer-events: auto;
}
.mentions-popup,
#context-menu,
.textcomplete-menu {
position: absolute;
@ -2198,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;
}
@ -2207,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,
@ -2228,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,
@ -2569,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,
@ -2589,6 +2694,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
right: 0;
transform: translateX(180px);
transition: transform 0.2s;
z-index: 1;
}
#viewport.userlist-open #chat .userlist {
@ -2628,11 +2734,6 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
flex-grow: 1;
}
#connect .btn {
margin-left: 0;
width: 100%;
}
#help .help-version-title {
flex-direction: column;
}
@ -2690,24 +2791,25 @@ 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 */
#confirm-dialog-overlay,
#upload-overlay,
#image-viewer,
#image-viewer .open-btn,
@ -2719,6 +2821,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
justify-content: center;
}
#confirm-dialog-overlay,
#upload-overlay,
#image-viewer {
position: fixed;
@ -2734,14 +2837,16 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
user-select: none;
}
#confirm-dialog-overlay.opened,
#upload-overlay.is-dragover,
#image-viewer.opened {
visibility: visible;
opacity: 1;
}
#confirm-dialog-overlay,
#image-viewer {
background: rgba(0, 0, 0, 0.9);
background: rgb(0 0 0 / 90%);
}
#image-viewer .close-btn,
@ -2804,22 +2909,31 @@ 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="motd"] .content,
#chat .msg[data-type="monospace_block"] .content,
#chat .msg[data-type="notice"] .content,
#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%;
justify-content: center;
align-items: center;
}

View file

@ -1,5 +1,5 @@
<!doctype html>
<html>
<html lang="en">
<head>
<meta charset="utf-8">
@ -22,7 +22,7 @@
<link id="favicon" rel="icon" sizes="16x16 32x32 64x64" href="favicon.ico" data-other="img/favicon-alerted.ico" type="image/x-icon">
<!-- Safari pinned tab icon -->
<link rel="mask-icon" href="img/icon-black-transparent-bg.svg" color="#415363">
<link rel="mask-icon" href="img/icon-black-transparent-bg.svg" color="#415364">
<link rel="manifest" href="thelounge.webmanifest">
@ -48,12 +48,12 @@
</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">
<img src="img/logo-vertical-transparent-bg.svg" class="logo" alt="The Lounge" width="256" height="170">
<img src="img/logo-vertical-transparent-bg-inverted.svg" class="logo-inverted" alt="The Lounge" width="256" height="170">
<img src="img/logo-vertical-transparent-bg.svg" class="logo" alt="" width="256" height="170">
<img src="img/logo-vertical-transparent-bg-inverted.svg" class="logo-inverted" alt="" width="256" height="170">
<p id="loading-page-message">The Lounge requires a modern browser with JavaScript enabled.</p>
</div>
<div id="loading-reload-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,10 +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;
@ -195,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;
@ -217,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", () => {
@ -261,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 + " ";
}
@ -276,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>",
@ -285,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();
@ -295,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];
}
@ -303,7 +300,7 @@ function rawNicks() {
return [me];
}
function completeNicks(word, isFuzzy) {
function completeNicks(word: string, isFuzzy: boolean) {
const users = rawNicks();
word = word.toLowerCase();
@ -314,19 +311,30 @@ function completeNicks(word, isFuzzy) {
return users.filter((w) => !w.toLowerCase().indexOf(word));
}
function completeCommands(word) {
const words = constants.commands.slice();
function getCommands() {
let cmds = constants.commands.slice();
return fuzzyGrep(word, words);
if (!store.state.settings.searchEnabled) {
cmds = cmds.filter((c) => c !== "/search");
}
return cmds;
}
function completeChans(word) {
const words = [];
function completeCommands(word: string) {
const commands = getCommands();
return fuzzyGrep(word, commands);
}
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);
function completeChans(word: string) {
const words: string[] = [];
if (store.state.activeChannel) {
for (const channel of store.state.activeChannel.network.channels) {
// Push all channels that start with the same CHANTYPE
if (channel.type === 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};

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