Author SHA1 Message Date
Simon Ser 488e11eddc eslint: add extra rules 2024-11-16 12:59:42 +01:00
Simon Ser 2f1bf8a2fe lib/client: don't mutate input argument in fetchHistoryBetween() 2024-11-16 12:45:08 +01:00
Simon Ser 75eb175e24 eslint: enable object-shorthand 2024-11-16 12:28:17 +01:00
Simon Ser db0a69dcfd commands: use Map instead of object 2024-11-16 12:27:34 +01:00
Simon Ser 9c2beac7dd eslint: enable no-implicit-coercion 2024-11-16 12:18:17 +01:00
Simon Ser 8ff1cd8317 eslint: add no-invalid-this and prefer-arrow-callback 2024-11-15 02:19:28 +01:00
Simon Ser f6e8f83d4e components/app: simplify connectParams object field 2024-11-15 02:19:28 +01:00
Simon Ser 18fa0ebc6a readme: switch from --production to --omit=dev
The former is deprecated.
2024-11-13 01:18:58 +01:00
Simon Ser afa09cfc25 lib/client: fix typo
That one turned out to be surprisingly tricky to dig out.
2024-11-12 23:11:10 +01:00
Simon Ser 977752e0f2 lib/client: bind handleOnline to this
It's used as a callback to removeEventListener().
2024-11-12 23:10:38 +01:00
Simon Ser 4bce52f162 ci: temporarily switch to alpine/edge
It has a more up-to-date nodejs version which doesn't deadlock in
"npm install".
2024-11-12 23:10:34 +01:00
Simon Ser 75ec7cd212 lib/client: don't throttle reconnections if opened long ago
If a connection was opened a long time ago, and recently got broken,
try to reconnect immediately.
2024-11-12 23:10:30 +01:00
Simon Ser 24e6767cab client: reconnect immediately if network comes online during backoff 2024-11-12 23:10:22 +01:00
Simon Ser ad165389f0 Fix nick colors in members list
Closes: https://todo.sr.ht/~emersion/gamja/164
2024-11-10 21:04:28 +01:00
Simon Ser daef362931 Upgrade dependencies 2024-10-23 20:31:08 +02:00
Calvin Lee 3ba0bfe3e6 change sorting of channels in the sidebar 2024-10-23 01:54:51 +00:00
Simon Ser b67cd10c64 Remove usage of == and != 2024-10-14 00:56:36 +02:00
Simon Ser 205a617c51 Move to Codeberg 2024-10-11 15:07:43 +02:00
Simon Ser 4145907d36 ci: use new SSH key for deployments 2024-10-11 00:25:02 +02:00
Simon Ser c6e63d5724 components/composer: add support for multiple file upload 2024-09-29 15:31:25 +02:00
xseandSimon Ser 2f6efb56de components/composer: handle drag and drop file upload 2024-09-29 15:21:56 +02:00
Simon Ser cf54beacc2 lint: turn on @stylistic/js/arrow-parens 2024-09-29 11:54:21 +02:00
Simon Ser 69485716a0 lint: turn on @stylistic/js/comma-dangle 2024-09-29 11:50:57 +02:00
Simon Ser b9d12bc8cd lint: turn on @stylistic/js/semi 2024-09-29 11:49:17 +02:00
Simon Ser b93db7ac0e ci: fail when ESLint reports warnings 2024-09-29 11:45:56 +02:00
Simon Ser d96e34da79 Wire up stylistic to ESLint 2024-09-29 11:45:42 +02:00
Simon Ser 78bfd16f25 Remove two remaining single quote strings 2024-09-29 11:45:13 +02:00
Simon Ser 07ae5f7167 Disallow var keyword 2024-09-28 22:07:39 +02:00
Simon Ser 312c755c11 eslint: fix global ignore for dist/ 2024-09-28 21:57:35 +02:00
Simon Ser a03ad28438 Update dependencies 2024-09-28 21:50:03 +02:00
Simon Ser f389ea6ffd eslint: ignore dist/ directory 2024-09-28 21:49:13 +02:00
Simon Ser 7c445d0bc9 Add ESLint 2024-09-28 21:45:45 +02:00
Simon Ser 97920ff7f6 Prefix unused variables with an underscore 2024-09-28 21:44:23 +02:00
Simon Ser b89fd604d0 Remove unnecessary break statements 2024-09-28 21:44:03 +02:00
Simon Ser 6693cc0c78 Remove unused variables 2024-09-28 21:43:23 +02:00
Simon Ser 6747c03a75 components/app: add missing break 2024-09-28 21:36:35 +02:00
Simon Ser 35e924258a components/buffer: drop leading asterisk for MODE messages
This is inconsistent with other messages.
2024-09-08 12:50:00 +02:00
Simon Ser 26792ec386 components/buffer: add human-readable channel mode changes
References: https://todo.sr.ht/~emersion/gamja/162
2024-09-08 12:48:07 +02:00
Simon Ser a3b375ab3f components/membership: fix operator color 2024-09-08 12:47:37 +02:00
Simon Ser e1a15ceeb9 components/membership: fix missing import 2024-09-07 17:59:01 +02:00
Simon Ser 9e68316467 components/buffer: use case-mapping when displaying MODE messages 2024-09-07 12:45:49 +02:00
Simon Ser 6be24e8ed9 lib/irc: unexport STD_MEMBERSHIPS and STD_CHANTYPES 2024-09-07 12:37:10 +02:00
Simon Ser 301f133272 lib/irc: move over STD_MEMBERSHIP_NAMES 2024-09-07 12:36:13 +02:00
Simon Ser 9bcfd088c2 components/member-list: remove dead code 2024-09-07 12:31:07 +02:00
Simon Ser 39de184734 readme: accept patches on Codeberg 2024-09-03 21:08:13 +02:00
Simon Ser 2c0f2a80e9 lib/irc: remove stray hardcoded constant 2024-08-13 00:29:00 +02:00
Simon Ser 1c5dc652a9 Downgrade preact to v10.17.1 once again
Seeing this again:

    DOMException: Node.insertBefore: Child to insert before is not a child of this node

Ref https://github.com/preactjs/preact/issues/4221
2024-07-12 09:07:02 +02:00
Simon Ser b06ebc0267 Upgrade dependencies 2024-07-08 14:43:18 +02:00
Simon Ser f657a81824 components/buffer-list: fix text color when active and unread
Previously this couldn't happen, but now we don't mark the active
buffer as read when the window doesn't have focus.
2024-07-08 08:33:24 +02:00
Simon Ser c69869209f components/scroll-manager: relax scroll check
See https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#determine_if_an_element_has_been_totally_scrolled
2024-07-02 09:00:30 +02:00
Simon Ser 331a2f0c4e components/scroll-manager: use getSnapshotBeforeUpdate
componentWillReceiveProps is deprecated.
2024-07-02 08:59:43 +02:00
Simon Ser 6c324d44a1 lib/client: add support for AUTHENTICATE chunking
SASL responses need to be split into 400 byte chunks before being
sent to the server.
2024-06-30 23:44:14 +02:00
Simon Ser d9f7faad88 components/app: warn when username is missing for OAuth introspection
Makes things easier to debug.
2024-06-25 17:54:48 +02:00
Simon Ser f698d7a250 doc/url-params: document wildcard in nick param 2024-05-09 22:56:58 +02:00
Simon Ser 0f273b9699 components/app: fix STATUSMSG NOTICE when target buffer is closed 2024-04-30 11:22:22 +02:00
Simon Ser 3d03c0dbcf components/app: update prevReadReceipt only when switching buffer
We don't want the unread separator to move around when the tab gets
focus, for instance.
2024-04-25 19:32:10 +02:00
sitting33andSimon Ser 0b7726819d Show number of highlights in window title
Co-authored-by: Simon Ser <contact@emersion.fr>
Closes: https://todo.sr.ht/~emersion/gamja/134
2024-04-25 16:49:19 +02:00
Simon Serandsitting33 8faff95631 components/app: include bouncer network name in window title
Co-authored-by: sitting33 <me@sit.sh>
2024-04-25 16:34:05 +02:00
Simon Ser 4d6f14ab0b components/app: introduce updateDocumentTitle()
The logic in here will get more involved once we add unread counts
and such.
2024-04-25 16:23:23 +02:00
sitting33andSimon Ser 9924f08794 Don't mark messages as read when window is not in focus 2024-04-25 16:07:41 +02:00
sitting33andSimon Ser f79b6bfaa1 components/app: split switchBuffer() and markBufferAsRead()
We'll want to mark as read independently in response to focus events.
2024-04-25 15:59:47 +02:00
Simon Ser 269e034581 Hide replies to our own internal WHO commands
References: https://todo.sr.ht/~emersion/gamja/88
2024-04-17 23:45:44 +02:00
Simon Ser 1ea7c30744 lib/client: fix number of field check in RPL_WHOSPCRPL
The first field is the client.
2024-04-17 23:29:54 +02:00
Simon SerandAlex McGrath 87e88cccca Add support for soju.im/filehost
For now, only handle paste events containing files.

Co-authored-by: Alex McGrath <amk@amk.ie>
2024-04-16 13:22:24 +02:00
Simon Ser 97b9efcc9f Upgrade dependencies 2024-04-10 14:24:34 +02:00
Simon Ser 7ec9ae7faa Upgrade preact to v10.20.2 2024-04-09 20:45:22 +02:00
Simon Ser ebcb731e2f components/buffer: fold NICK change chains 2024-03-29 16:08:04 +01:00
Simon Ser 23ceda5523 Revert "Upgrade preact to v10.20.0"
This reverts commit e843fe3ecb.

Unfortunately the fix doesn't seem to work for us…
2024-03-20 12:07:43 +01:00
Simon Ser e843fe3ecb Upgrade preact to v10.20.0
The upstream preact bug [1] has been fixed now!

[1]: https://github.com/preactjs/preact/issues/4221
2024-03-20 10:44:11 +01:00
Simon Ser 5171e0010d doc/setup: use plaintext HTTP listener for soju 2024-03-19 14:27:44 +01:00
Ángel CastañedaandSimon Ser 5db432b57a docs/setup: proxy pass host header to websocket server 2024-03-19 14:26:57 +01:00
Simon Ser 3584c1eb10 lib/irc: fix whitespace RegExp test in isURIPrefix 2024-03-13 15:41:04 +01:00
Martijn BraamandSimon Ser c1c7c91c38 Prevent zalgo in IRC messages
the overflow: auto; rule is enough to prevent zalgo from drawing over
other IRC messages containing it to a single line.
2024-03-12 09:29:37 +01:00
Simon Ser 2fe2ce6912 lib/irc: fix assignment to undefined variable in isURIPrefix 2024-03-07 23:04:40 +01:00
Simon Ser 57f7b1c011 lib/irc: fix whitespace split in isURIPrefix
We want to get the last index of whitespace, not the first one.
2024-03-07 11:40:37 +01:00
Simon Ser 5d3738bc40 lib/irc: ignore highlights in URLs 2024-03-02 12:36:30 +01:00
Simon Ser 429b4595e7 lib/client: print raw messages in debug mode
Browser consoles aren't super helpful in general and just show
the command name, require extra clicks to see the params.
2024-03-01 15:03:09 +01:00
Simon Ser 038cc68ee4 components/buffer-list: show realname as tooltip 2024-02-20 22:50:09 +01:00
Simon Ser 15cc546876 components/buffer: show realname as tooltip 2024-02-20 22:48:03 +01:00
Simon Ser a514104c55 commands: drop unvoice
We have devoice already, and that's the one defined in popular
clients such as WeeChat.
2024-02-15 16:40:41 +01:00
Simon Ser 7e5e94cda0 components/help: always show autocomplete keybind 2024-02-15 16:37:32 +01:00
Simon Ser 75d721c02d components/help: add autocomplete 2024-02-15 16:34:16 +01:00
Simon Ser 141fc3e07c Pin preact to v10.17.1
We can't upgrade due to this bug:
https://github.com/preactjs/preact/issues/4221
2024-01-10 20:30:29 +01:00
Simon Ser b38777e92a lib/linkify: add geo URI scheme 2024-01-10 11:38:23 +01:00
Simon Ser 0640ff8712 Upgrade linkifyjs to v4 2024-01-10 11:33:20 +01:00
Simon Ser 67b2b07506 Upgrade dependencies
Leave preact and linkifyjs alone because they cause breakage.
2024-01-10 10:50:30 +01:00
Simon Ser 15e451f7f8 doc/config-file: indicate where errors are logged 2024-01-07 22:16:56 +01:00
Simon Ser 617a3a7485 Downgrade preact to 10.17.1
References: https://github.com/preactjs/preact/issues/4221
2023-11-29 17:46:16 +01:00
Simon Ser 4f828db244 Downgrade preact to 10.18.2
References: https://github.com/preactjs/preact/issues/4221
2023-11-28 15:13:02 +01:00
Simon Ser bc19829673 Upgrade dependencies 2023-11-26 18:22:32 +01:00
Simon Ser 5b8c886c91 components/app: throttle our focus PINGs 2023-11-26 16:43:42 +01:00
Simon Ser c6a1513a07 doc/setup: add kimchi instructions 2023-11-12 10:22:00 +01:00
Simon Ser 3771b39979 doc/setup: improve gamja config instructions for webircgateway 2023-11-12 10:18:57 +01:00
Simon Ser ca4b3575b1 Move docs to a separate directory 2023-11-12 10:09:50 +01:00
Simon Ser 68cb6c56c4 components/buffer: drop duplicate MONITOR events 2023-11-10 18:11:34 +01:00
Simon Ser e0de4d1b36 Set targets.default.context in package.json
parcel doesn't auto-detect that field properly due to "engines".

Closes: https://todo.sr.ht/~emersion/gamja/158
2023-10-21 12:51:06 +02:00
Simon Ser 06f7cf9565 Fix ReferenceError when adding new bouncer network
Fixes the following:

    ReferenceError: can't access lexical declaration 'client' before initialization
    handleNetworkSubmit app.js:1868
2023-10-03 11:43:00 +02:00
Simon Ser 5e33919cce Show MONITOR online/offline notifications in user buffers
We were only showing QUIT, which was weird because it wouldn't
say when the user becomes online again. Use MONITOR instead.
2023-08-25 13:10:05 +02:00
Simon Ser 97b5970acb state: simplify MONITOR reply handling 2023-08-25 13:09:14 +02:00
Simon Ser ffbbde7f28 Upgrade dependencies 2023-08-17 10:50:31 +02:00
Simon Ser 8f29f0c35d lib/client: ensure server prefix is never null
Fixes a null deref in handleChatMessage, because incoming message
prefixes are populated with the server's if null.
2023-08-17 10:45:56 +02:00
Simon Ser 3b383308d4 Send PING on window focus
References: https://todo.sr.ht/~emersion/gamja/148
2023-06-23 16:05:45 +02:00
Simon Ser 3a95fd5ba4 components/switcher-form: match topics and realnames 2023-06-14 11:52:54 +02:00
Simon Ser 44a064274d Add buffer switcher 2023-06-08 15:07:28 +02:00
Simon Ser fe016807da components/help: fix typo for Ctrl key bindings 2023-06-08 12:15:53 +02:00
Simon Ser 10d988b891 store: fix undefined Unread 2023-06-08 12:09:24 +02:00
Simon Ser a0ed50a8e2 Keep closed buffers in store
This retains their delivery receipts.

Closes: https://todo.sr.ht/~emersion/gamja/154
2023-06-08 11:54:16 +02:00
Simon Ser 2f627eecad state: handle WHO replies in bulk 2023-04-19 13:04:58 +02:00
Simon Ser 2d651ef901 components/app: prevent multiple WHO channel commands in parallel
References: https://todo.sr.ht/~emersion/gamja/152
2023-04-19 12:56:12 +02:00
Simon Ser 535bdb2f52 Migrate to async/await 2023-04-19 12:51:13 +02:00
Simon Ser 57f64e9cc2 lib/irc: add formatURL 2023-04-19 11:43:45 +02:00
Simon Ser 57809be989 Upgrade dependencies 2023-04-04 19:10:10 +02:00
Simon Ser 6c26ee2156 store: fix clearing buffers for a specific server 2023-04-04 17:00:05 +02:00
Simon Ser 5db0105dbd store: protect against dup buffers 2023-04-04 16:51:23 +02:00
Simon Ser c8fda8ed53 store: stop matching server URL and nick
This was supposed to accomodate for multi-server support, but to
be honest this is out of scope for gamja.

Closes: https://todo.sr.ht/~emersion/gamja/151
2023-04-04 16:46:58 +02:00
Simon Ser fd63c169ed lib/client: encode empty SASL response as "+" 2023-03-21 17:57:09 +01:00
Simon Ser 2c3fbdd605 readme: document default for server.url in config.json 2023-03-13 20:36:47 +01:00
Simon Ser 2883234ff6 Don't perform OAuth redirection after server meteadata error 2023-03-10 14:14:37 +01:00
Giorgi Taba KobakhidzeandSimon Ser 4f350ae223 components/app: ensure msg.tags is initialized
Fixes the following error when sending a message on a server
without echo-message:

    Uncaught TypeError: t.tags is undefined
        prepareChatMessage app.js:602
        handleChatMessage app.js:616
        privmsg app.js:1514
        handleComposerSubmit app.js:1535
        handleSubmit composer.js:30
        Preact 15
        handleMessage app.js:1013
        connect app.js:791
        handleMessage client.js:448
        reconnect client.js:176
        reconnect client.js:174
        Yt client.js:151
        connect app.js:754
        handleConnectSubmit app.js:1279
        handleSubmit connect-form.js:74
        Preact 16
        handleConfig app.js:382
        <anonymous> app.js:238
        promise callback* app.js:237
        Preact 4
        <anonymous> main.js:4
2023-02-17 23:36:46 +01:00
Simon Ser f7459704f6 components/composer: focus composer on keydown if a link is active
Fixes message not typed after clicking on a link.
2023-01-31 18:28:51 +01:00
Simon Ser c6024e643a Upgrade dependencies 2023-01-16 12:10:31 +01:00
Juan Cruz OrioliandSimon Ser c547a32282 components: Use onInput instead of onChange
This is one of the differences between React and Preact:
https://preactjs.com/guide/v10/differences-to-react/#use-oninput-instead-of-onchange

Closes: https://todo.sr.ht/~emersion/gamja/128
2023-01-10 18:14:53 +01:00
delthasandSimon Ser 081f5743be Fix stripping hex color formatting
Hex colors can be set with the same formats as the regular colors:
<CODE>, <CODE><COLOR>, or <CODE><COLOR>,<COLOR>.

Previously we only supporteed <CODE><COLOR>.

This patch enables stripping colors for all valid color formats.

Co-authored-by: Simon Ser <contact@emersion.fr>
2022-12-02 16:03:14 +01:00
Simon Ser 3f059567c5 Skip regular chat message handling for infinite scroll
Infinite scroll is special: it shouldn't trigger notifications.
Additionally we need to avoid sending on MARKREAD command per
message in the chathistory batch.

Split chat message handling into separate functions.
2022-11-30 12:23:12 +01:00
Simon Ser 4b306305bf Move msg.tags fallback to client 2022-11-30 11:30:46 +01:00
Simon Ser a172c810e9 Make first server check more robust when disconnecting
A disconnect/reconnect cycle will bump the server ID.
2022-11-30 11:21:54 +01:00
Simon Ser ab3569e104 Close settings dialog when disconnecting 2022-11-30 11:18:23 +01:00
Simon Ser dc5e64aaac lib/client: unify checks for chathistory end 2022-11-30 10:17:50 +01:00
Simon Ser 2d27168529 Use ratified extended-monitor cap name
References: https://github.com/ircv3/ircv3-specifications/pull/508
2022-11-06 20:40:00 +01:00
Simon Ser 24ba3f5189 Remove unnecessary whoChannelBuffer() call
switchBuffer() will do that already, no need to do it manually here.
We risk sending two duplicate WHO commands.
2022-10-23 20:21:27 +02:00
Simon Ser 90a2c91651 Load initial members state via WHO when channel is selected
Closes: https://todo.sr.ht/~emersion/gamja/13
2022-10-23 20:18:33 +02:00
Simon Ser e815295503 Add support for OAuth 2.0 authentication 2022-10-14 10:52:44 +02:00
Simon Ser bbc94c88c0 Upgrade dependencies 2022-09-18 20:08:13 +02:00
Simon Ser 84ca0a4408 components/connect-form: autofocus username field 2022-09-12 13:43:58 +02:00
Simon Ser 84b68308b9 components/app: switch off loading state atomically
Set connectParams together with loading, to avoid intermediate
state where loading = false but connectParams isn't set yet.
2022-09-12 13:42:44 +02:00
Simon Ser 4964782c30 Display error in loading state 2022-09-12 13:41:23 +02:00
Simon Ser 54e1fc93d9 Add config option to generate random nickname
Closes: https://todo.sr.ht/~emersion/gamja/136
2022-09-12 13:04:59 +02:00
Simon Ser 34d3bd6df9 Remove unnecessary if in App.handleConfig
config.json is merged with baseConfig. The latter is guaranteed
to contain a "server" field.
2022-09-12 09:54:38 +02:00
Simon Ser a13f74d466 Disallow server.{autoconnect,auth} mismatch in config.json
This combination doesn't make sense.
2022-09-12 09:48:49 +02:00
Simon Ser a603b79e33 components/buffer-list: show buffers with errors in red 2022-09-05 14:00:52 +02:00
Nolan PrescottandSimon Ser 096fcbf829 Sort lists with localeCompare
The difference in case sensitivity is the most obvious change with
servers like soju that support CASEMAPPING ascii and
rfc1459. Currently the list:
  'Alpha', 'aardvark', 'Charlie', 'comma'
currently sorts to:
  'Alpha', 'Charlie', 'aardvark', 'comma'
with this change it will instead become:
  'aardvark', 'Alpha', 'Charlie', 'comma'

If something like RFC 7613 gets broader support then there are a few
more differences for a list like:
  'éclair', 'ecstatic, 'aardvark', 'zed', 'Gamma'
currently sorts to:
  'Gamma', 'aardvark', 'ecstatic', 'zed', 'éclair'
with this patch would instead sort to:
  'aardvark', 'éclair', 'ecstatic', 'Gamma', 'zed'

The above examples were run with a locale unspecified which fell back
to my browser/host default of 'en'.
2022-09-05 09:03:42 +02:00
Simon Ser a2d2a11d44 Drop support for soju.im/read
It's been superseded by draft/read-marker.
2022-09-03 14:41:53 +02:00
Simon Ser e6618c8a1f Fix draft/read-marker cap not negotiated
Fixes: 1428ec4d49 ("Add support for draft/read-marker")
2022-09-03 14:40:54 +02:00
Simon Ser aa9aa78d71 Fix ignored MARKREAD messages
The prefix is a remnant of the soju extension. The IRCv3 one
doesn't have it.

Fixes: 1428ec4d49 ("Add support for draft/read-marker")
2022-09-03 14:40:46 +02:00
Simon Ser 4780b9c709 Fetch read marker before backlog for user targets 2022-09-03 14:31:56 +02:00
Simon Ser e7b69cec9a Limit composer length
Often times IRC servers will truncate messages which are too big.
2022-08-28 19:16:41 +02:00
xseandSimon Ser cfbd91d257 Make use of destBuffers when fetching history.
Fixes an issue where messages intended to go on the server's buffer end up on their own
2022-08-22 12:46:03 +02:00
Simon Ser 7138e43710 Ignore RPL_CHANNEL_URL 2022-08-22 10:35:50 +02:00
Simon Ser 89647472ae components/app: don't open buffer for CTCP messages
These are usually completely uninteresting messages, e.g. CTCP
VERSION or whatever.
2022-08-22 10:30:56 +02:00
Simon Ser e2dc32c0d3 Update dependencies 2022-07-11 21:02:12 +02:00
Simon Ser 1bcd9d3607 ci: deploy to new server 2022-07-09 12:26:39 +02:00
Simon Ser e4ebf5eb80 ci: fix deploy host
emersion.fr is now an alias for the new server. gamja hasn't been
migrated yet.
2022-07-08 21:24:09 +02:00
Simon Ser 1428ec4d49 Add support for draft/read-marker
References: https://github.com/ircv3/ircv3-specifications/pull/489
2022-07-01 13:35:27 +02:00
ArikandSimon Ser 839e46360e Use monospace on <input> too
It looks like having "font-family: monospace" on <body> doesn't set it
for <input> too.
2022-07-01 13:34:22 +02:00
Simon Ser d0064dd647 components/buffer: show disclaimer for +draft/channel-context messages 2022-06-28 15:55:35 +02:00
delthasandSimon Ser b9693d53ec Support @+draft/channel-context
See: https://github.com/ircv3/ircv3-specifications/pull/498
2022-06-28 15:33:38 +02:00
Simon Ser f6ba40046f components/buffer-header: fix duplicate settings button 2022-06-28 15:11:48 +02:00
38 changed files with 4942 additions and 3495 deletions
+10 -4
View File
@@ -1,19 +1,25 @@
image: alpine/latest # TODO switch back to alpine/latest once the "npm install" deadlock is fixed
image: alpine/edge
packages: packages:
- npm - npm
- rsync - rsync
sources: sources:
- https://git.sr.ht/~emersion/gamja - https://codeberg.org/emersion/gamja.git
secrets: secrets:
- 5874ac5a-905e-4596-a117-fed1401c60ce # deploy SSH key - 7a146c8e-aeb4-46e7-99bf-05af7486bbe9 # deploy SSH key
tasks: tasks:
- setup: | - setup: |
cd gamja cd gamja
npm install --include=dev npm install --include=dev
- build: |
cd gamja
npm run build npm run build
- lint: |
cd gamja
npm run -- lint --max-warnings 0
- deploy: | - deploy: |
cd gamja/dist cd gamja/dist
[ "$(git rev-parse HEAD)" = "$(git rev-parse origin/master)" ] || complete-build [ "$(git rev-parse HEAD)" = "$(git rev-parse origin/master)" ] || complete-build
rsync --rsh="ssh -o StrictHostKeyChecking=no" -rP \ rsync --rsh="ssh -o StrictHostKeyChecking=no" -rP \
--delete --exclude=config.json \ --delete --exclude=config.json \
. deploy@emersion.fr:/srv/http/gamja . deploy-gamja@sheeta.emersion.fr:/srv/http/gamja
+10 -94
View File
@@ -10,54 +10,9 @@ Requires an IRC WebSocket server.
First install dependencies: First install dependencies:
npm install --production npm install --omit=dev
Then configure an HTTP server to serve the gamja files. Below are some Then [configure an HTTP server] to serve the gamja files.
server-specific instructions.
### [soju]
Add a WebSocket listener to soju, e.g. `listen wss://127.0.0.1:8080`.
Configure your reverse proxy to serve gamja files and proxy `/socket` to soju.
### [webircgateway]
Setup webircgateway to serve gamja files:
```ini
[fileserving]
enabled = true
webroot = /path/to/gamja
```
Then connect to webircgateway and append `?server=/webirc/websocket/` to the
URL.
### nginx
If you use nginx as a reverse HTTP proxy, make sure to bump the default read
timeout to a value higher than the IRC server PING interval. Example:
```
location / {
root /path/to/gamja;
}
location /socket {
proxy_pass http://127.0.0.1:8080;
proxy_read_timeout 600s;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
If you are unable to configure the proxy timeout accordingly, or if your IRC
server doesn't send PINGs, you can set the `server.ping` option in
`config.json` (see below).
### Development server ### Development server
@@ -76,52 +31,13 @@ Optionally, [Parcel] can be used to build a minified version of gamja.
npm install --include=dev npm install --include=dev
npm run build npm run build
## Query parameters ## Configuration
gamja settings can be overridden using URL query parameters: gamja can be configured via a [configuration file] and via [URL parameters].
- `server`: path or URL to the WebSocket server
- `nick`: nickname
- `channels`: comma-separated list of channels to join (`#` needs to be escaped)
- `open`: [IRC URL] to open
- `debug`: if set to 1, debug mode is enabled
Alternatively, the channels can be set with the URL fragment (ie, by just
appending the channel name to the gamja URL).
## Configuration file
gamja default settings can be set using a `config.json` file at the root:
```js
{
// IRC server settings.
"server": {
// WebSocket URL or path to connect to (string).
"url": "wss://irc.example.org",
// Channel(s) to auto-join (string or array of strings).
"autojoin": "#gamja",
// Controls how the password UI is presented to the user. Set to
// "mandatory" to require a password, "optional" to accept one but not
// require it, "disabled" to never ask for a password, or "external" to
// use SASL EXTERNAL. Defaults to "optional".
"auth": "optional",
// Default nickname (string).
"nick": "asdf",
// Don't display the login UI, immediately connect to the server
// (boolean).
"autoconnect": true,
// Interval in seconds to send PING commands (number). Set to 0 to
// disable. Enabling PINGs can have an impact on client power usage and
// should only be enabled if necessary.
"ping": 60
}
}
```
## Contributing ## Contributing
Send patches on the [mailing list], report bugs on the [issue tracker]. Discuss Send patches on [Codeberg], report bugs on the [issue tracker]. Discuss
in [#soju on Libera Chat]. in [#soju on Libera Chat].
## License ## License
@@ -130,11 +46,11 @@ AGPLv3, see LICENSE.
Copyright (C) 2020 The gamja Contributors Copyright (C) 2020 The gamja Contributors
[gamja]: https://sr.ht/~emersion/gamja/ [gamja]: https://codeberg.org/emersion/gamja
[soju]: https://soju.im [Codeberg]: https://codeberg.org/emersion/gamja
[webircgateway]: https://github.com/kiwiirc/webircgateway
[mailing list]: https://lists.sr.ht/~emersion/public-inbox
[issue tracker]: https://todo.sr.ht/~emersion/gamja [issue tracker]: https://todo.sr.ht/~emersion/gamja
[Parcel]: https://parceljs.org [Parcel]: https://parceljs.org
[IRC URL]: https://datatracker.ietf.org/doc/html/draft-butcher-irc-url-04 [configure an HTTP server]: doc/setup.md
[configuration file]: doc/config-file.md
[URL parameters]: doc/url-params.md
[#soju on Libera Chat]: ircs://irc.libera.chat/#soju [#soju on Libera Chat]: ircs://irc.libera.chat/#soju
+83 -51
View File
@@ -25,14 +25,14 @@ function getActiveChannel(app) {
return activeBuffer.name; return activeBuffer.name;
} }
function setUserHostMode(app, args, mode) { async function setUserHostMode(app, args, mode) {
let nick = args[0]; let nick = args[0];
if (!nick) { if (!nick) {
throw new Error("Missing nick"); throw new Error("Missing nick");
} }
let activeChannel = getActiveChannel(app); let activeChannel = getActiveChannel(app);
let client = getActiveClient(app); let client = getActiveClient(app);
client.whois(nick).then((whois) => { let whois = await client.whois(nick);
const info = whois[irc.RPL_WHOISUSER].params; const info = whois[irc.RPL_WHOISUSER].params;
const user = info[2]; const user = info[2];
const host = info[3]; const host = info[3];
@@ -40,7 +40,6 @@ function setUserHostMode(app, args, mode) {
command: "MODE", command: "MODE",
params: [activeChannel, mode, `*!${user}@${host}`], params: [activeChannel, mode, `*!${user}@${host}`],
}); });
});
} }
function markServerBufferUnread(app) { function markServerBufferUnread(app) {
@@ -54,6 +53,7 @@ function markServerBufferUnread(app) {
} }
const join = { const join = {
name: "join",
usage: "<name> [password]", usage: "<name> [password]",
description: "Join a channel", description: "Join a channel",
execute: (app, args) => { execute: (app, args) => {
@@ -70,6 +70,7 @@ const join = {
}; };
const kick = { const kick = {
name: "kick",
usage: "<nick> [comment]", usage: "<nick> [comment]",
description: "Remove a user from the channel", description: "Remove a user from the channel",
execute: (app, args) => { execute: (app, args) => {
@@ -84,10 +85,11 @@ const kick = {
}; };
const ban = { const ban = {
name: "ban",
usage: "[nick]", usage: "[nick]",
description: "Ban a user from the channel, or display the current ban list", description: "Ban a user from the channel, or display the current ban list",
execute: (app, args) => { execute: (app, args) => {
if (args.length == 0) { if (args.length === 0) {
let activeChannel = getActiveChannel(app); let activeChannel = getActiveChannel(app);
getActiveClient(app).send({ getActiveClient(app).send({
command: "MODE", command: "MODE",
@@ -112,20 +114,22 @@ function givemode(app, args, mode) {
}); });
} }
export default { const commands = [
"away": { {
name: "away",
usage: "[message]", usage: "[message]",
description: "Set away message", description: "Set away message",
execute: (app, args) => { execute: (app, args) => {
const params = [] const params = [];
if (args.length) { if (args.length) {
params.push(args.join(" ")); params.push(args.join(" "));
} }
getActiveClient(app).send({command: "AWAY", params}); getActiveClient(app).send({command: "AWAY", params});
}, },
}, },
"ban": ban, ban,
"buffer": { {
name: "buffer",
usage: "<name>", usage: "<name>",
description: "Switch to a buffer", description: "Switch to a buffer",
execute: (app, args) => { execute: (app, args) => {
@@ -139,39 +143,45 @@ export default {
throw new Error("Unknown buffer"); throw new Error("Unknown buffer");
}, },
}, },
"close": { {
name: "close",
description: "Close the current buffer", description: "Close the current buffer",
execute: (app, args) => { execute: (app, args) => {
let activeBuffer = app.state.buffers.get(app.state.activeBuffer); let activeBuffer = app.state.buffers.get(app.state.activeBuffer);
if (!activeBuffer || activeBuffer.type == BufferType.SERVER) { if (!activeBuffer || activeBuffer.type === BufferType.SERVER) {
throw new Error("Not in a user or channel buffer"); throw new Error("Not in a user or channel buffer");
} }
app.close(activeBuffer.id); app.close(activeBuffer.id);
}, },
}, },
"deop": { {
name: "deop",
usage: "<nick>", usage: "<nick>",
description: "Remove operator status for a user on this channel", description: "Remove operator status for a user on this channel",
execute: (app, args) => givemode(app, args, "-o"), execute: (app, args) => givemode(app, args, "-o"),
}, },
"devoice": { {
name: "devoice",
usage: "<nick>", usage: "<nick>",
description: "Remove voiced status for a user on this channel", description: "Remove voiced status for a user on this channel",
execute: (app, args) => givemode(app, args, "-v"), execute: (app, args) => givemode(app, args, "-v"),
}, },
"disconnect": { {
name: "disconnect",
description: "Disconnect from the server", description: "Disconnect from the server",
execute: (app, args) => { execute: (app, args) => {
app.disconnect(); app.disconnect();
}, },
}, },
"help": { {
name: "help",
description: "Show help menu", description: "Show help menu",
execute: (app, args) => { execute: (app, args) => {
app.openHelp(); app.openHelp();
}, },
}, },
"invite": { {
name: "invite",
usage: "<nick>", usage: "<nick>",
description: "Invite a user to the channel", description: "Invite a user to the channel",
execute: (app, args) => { execute: (app, args) => {
@@ -185,10 +195,11 @@ export default {
]}); ]});
}, },
}, },
"j": join, { ...join, name: "j" },
"join": join, join,
"kick": kick, kick,
"kickban": { {
name: "kickban",
usage: "<target>", usage: "<target>",
description: "Ban a user and removes them from the channel", description: "Ban a user and removes them from the channel",
execute: (app, args) => { execute: (app, args) => {
@@ -196,7 +207,8 @@ export default {
ban.execute(app, args); ban.execute(app, args);
}, },
}, },
"lusers": { {
name: "lusers",
usage: "[<mask> [<target>]]", usage: "[<mask> [<target>]]",
description: "Request user statistics about the network", description: "Request user statistics about the network",
execute: (app, args) => { execute: (app, args) => {
@@ -204,7 +216,8 @@ export default {
markServerBufferUnread(app); markServerBufferUnread(app);
}, },
}, },
"me": { {
name: "me",
usage: "<action>", usage: "<action>",
description: "Send an action message to the current buffer", description: "Send an action message to the current buffer",
execute: (app, args) => { execute: (app, args) => {
@@ -214,7 +227,8 @@ export default {
app.privmsg(target, text); app.privmsg(target, text);
}, },
}, },
"mode": { {
name: "mode",
usage: "[target] [modes] [mode args...]", usage: "[target] [modes] [mode args...]",
description: "Query or change a channel or user mode", description: "Query or change a channel or user mode",
execute: (app, args) => { execute: (app, args) => {
@@ -226,7 +240,8 @@ export default {
getActiveClient(app).send({ command: "MODE", params: args }); getActiveClient(app).send({ command: "MODE", params: args });
}, },
}, },
"motd": { {
name: "motd",
usage: "[server]", usage: "[server]",
description: "Get the Message Of The Day", description: "Get the Message Of The Day",
execute: (app, args) => { execute: (app, args) => {
@@ -234,7 +249,8 @@ export default {
markServerBufferUnread(app); markServerBufferUnread(app);
}, },
}, },
"msg": { {
name: "msg",
usage: "<target> <message>", usage: "<target> <message>",
description: "Send a message to a nickname or a channel", description: "Send a message to a nickname or a channel",
execute: (app, args) => { execute: (app, args) => {
@@ -243,7 +259,8 @@ export default {
getActiveClient(app).send({ command: "PRIVMSG", params: [target, text] }); getActiveClient(app).send({ command: "PRIVMSG", params: [target, text] });
}, },
}, },
"nick": { {
name: "nick",
usage: "<nick>", usage: "<nick>",
description: "Change current nickname", description: "Change current nickname",
execute: (app, args) => { execute: (app, args) => {
@@ -251,7 +268,8 @@ export default {
getActiveClient(app).send({ command: "NICK", params: [newNick] }); getActiveClient(app).send({ command: "NICK", params: [newNick] });
}, },
}, },
"notice": { {
name: "notice",
usage: "<target> <message>", usage: "<target> <message>",
description: "Send a notice to a nickname or a channel", description: "Send a notice to a nickname or a channel",
execute: (app, args) => { execute: (app, args) => {
@@ -260,12 +278,14 @@ export default {
getActiveClient(app).send({ command: "NOTICE", params: [target, text] }); getActiveClient(app).send({ command: "NOTICE", params: [target, text] });
}, },
}, },
"op": { {
name: "op",
usage: "<nick>", usage: "<nick>",
description: "Give a user operator status on this channel", description: "Give a user operator status on this channel",
execute: (app, args) => givemode(app, args, "+o"), execute: (app, args) => givemode(app, args, "+o"),
}, },
"part": { {
name: "part",
usage: "[reason]", usage: "[reason]",
description: "Leave a channel", description: "Leave a channel",
execute: (app, args) => { execute: (app, args) => {
@@ -278,7 +298,8 @@ export default {
getActiveClient(app).send({ command: "PART", params }); getActiveClient(app).send({ command: "PART", params });
}, },
}, },
"query": { {
name: "query",
usage: "<nick> [message]", usage: "<nick> [message]",
description: "Open a buffer to send messages to a nickname", description: "Open a buffer to send messages to a nickname",
execute: (app, args) => { execute: (app, args) => {
@@ -294,11 +315,12 @@ export default {
} }
}, },
}, },
"quiet": { {
name: "quiet",
usage: "[nick]", usage: "[nick]",
description: "Quiet a user in the channel, or display the current quiet list", description: "Quiet a user in the channel, or display the current quiet list",
execute: (app, args) => { execute: (app, args) => {
if (args.length == 0) { if (args.length === 0) {
getActiveClient(app).send({ getActiveClient(app).send({
command: "MODE", command: "MODE",
params: [getActiveChannel(app), "+q"], params: [getActiveChannel(app), "+q"],
@@ -308,13 +330,15 @@ export default {
} }
}, },
}, },
"quit": { {
name: "quit",
description: "Quit", description: "Quit",
execute: (app, args) => { execute: (app, args) => {
app.close({ name: SERVER_BUFFER }); app.close({ name: SERVER_BUFFER });
}, },
}, },
"quote": { {
name: "quote",
usage: "<command>", usage: "<command>",
description: "Send a raw IRC command to the server", description: "Send a raw IRC command to the server",
execute: (app, args) => { execute: (app, args) => {
@@ -327,13 +351,15 @@ export default {
getActiveClient(app).send(msg); getActiveClient(app).send(msg);
}, },
}, },
"reconnect": { {
name: "reconnect",
description: "Reconnect to the server", description: "Reconnect to the server",
execute: (app, args) => { execute: (app, args) => {
app.reconnect(); app.reconnect();
}, },
}, },
"setname": { {
name: "setname",
usage: "<realname>", usage: "<realname>",
description: "Change current realname", description: "Change current realname",
execute: (app, args) => { execute: (app, args) => {
@@ -346,7 +372,8 @@ export default {
// TODO: save to local storage // TODO: save to local storage
}, },
}, },
"stats": { {
name: "stats",
usage: "<query> [server]", usage: "<query> [server]",
description: "Request server statistics", description: "Request server statistics",
execute: (app, args) => { execute: (app, args) => {
@@ -362,7 +389,8 @@ export default {
markServerBufferUnread(app); markServerBufferUnread(app);
}, },
}, },
"topic": { {
name: "topic",
usage: "<topic>", usage: "<topic>",
description: "Change the topic of the current channel", description: "Change the topic of the current channel",
execute: (app, args) => { execute: (app, args) => {
@@ -374,31 +402,30 @@ export default {
getActiveClient(app).send({ command: "TOPIC", params }); getActiveClient(app).send({ command: "TOPIC", params });
}, },
}, },
"unban": { {
name: "unban",
usage: "<nick>", usage: "<nick>",
description: "Remove a user from the ban list", description: "Remove a user from the ban list",
execute: (app, args) => { execute: (app, args) => {
return setUserHostMode(app, args, "-b"); return setUserHostMode(app, args, "-b");
}, },
}, },
"unquiet": { {
name: "unquiet",
usage: "<nick>", usage: "<nick>",
description: "Remove a user from the quiet list", description: "Remove a user from the quiet list",
execute: (app, args) => { execute: (app, args) => {
return setUserHostMode(app, args, "-q"); return setUserHostMode(app, args, "-q");
}, },
}, },
"unvoice": { {
usage: "<nick>", name: "voice",
description: "Remove a user from the voiced list",
execute: (app, args) => givemode(app, args, "-v"),
},
"voice": {
usage: "<nick>", usage: "<nick>",
description: "Give a user voiced status on this channel", description: "Give a user voiced status on this channel",
execute: (app, args) => givemode(app, args, "+v"), execute: (app, args) => givemode(app, args, "+v"),
}, },
"who": { {
name: "who",
usage: "<mask>", usage: "<mask>",
description: "Retrieve a list of users", description: "Retrieve a list of users",
execute: (app, args) => { execute: (app, args) => {
@@ -406,7 +433,8 @@ export default {
markServerBufferUnread(app); markServerBufferUnread(app);
}, },
}, },
"whois": { {
name: "whois",
usage: "<nick>", usage: "<nick>",
description: "Retrieve information about a user", description: "Retrieve information about a user",
execute: (app, args) => { execute: (app, args) => {
@@ -418,7 +446,8 @@ export default {
markServerBufferUnread(app); markServerBufferUnread(app);
}, },
}, },
"whowas": { {
name: "whowas",
usage: "<nick> [count]", usage: "<nick> [count]",
description: "Retrieve information about an offline user", description: "Retrieve information about an offline user",
execute: (app, args) => { execute: (app, args) => {
@@ -429,7 +458,8 @@ export default {
markServerBufferUnread(app); markServerBufferUnread(app);
}, },
}, },
"list": { {
name: "list",
usage: "[filter]", usage: "[filter]",
description: "Retrieve a list of channels from a network", description: "Retrieve a list of channels from a network",
execute: (app, args) => { execute: (app, args) => {
@@ -437,4 +467,6 @@ export default {
markServerBufferUnread(app); markServerBufferUnread(app);
}, },
}, },
}; ];
export default new Map(commands.map((cmd) => [cmd.name, cmd]));
+423 -102
View File
@@ -1,5 +1,6 @@
import * as irc from "../lib/irc.js"; import * as irc from "../lib/irc.js";
import Client from "../lib/client.js"; import Client from "../lib/client.js";
import * as oauth2 from "../lib/oauth2.js";
import Buffer from "./buffer.js"; import Buffer from "./buffer.js";
import BufferList from "./buffer-list.js"; import BufferList from "./buffer-list.js";
import BufferHeader from "./buffer-header.js"; import BufferHeader from "./buffer-header.js";
@@ -12,6 +13,7 @@ import AuthForm from "./auth-form.js";
import RegisterForm from "./register-form.js"; import RegisterForm from "./register-form.js";
import VerifyForm from "./verify-form.js"; import VerifyForm from "./verify-form.js";
import SettingsForm from "./settings-form.js"; import SettingsForm from "./settings-form.js";
import SwitcherForm from "./switcher-form.js";
import Composer from "./composer.js"; import Composer from "./composer.js";
import ScrollManager from "./scroll-manager.js"; import ScrollManager from "./scroll-manager.js";
import Dialog from "./dialog.js"; import Dialog from "./dialog.js";
@@ -53,7 +55,7 @@ function isProduction() {
// NODE_ENV is set by the Parcel build system // NODE_ENV is set by the Parcel build system
try { try {
return process.env.NODE_ENV === "production"; return process.env.NODE_ENV === "production";
} catch (err) { } catch (_err) {
return false; return false;
} }
} }
@@ -92,7 +94,7 @@ function splitHostPort(str) {
function fillConnectParams(params) { function fillConnectParams(params) {
let host = window.location.host || "localhost:8080"; let host = window.location.host || "localhost:8080";
let proto = "wss:"; let proto = "wss:";
if (window.location.protocol != "https:") { if (window.location.protocol !== "https:") {
proto = "ws:"; proto = "ws:";
} }
let path = window.location.pathname || "/"; let path = window.location.pathname || "/";
@@ -198,6 +200,7 @@ export default class App extends Component {
autoOpenURL = null; autoOpenURL = null;
messageNotifications = new Set(); messageNotifications = new Set();
baseTitle = null; baseTitle = null;
lastFocusPingDate = null;
constructor(props) { constructor(props) {
super(props); super(props);
@@ -224,6 +227,9 @@ export default class App extends Component {
this.handleVerifySubmit = this.handleVerifySubmit.bind(this); this.handleVerifySubmit = this.handleVerifySubmit.bind(this);
this.handleOpenSettingsClick = this.handleOpenSettingsClick.bind(this); this.handleOpenSettingsClick = this.handleOpenSettingsClick.bind(this);
this.handleSettingsChange = this.handleSettingsChange.bind(this); this.handleSettingsChange = this.handleSettingsChange.bind(this);
this.handleSettingsDisconnect = this.handleSettingsDisconnect.bind(this);
this.handleSwitchSubmit = this.handleSwitchSubmit.bind(this);
this.handleWindowFocus = this.handleWindowFocus.bind(this);
this.state.settings = { this.state.settings = {
...this.state.settings, ...this.state.settings,
@@ -249,12 +255,9 @@ export default class App extends Component {
* - Default server URL constructed from the current URL location (this is * - Default server URL constructed from the current URL location (this is
* done in fillConnectParams) * done in fillConnectParams)
*/ */
handleConfig(config) { async handleConfig(config) {
this.setState({ loading: false });
let connectParams = { ...this.state.connectParams }; let connectParams = { ...this.state.connectParams };
if (config.server) {
if (typeof config.server.url === "string") { if (typeof config.server.url === "string") {
connectParams.url = config.server.url; connectParams.url = config.server.url;
} }
@@ -275,6 +278,14 @@ export default class App extends Component {
if (typeof config.server.ping === "number") { if (typeof config.server.ping === "number") {
connectParams.ping = config.server.ping; connectParams.ping = config.server.ping;
} }
if (connectParams.autoconnect && config.server.auth === "mandatory") {
console.error("Error in config.json: cannot set server.autoconnect = true and server.auth = \"mandatory\"");
connectParams.autoconnect = false;
}
if (config.server.auth === "oauth2" && (!config.oauth2 || !config.oauth2.url || !config.oauth2.client_id)) {
console.error("Error in config.json: server.auth = \"oauth2\" requires oauth2 settings");
config.server.auth = null;
} }
let autoconnect = store.autoconnect.load(); let autoconnect = store.autoconnect.load();
@@ -320,6 +331,48 @@ export default class App extends Component {
this.config = config; this.config = config;
if (!connectParams.nick && connectParams.autoconnect) {
connectParams.nick = "user-*";
}
if (connectParams.nick && connectParams.nick.includes("*")) {
let placeholder = Math.random().toString(36).substr(2, 7);
connectParams.nick = connectParams.nick.replace("*", placeholder);
}
if (config.server.auth === "oauth2" && !connectParams.saslOauthBearer) {
if (queryParams.error) {
console.error("OAuth 2.0 authorization failed: ", queryParams.error);
this.showError("Authentication failed: " + (queryParams.error_description || queryParams.error));
return;
}
if (!queryParams.code) {
this.redirectOauth2Authorize();
return;
}
// Strip code from query params, to prevent page refreshes from
// trying to exchange the code again
let url = new URL(window.location.toString());
url.searchParams.delete("code");
url.searchParams.delete("state");
window.history.replaceState(null, "", url.toString());
let saslOauthBearer;
try {
saslOauthBearer = await this.exchangeOauth2Code(queryParams.code);
} catch (err) {
this.showError(err);
return;
}
connectParams.saslOauthBearer = saslOauthBearer;
if (saslOauthBearer.username && !connectParams.nick) {
connectParams.nick = saslOauthBearer.username;
}
}
if (autojoin.length > 0) { if (autojoin.length > 0) {
if (connectParams.autoconnect) { if (connectParams.autoconnect) {
// Ask the user whether they want to join that new channel. // Ask the user whether they want to join that new channel.
@@ -330,7 +383,7 @@ export default class App extends Component {
} }
} }
this.setState({ connectParams: connectParams }); this.setState({ loading: false, connectParams });
if (connectParams.autoconnect) { if (connectParams.autoconnect) {
this.setState({ connectForm: false }); this.setState({ connectForm: false });
@@ -338,6 +391,63 @@ export default class App extends Component {
} }
} }
async redirectOauth2Authorize() {
let serverMetadata;
try {
serverMetadata = await oauth2.fetchServerMetadata(this.config.oauth2.url);
} catch (err) {
console.error("Failed to fetch OAuth 2.0 server metadata:", err);
this.showError("Failed to fetch OAuth 2.0 server metadata");
return;
}
oauth2.redirectAuthorize({
serverMetadata,
clientId: this.config.oauth2.client_id,
redirectUri: window.location.toString(),
scope: this.config.oauth2.scope,
});
}
async exchangeOauth2Code(code) {
let serverMetadata = await oauth2.fetchServerMetadata(this.config.oauth2.url);
let redirectUri = new URL(window.location.toString());
redirectUri.searchParams.delete("code");
redirectUri.searchParams.delete("state");
let data = await oauth2.exchangeCode({
serverMetadata,
redirectUri: redirectUri.toString(),
code,
clientId: this.config.oauth2.client_id,
clientSecret: this.config.oauth2.client_secret,
});
// TODO: handle expires_in/refresh_token
let token = data.access_token;
let username = null;
if (serverMetadata.introspection_endpoint) {
try {
let data = await oauth2.introspectToken({
serverMetadata,
token,
clientId: this.config.oauth2.client_id,
clientSecret: this.config.oauth2.client_secret,
});
username = data.username;
if (!username) {
console.warn("Username missing from OAuth 2.0 token introspection response");
}
} catch (err) {
console.warn("Failed to introspect OAuth 2.0 token:", err);
}
}
return { token, username };
}
showError(err) { showError(err) {
console.error("App error: ", err); console.error("App error: ", err);
@@ -386,16 +496,17 @@ export default class App extends Component {
let stored = this.bufferStore.get({ name, server: client.params }); let stored = this.bufferStore.get({ name, server: client.params });
if (client.caps.enabled.has("draft/chathistory") && stored) { if (client.caps.enabled.has("draft/chathistory") && stored) {
this.setBufferState({ server: serverID, name }, { unread: stored.unread }); this.setBufferState({ server: serverID, name }, { unread: stored.unread }, () => {
this.updateDocumentTitle();
});
} }
if (!stored) {
this.bufferStore.put({ this.bufferStore.put({
name, name,
server: client.params, server: client.params,
unread: Unread.NONE, closed: false,
}); });
} }
}
createBuffer(serverID, name) { createBuffer(serverID, name) {
let client = this.clients.get(serverID); let client = this.clients.get(serverID);
@@ -404,7 +515,7 @@ export default class App extends Component {
this.setState((state) => { this.setState((state) => {
let updated; let updated;
[id, updated] = State.createBuffer(state, name, serverID, client); [id, updated] = State.createBuffer(state, name, serverID, client);
isNew = !!updated; isNew = Boolean(updated);
return updated; return updated;
}); });
if (isNew) { if (isNew) {
@@ -414,17 +525,14 @@ export default class App extends Component {
} }
sendReadReceipt(client, storedBuffer) { sendReadReceipt(client, storedBuffer) {
if (!client.caps.enabled.has("soju.im/read")) { if (!client.supportsReadMarker()) {
return; return;
} }
let readReceipt = storedBuffer.receipts[ReceiptType.READ]; let readReceipt = storedBuffer.receipts[ReceiptType.READ];
if (storedBuffer.name === "*" || !readReceipt) { if (storedBuffer.name === "*" || !readReceipt) {
return; return;
} }
client.send({ client.setReadMarker(storedBuffer.name, readReceipt.time);
command: "READ",
params: [storedBuffer.name, "timestamp="+readReceipt.time],
});
} }
switchBuffer(id) { switchBuffer(id) {
@@ -438,13 +546,9 @@ export default class App extends Component {
let client = this.clients.get(buf.server); let client = this.clients.get(buf.server);
let stored = this.bufferStore.get({ name: buf.name, server: client.params }); let stored = this.bufferStore.get({ name: buf.name, server: client.params });
let prevReadReceipt = getReceipt(stored, ReceiptType.READ); let prevReadReceipt = getReceipt(stored, ReceiptType.READ);
// TODO: only mark as read if user scrolled at the bottom let update = State.updateBuffer(state, buf.id, { prevReadReceipt });
let update = State.updateBuffer(state, buf.id, {
unread: Unread.NONE,
prevReadReceipt,
});
return { ...update, activeBuffer: buf.id }; return { activeBuffer: buf.id, ...update };
}, () => { }, () => {
if (!buf) { if (!buf) {
return; return;
@@ -454,6 +558,35 @@ export default class App extends Component {
this.buffer.current.focus(); this.buffer.current.focus();
} }
let server = this.state.servers.get(buf.server);
if (buf.type === BufferType.NICK && !server.users.has(buf.name)) {
this.whoUserBuffer(buf.name, buf.server);
}
if (buf.type === BufferType.CHANNEL && !buf.hasInitialWho) {
this.whoChannelBuffer(buf.name, buf.server);
}
this.updateDocumentTitle();
});
// TODO: only mark as read if user scrolled at the bottom
this.markBufferAsRead(id);
}
markBufferAsRead(id) {
let buf;
this.setState((state) => {
buf = State.getBuffer(state, id);
if (!buf) {
return;
}
return State.updateBuffer(state, buf.id, { unread: Unread.NONE });
}, () => {
if (!buf) {
return;
}
let client = this.clients.get(buf.server); let client = this.clients.get(buf.server);
for (let notif of this.messageNotifications) { for (let notif of this.messageNotifications) {
@@ -475,32 +608,73 @@ export default class App extends Component {
} }
} }
let server = this.state.servers.get(buf.server); this.updateDocumentTitle();
if (buf.type === BufferType.NICK && !server.users.has(buf.name)) {
this.whoUserBuffer(buf.name, buf.server);
}
if (buf.type !== BufferType.SERVER) {
document.title = buf.name + ' · ' + this.baseTitle;
} else {
document.title = this.baseTitle;
}
}); });
} }
addMessage(serverID, bufName, msg) { updateDocumentTitle() {
let client = this.clients.get(serverID); let buf = State.getBuffer(this.state, this.state.activeBuffer);
let server;
if (buf) {
server = this.state.servers.get(buf.server);
}
let bouncerNetwork;
if (server.bouncerNetID) {
bouncerNetwork = this.state.bouncerNetworks.get(server.bouncerNetID);
}
let numUnread = 0;
for (let buffer of this.state.buffers.values()) {
if (Unread.compare(buffer.unread, Unread.HIGHLIGHT) >= 0) {
numUnread++;
}
}
let parts = [];
if (buf && buf.type !== BufferType.SERVER) {
parts.push(buf.name);
}
if (bouncerNetwork) {
parts.push(getServerName(server, bouncerNetwork));
}
parts.push(this.baseTitle);
let title = "";
if (numUnread > 0) {
title = `(${numUnread}) `;
}
title += parts.join(" · ");
document.title = title;
}
prepareChatMessage(serverID, msg) {
// Treat server-wide broadcasts as highlights. They're sent by server // Treat server-wide broadcasts as highlights. They're sent by server
// operators and can contain important information. // operators and can contain important information.
if (msg.isHighlight === undefined) {
let client = this.clients.get(serverID);
msg.isHighlight = irc.isHighlight(msg, client.nick, client.cm) || irc.isServerBroadcast(msg); msg.isHighlight = irc.isHighlight(msg, client.nick, client.cm) || irc.isServerBroadcast(msg);
}
if (!msg.tags) { if (!msg.tags) {
// Can happen for outgoing messages for instance
msg.tags = {}; msg.tags = {};
} }
if (!msg.tags.time) { if (!msg.tags.time) {
msg.tags.time = irc.formatDate(new Date()); msg.tags.time = irc.formatDate(new Date());
} }
}
addChatMessage(serverID, bufName, msg) {
this.prepareChatMessage(serverID, msg);
let bufID = { server: serverID, name: bufName };
this.setState((state) => State.addMessage(state, msg, bufID));
}
handleChatMessage(serverID, bufName, msg) {
let client = this.clients.get(serverID);
this.prepareChatMessage(serverID, msg);
let stored = this.bufferStore.get({ name: bufName, server: client.params }); let stored = this.bufferStore.get({ name: bufName, server: client.params });
let deliveryReceipt = getReceipt(stored, ReceiptType.DELIVERED); let deliveryReceipt = getReceipt(stored, ReceiptType.DELIVERED);
@@ -508,14 +682,12 @@ export default class App extends Component {
let isDelivered = isMessageBeforeReceipt(msg, deliveryReceipt); let isDelivered = isMessageBeforeReceipt(msg, deliveryReceipt);
let isRead = isMessageBeforeReceipt(msg, readReceipt); let isRead = isMessageBeforeReceipt(msg, readReceipt);
// TODO: messages coming from infinite scroll shouldn't trigger notifications
if (client.isMyNick(msg.prefix.name)) { if (client.isMyNick(msg.prefix.name)) {
isRead = true; isRead = true;
} }
let msgUnread = Unread.NONE; let msgUnread = Unread.NONE;
if ((msg.command == "PRIVMSG" || msg.command == "NOTICE") && !isRead) { if ((msg.command === "PRIVMSG" || msg.command === "NOTICE") && !isRead) {
let target = msg.params[0]; let target = msg.params[0];
let text = msg.params[1]; let text = msg.params[1];
@@ -530,7 +702,7 @@ export default class App extends Component {
msgUnread = Unread.MESSAGE; msgUnread = Unread.MESSAGE;
} }
if (msgUnread == Unread.HIGHLIGHT && !isDelivered && !irc.parseCTCP(msg)) { if (msgUnread === Unread.HIGHLIGHT && !isDelivered && !irc.parseCTCP(msg)) {
let title = "New " + kind + " from " + msg.prefix.name; let title = "New " + kind + " from " + msg.prefix.name;
if (client.isChannel(bufName)) { if (client.isChannel(bufName)) {
title += " in " + bufName; title += " in " + bufName;
@@ -588,7 +760,7 @@ export default class App extends Component {
// Open a new buffer if the message doesn't come from me or is a // Open a new buffer if the message doesn't come from me or is a
// self-message // self-message
if ((!client.isMyNick(msg.prefix.name) || client.isMyNick(bufName)) && (msg.command != "PART" && msg.comand != "QUIT")) { if ((!client.isMyNick(msg.prefix.name) || client.isMyNick(bufName)) && (msg.command !== "PART" && msg.comand !== "QUIT" && msg.command !== irc.RPL_MONONLINE && msg.command !== irc.RPL_MONOFFLINE)) {
this.createBuffer(serverID, bufName); this.createBuffer(serverID, bufName);
} }
@@ -600,7 +772,7 @@ export default class App extends Component {
let prevReadReceipt = buf.prevReadReceipt; let prevReadReceipt = buf.prevReadReceipt;
let receipts = { [ReceiptType.DELIVERED]: receiptFromMessage(msg) }; let receipts = { [ReceiptType.DELIVERED]: receiptFromMessage(msg) };
if (this.state.activeBuffer !== buf.id) { if (this.state.activeBuffer !== buf.id || !document.hasFocus()) {
unread = Unread.union(unread, msgUnread); unread = Unread.union(unread, msgUnread);
} else { } else {
receipts[ReceiptType.READ] = receiptFromMessage(msg); receipts[ReceiptType.READ] = receiptFromMessage(msg);
@@ -621,6 +793,10 @@ export default class App extends Component {
this.sendReadReceipt(client, stored); this.sendReadReceipt(client, stored);
} }
return { unread, prevReadReceipt }; return { unread, prevReadReceipt };
}, () => {
if (msgUnread === Unread.HIGHLIGHT) {
this.updateDocumentTitle();
}
}); });
} }
@@ -730,6 +906,12 @@ export default class App extends Component {
let client = this.clients.get(serverID); let client = this.clients.get(serverID);
let chatHistoryBatch = irc.findBatchByType(msg, "chathistory"); let chatHistoryBatch = irc.findBatchByType(msg, "chathistory");
// Reply triggered by some command sent by us, not worth displaying to
// the user
if (msg.internal) {
return [];
}
let target, channel, affectedBuffers; let target, channel, affectedBuffers;
switch (msg.command) { switch (msg.command) {
case "MODE": case "MODE":
@@ -744,13 +926,14 @@ export default class App extends Component {
if (client.isMyNick(target)) { if (client.isMyNick(target)) {
if (client.cm(msg.prefix.name) === client.cm(client.serverPrefix.name)) { if (client.cm(msg.prefix.name) === client.cm(client.serverPrefix.name)) {
target = SERVER_BUFFER; target = SERVER_BUFFER;
} else {
let context = msg.tags["+draft/channel-context"];
if (context && client.isChannel(context) && State.getBuffer(this.state, { server: serverID, name: context })) {
target = context;
} else { } else {
target = msg.prefix.name; target = msg.prefix.name;
} }
} }
if (msg.command === "NOTICE" && !State.getBuffer(this.state, { server: serverID, name: target })) {
// Don't open a new buffer if this is just a NOTICE
target = SERVER_BUFFER;
} }
let allowedPrefixes = client.isupport.statusMsg(); let allowedPrefixes = client.isupport.statusMsg();
@@ -760,6 +943,22 @@ export default class App extends Component {
target = parts.name; target = parts.name;
} }
} }
// Don't open a new buffer if this is just a NOTICE or a garbage
// CTCP message
let openNewBuffer = true;
if (msg.command !== "PRIVMSG") {
openNewBuffer = false;
} else {
let ctcp = irc.parseCTCP(msg);
if (ctcp && ctcp.command !== "ACTION") {
openNewBuffer = false;
}
}
if (!openNewBuffer && !State.getBuffer(this.state, { server: serverID, name: target })) {
target = SERVER_BUFFER;
}
return [target]; return [target];
case "JOIN": case "JOIN":
channel = msg.params[0]; channel = msg.params[0];
@@ -779,10 +978,10 @@ export default class App extends Component {
affectedBuffers.push(chatHistoryBatch.params[0]); affectedBuffers.push(chatHistoryBatch.params[0]);
} else { } else {
this.state.buffers.forEach((buf) => { this.state.buffers.forEach((buf) => {
if (buf.server != serverID) { if (buf.server !== serverID) {
return; return;
} }
if (!buf.members.has(msg.prefix.name) && client.cm(buf.name) !== client.cm(msg.prefix.name)) { if (!buf.members.has(msg.prefix.name)) {
return; return;
} }
affectedBuffers.push(buf.name); affectedBuffers.push(buf.name);
@@ -797,7 +996,7 @@ export default class App extends Component {
affectedBuffers.push(chatHistoryBatch.params[0]); affectedBuffers.push(chatHistoryBatch.params[0]);
} else { } else {
this.state.buffers.forEach((buf) => { this.state.buffers.forEach((buf) => {
if (buf.server != serverID) { if (buf.server !== serverID) {
return; return;
} }
if (!buf.members.has(msg.prefix.name)) { if (!buf.members.has(msg.prefix.name)) {
@@ -838,6 +1037,15 @@ export default class App extends Component {
case irc.RPL_INVITING: case irc.RPL_INVITING:
channel = msg.params[2]; channel = msg.params[2];
return [channel]; return [channel];
case irc.RPL_MONONLINE:
case irc.RPL_MONOFFLINE:
let targets = msg.params[1].split(",");
affectedBuffers = [];
for (let target of targets) {
let prefix = irc.parsePrefix(target);
affectedBuffers.push(prefix.name);
}
return affectedBuffers;
case irc.RPL_YOURHOST: case irc.RPL_YOURHOST:
case irc.RPL_MYINFO: case irc.RPL_MYINFO:
case irc.RPL_ISUPPORT: case irc.RPL_ISUPPORT:
@@ -849,9 +1057,8 @@ export default class App extends Component {
case irc.RPL_TOPICWHOTIME: case irc.RPL_TOPICWHOTIME:
case irc.RPL_NAMREPLY: case irc.RPL_NAMREPLY:
case irc.RPL_ENDOFNAMES: case irc.RPL_ENDOFNAMES:
case irc.RPL_MONONLINE:
case irc.RPL_MONOFFLINE:
case irc.RPL_SASLSUCCESS: case irc.RPL_SASLSUCCESS:
case irc.RPL_CHANNEL_URL:
case "AWAY": case "AWAY":
case "SETNAME": case "SETNAME":
case "CHGHOST": case "CHGHOST":
@@ -865,7 +1072,7 @@ export default class App extends Component {
case "CHATHISTORY": case "CHATHISTORY":
case "ACK": case "ACK":
case "BOUNCER": case "BOUNCER":
case "READ": case "MARKREAD":
// Ignore these // Ignore these
return []; return [];
default: default:
@@ -896,7 +1103,7 @@ export default class App extends Component {
// Restore opened channel and user buffers // Restore opened channel and user buffers
let join = []; let join = [];
for (let buf of this.bufferStore.list(client.params)) { for (let buf of this.bufferStore.list(client.params)) {
if (buf.name === "*") { if (buf.name === "*" || buf.closed) {
continue; continue;
} }
@@ -935,13 +1142,14 @@ export default class App extends Component {
this.openURL(this.autoOpenURL); this.openURL(this.autoOpenURL);
this.autoOpenURL = null; this.autoOpenURL = null;
} }
break;
case "JOIN": case "JOIN":
channel = msg.params[0]; channel = msg.params[0];
if (client.isMyNick(msg.prefix.name)) { if (client.isMyNick(msg.prefix.name)) {
this.syncBufferUnread(serverID, channel); this.syncBufferUnread(serverID, channel);
} }
if (channel == this.switchToChannel) { if (channel === this.switchToChannel) {
this.switchBuffer({ server: serverID, name: channel }); this.switchBuffer({ server: serverID, name: channel });
this.switchToChannel = null; this.switchToChannel = null;
} }
@@ -1015,10 +1223,10 @@ export default class App extends Component {
this.autoOpenURL = null; this.autoOpenURL = null;
} }
break; break;
case "READ": case "MARKREAD":
target = msg.params[0]; target = msg.params[0];
let bound = msg.params[1]; let bound = msg.params[1];
if (!client.isMyNick(msg.prefix.name) || bound === "*" || !bound.startsWith("timestamp=")) { if (bound === "*" || !bound.startsWith("timestamp=")) {
break; break;
} }
let readReceipt = { time: bound.replace("timestamp=", "") }; let readReceipt = { time: bound.replace("timestamp=", "") };
@@ -1034,9 +1242,13 @@ export default class App extends Component {
notif.close(); notif.close();
} }
} }
let unread;
let closed = true;
this.setBufferState({ server: serverID, name: target }, (buf) => { this.setBufferState({ server: serverID, name: target }, (buf) => {
closed = false;
// Re-compute unread status // Re-compute unread status
let unread = Unread.NONE; unread = Unread.NONE;
for (let i = buf.messages.length - 1; i >= 0; i--) { for (let i = buf.messages.length - 1; i >= 0; i--) {
let msg = buf.messages[i]; let msg = buf.messages[i];
if (msg.command !== "PRIVMSG" && msg.command !== "NOTICE") { if (msg.command !== "PRIVMSG" && msg.command !== "NOTICE") {
@@ -1054,29 +1266,31 @@ export default class App extends Component {
unread = Unread.MESSAGE; unread = Unread.MESSAGE;
} }
return { unread };
}, () => {
this.bufferStore.put({ this.bufferStore.put({
name: target, name: target,
server: client.params, server: client.params,
unread, unread,
closed,
receipts: { [ReceiptType.READ]: readReceipt }, receipts: { [ReceiptType.READ]: readReceipt },
}); });
this.updateDocumentTitle();
return { unread };
}); });
break; break;
default: default:
if (irc.isError(msg.command) && msg.command != irc.ERR_NOMOTD) { if (irc.isError(msg.command) && msg.command !== irc.ERR_NOMOTD) {
let description = msg.params[msg.params.length - 1]; let description = msg.params[msg.params.length - 1];
this.showError(description); this.showError(description);
} }
} }
destBuffers.forEach((bufName) => { destBuffers.forEach((bufName) => {
this.addMessage(serverID, bufName, msg); this.handleChatMessage(serverID, bufName, msg);
}); });
} }
fetchBacklog(serverID) { async fetchBacklog(serverID) {
let client = this.clients.get(serverID); let client = this.clients.get(serverID);
if (!client.caps.enabled.has("draft/chathistory")) { if (!client.caps.enabled.has("draft/chathistory")) {
return; return;
@@ -1091,8 +1305,8 @@ export default class App extends Component {
} }
let now = irc.formatDate(new Date()); let now = irc.formatDate(new Date());
client.fetchHistoryTargets(now, lastReceipt.time).then((targets) => { let targets = await client.fetchHistoryTargets(now, lastReceipt.time);
targets.forEach((target) => { targets.forEach(async (target) => {
let from = lastReceipt; let from = lastReceipt;
let to = { time: now }; let to = { time: now };
@@ -1112,18 +1326,27 @@ export default class App extends Component {
from = receiptFromMessage(lastMsg); from = receiptFromMessage(lastMsg);
} }
client.fetchHistoryBetween(target.name, from, to, CHATHISTORY_MAX_SIZE).then((result) => { // Query read marker if this is a user (ie, we haven't received
// the read marker as part of a JOIN burst)
if (client.supportsReadMarker() && client.isNick(target.name)) {
client.fetchReadMarker(target.name);
}
let result;
try {
result = await client.fetchHistoryBetween(target.name, from, to, CHATHISTORY_MAX_SIZE);
} catch (err) {
console.error("Failed to fetch backlog for '" + target.name + "': ", err);
this.showError("Failed to fetch backlog for '" + target.name + "'");
return;
}
for (let msg of result.messages) { for (let msg of result.messages) {
let destBuffers = this.routeMessage(serverID, msg); let destBuffers = this.routeMessage(serverID, msg);
for (let bufName of destBuffers) { for (let bufName of destBuffers) {
this.addMessage(serverID, target.name, msg); this.handleChatMessage(serverID, bufName, msg);
} }
} }
}).catch((err) => {
console.error("Failed to fetch backlog for '" + target.name + "': ", err);
this.showError("Failed to fetch backlog for '" + target.name + "'");
});
});
}); });
} }
@@ -1223,11 +1446,25 @@ export default class App extends Component {
}); });
client.monitor(target); client.monitor(target);
if (client.caps.enabled.has("soju.im/read")) { if (client.supportsReadMarker()) {
client.send({ client.fetchReadMarker(target);
command: "READ", }
params: [target], }
async whoChannelBuffer(target, serverID) {
let client = this.clients.get(serverID);
// Prevent multiple WHO commands for the same channel in parallel
this.setBufferState({ name: target, server: serverID }, { hasInitialWho: true });
let hasInitialWho = false;
try {
await client.who(target, {
fields: ["flags", "hostname", "nick", "realname", "username", "account"],
}); });
hasInitialWho = true;
} finally {
this.setBufferState({ name: target, server: serverID }, { hasInitialWho });
} }
} }
@@ -1281,6 +1518,7 @@ export default class App extends Component {
}); });
let disconnectAll = client && !client.params.bouncerNetwork && client.caps.enabled.has("soju.im/bouncer-networks"); let disconnectAll = client && !client.params.bouncerNetwork && client.caps.enabled.has("soju.im/bouncer-networks");
let isFirstServer = this.state.servers.keys().next().value === buf.server;
this.disconnect(buf.server); this.disconnect(buf.server);
@@ -1289,7 +1527,7 @@ export default class App extends Component {
servers.delete(buf.server); servers.delete(buf.server);
let connectForm = state.connectForm; let connectForm = state.connectForm;
if (servers.size == 0) { if (servers.size === 0) {
connectForm = true; connectForm = true;
} }
@@ -1306,7 +1544,7 @@ export default class App extends Component {
} }
// TODO: only clear autoconnect if this server is stored there // TODO: only clear autoconnect if this server is stored there
if (buf.server == 1) { if (isFirstServer) {
store.autoconnect.put(null); store.autoconnect.put(null);
} }
break; break;
@@ -1327,7 +1565,11 @@ export default class App extends Component {
client.unmonitor(buf.name); client.unmonitor(buf.name);
this.bufferStore.delete({ name: buf.name, server: client.params }); this.bufferStore.put({
name: buf.name,
server: client.params,
closed: true,
});
break; break;
} }
} }
@@ -1341,7 +1583,7 @@ export default class App extends Component {
let name = parts[0].toLowerCase().slice(1); let name = parts[0].toLowerCase().slice(1);
let args = parts.slice(1); let args = parts.slice(1);
let cmd = commands[name]; let cmd = commands.get(name);
if (!cmd) { if (!cmd) {
this.showError(`Unknown command "${name}" (run "/help" to get a command list)`); this.showError(`Unknown command "${name}" (run "/help" to get a command list)`);
return; return;
@@ -1356,7 +1598,7 @@ export default class App extends Component {
} }
privmsg(target, text) { privmsg(target, text) {
if (target == SERVER_BUFFER) { if (target === SERVER_BUFFER) {
this.showError("Cannot send message in server buffer"); this.showError("Cannot send message in server buffer");
return; return;
} }
@@ -1369,7 +1611,7 @@ export default class App extends Component {
if (!client.caps.enabled.has("echo-message")) { if (!client.caps.enabled.has("echo-message")) {
msg.prefix = { name: client.nick }; msg.prefix = { name: client.nick };
this.addMessage(serverID, target, msg); this.handleChatMessage(serverID, target, msg);
} }
} }
@@ -1473,8 +1715,8 @@ export default class App extends Component {
} }
if (prefix.startsWith("/")) { if (prefix.startsWith("/")) {
let repl = fromList(Object.keys(commands), prefix.slice(1)); let repl = fromList([...commands.keys()], prefix.slice(1));
return repl.map(cmd => "/" + cmd); return repl.map((cmd) => "/" + cmd);
} }
// TODO: consider using the CHANTYPES ISUPPORT token here // TODO: consider using the CHANTYPES ISUPPORT token here
@@ -1499,9 +1741,9 @@ export default class App extends Component {
this.openDialog("help"); this.openDialog("help");
} }
handleBufferScrollTop() { async handleBufferScrollTop() {
let buf = this.state.buffers.get(this.state.activeBuffer); let buf = this.state.buffers.get(this.state.activeBuffer);
if (!buf || buf.type == BufferType.SERVER) { if (!buf || buf.type === BufferType.SERVER) {
return; return;
} }
@@ -1529,14 +1771,36 @@ export default class App extends Component {
limit = 200; limit = 200;
} }
client.fetchHistoryBefore(buf.name, before, limit).then((result) => { let result = await client.fetchHistoryBefore(buf.name, before, limit);
this.endOfHistory.set(buf.id, !result.more); this.endOfHistory.set(buf.id, !result.more);
for (let msg of result.messages) {
this.addMessage(buf.server, buf.name, msg); if (result.messages.length > 0) {
let msg = result.messages[result.messages.length - 1];
let receipts = { [ReceiptType.DELIVERED]: receiptFromMessage(msg) };
if (this.state.activeBuffer === buf.id) {
receipts[ReceiptType.READ] = receiptFromMessage(msg);
} }
let stored = {
name: buf.name,
server: client.params,
receipts,
};
if (this.bufferStore.put(stored)) {
this.sendReadReceipt(client, stored);
}
this.setBufferState(buf, ({ prevReadReceipt }) => {
if (!isMessageBeforeReceipt(msg, prevReadReceipt)) {
prevReadReceipt = receiptFromMessage(msg);
}
return { prevReadReceipt };
}); });
} }
for (let msg of result.messages) {
this.addChatMessage(buf.server, buf.name, msg);
}
}
openDialog(name, data) { openDialog(name, data) {
this.setState({ dialog: name, dialogData: data }); this.setState({ dialog: name, dialogData: data });
} }
@@ -1652,12 +1916,13 @@ export default class App extends Component {
}); });
} }
handleNetworkSubmit(attrs, autojoin) { async handleNetworkSubmit(attrs, autojoin) {
let client = this.clients.values().next().value; let client = this.clients.values().next().value;
if (this.state.dialogData && this.state.dialogData.id) {
if (Object.keys(attrs).length == 0) {
this.dismissDialog(); this.dismissDialog();
if (this.state.dialogData && this.state.dialogData.id) {
if (Object.keys(attrs).length === 0) {
return; return;
} }
@@ -1667,7 +1932,7 @@ export default class App extends Component {
}); });
} else { } else {
attrs = { ...attrs, tls: "1" }; attrs = { ...attrs, tls: "1" };
client.createBouncerNetwork(attrs).then((id) => { let id = await client.createBouncerNetwork(attrs);
if (!autojoin) { if (!autojoin) {
return; return;
} }
@@ -1675,14 +1940,11 @@ export default class App extends Component {
// By this point, bouncer-networks-notify should've advertised // By this point, bouncer-networks-notify should've advertised
// the new network // the new network
let serverID = this.serverFromBouncerNetwork(id); let serverID = this.serverFromBouncerNetwork(id);
let client = this.clients.get(serverID); let newClient = this.clients.get(serverID);
client.params.autojoin = [autojoin]; newClient.params.autojoin = [autojoin];
this.switchToChannel = autojoin; this.switchToChannel = autojoin;
});
} }
this.dismissDialog();
} }
handleNetworkRemove() { handleNetworkRemove() {
@@ -1698,7 +1960,7 @@ export default class App extends Component {
handleOpenSettingsClick() { handleOpenSettingsClick() {
let showProtocolHandler = false; let showProtocolHandler = false;
for (let [id, client] of this.clients) { for (let [_id, client] of this.clients) {
if (client.caps.enabled.has("soju.im/bouncer-networks")) { if (client.caps.enabled.has("soju.im/bouncer-networks")) {
showProtocolHandler = true; showProtocolHandler = true;
break; break;
@@ -1713,18 +1975,56 @@ export default class App extends Component {
this.setState({ settings }); this.setState({ settings });
} }
handleSettingsDisconnect() {
this.dismissDialog();
this.disconnectAll();
}
handleSwitchSubmit(buf) {
this.dismissDialog();
if (buf) {
this.switchBuffer(buf);
}
}
handleWindowFocus() {
if (this.state.activeBuffer) {
// TODO: only do this if scrolled at the bottom
this.markBufferAsRead(this.state.activeBuffer);
}
// When the user focuses gamja, send a PING to make sure we detect any
// network errors ASAP
let now = new Date();
if (this.lastFocusPingDate && now.getTime() - this.lastFocusPingDate.getTime() < 15 * 1000) {
return;
}
this.lastFocusPingDate = now;
for (let client of this.clients.values()) {
client.send({ command: "PING", params: ["gamja"] });
}
}
componentDidMount() { componentDidMount() {
this.baseTitle = document.title; this.baseTitle = document.title;
setupKeybindings(this); setupKeybindings(this);
window.addEventListener("focus", this.handleWindowFocus);
} }
componentWillUnmount() { componentWillUnmount() {
document.title = this.baseTitle; document.title = this.baseTitle;
window.removeEventListener("focus", this.handleWindowFocus);
} }
render() { render() {
if (this.state.loading) { if (this.state.loading) {
return html`<section id="connect"></section>`; let error = null;
if (this.state.error) {
error = html`<form><p class="error-text">${this.state.error}</p></form>`;
}
return html`<section id="connect">${error}</section>`;
} }
let activeBuffer = null, activeServer = null, activeBouncerNetwork = null; let activeBuffer = null, activeServer = null, activeBouncerNetwork = null;
@@ -1738,6 +2038,11 @@ export default class App extends Component {
} }
} }
let activeClient = null;
if (activeBuffer) {
activeClient = this.clients.get(activeBuffer.server);
}
if (this.state.connectForm) { if (this.state.connectForm) {
let status = activeServer ? activeServer.status : ServerStatus.DISCONNECTED; let status = activeServer ? activeServer.status : ServerStatus.DISCONNECTED;
let connecting = status === ServerStatus.CONNECTING || status === ServerStatus.REGISTERING; let connecting = status === ServerStatus.CONNECTING || status === ServerStatus.REGISTERING;
@@ -1757,7 +2062,7 @@ export default class App extends Component {
let bufferHeader = null; let bufferHeader = null;
if (activeBuffer) { if (activeBuffer) {
let activeUser = null; let activeUser = null;
if (activeBuffer.type == BufferType.NICK) { if (activeBuffer.type === BufferType.NICK) {
activeUser = activeServer.users.get(activeBuffer.name); activeUser = activeServer.users.get(activeBuffer.name);
} }
@@ -1781,7 +2086,7 @@ export default class App extends Component {
} }
let memberList = null; let memberList = null;
if (activeBuffer && activeBuffer.type == BufferType.CHANNEL) { if (activeBuffer && activeBuffer.type === BufferType.CHANNEL) {
memberList = html` memberList = html`
<section <section
id="member-list" id="member-list"
@@ -1890,12 +2195,23 @@ export default class App extends Component {
settings=${this.state.settings} settings=${this.state.settings}
showProtocolHandler=${dialogData.showProtocolHandler} showProtocolHandler=${dialogData.showProtocolHandler}
onChange=${this.handleSettingsChange} onChange=${this.handleSettingsChange}
onDisconnect=${() => this.disconnectAll()} onDisconnect=${this.handleSettingsDisconnect}
onClose=${() => this.dismissDialog()} onClose=${this.dismissDialog}
/> />
</> </>
`; `;
break; break;
case "switch":
dialog = html`
<${Dialog} title="Switch to a channel or user" onDismiss=${this.dismissDialog}>
<${SwitcherForm}
buffers=${this.state.buffers}
servers=${this.state.servers}
bouncerNetworks=${this.state.bouncerNetworks}
onSubmit=${this.handleSwitchSubmit}/>
</>
`;
break;
} }
let error = null; let error = null;
@@ -1915,8 +2231,12 @@ export default class App extends Component {
} }
let commandOnly = false; let commandOnly = false;
let privmsgMaxLen;
if (activeBuffer && activeBuffer.type === BufferType.SERVER) { if (activeBuffer && activeBuffer.type === BufferType.SERVER) {
commandOnly = true; commandOnly = true;
} else if (activeBuffer) {
let client = this.clients.get(activeBuffer.server);
privmsgMaxLen = irc.getMaxPrivmsgLen(client.isupport, client.nick, activeBuffer.name);
} }
let app = html` let app = html`
@@ -1951,7 +2271,6 @@ export default class App extends Component {
<${Buffer} <${Buffer}
buffer=${activeBuffer} buffer=${activeBuffer}
server=${activeServer} server=${activeServer}
bouncerNetwork=${activeBouncerNetwork}
settings=${this.state.settings} settings=${this.state.settings}
onChannelClick=${this.handleChannelClick} onChannelClick=${this.handleChannelClick}
onNickClick=${this.handleNickClick} onNickClick=${this.handleNickClick}
@@ -1964,10 +2283,12 @@ export default class App extends Component {
${memberList} ${memberList}
<${Composer} <${Composer}
ref=${this.composer} ref=${this.composer}
client=${activeClient}
readOnly=${composerReadOnly} readOnly=${composerReadOnly}
onSubmit=${this.handleComposerSubmit} onSubmit=${this.handleComposerSubmit}
autocomplete=${this.autocomplete} autocomplete=${this.autocomplete}
commandOnly=${commandOnly} commandOnly=${commandOnly}
maxLen=${privmsgMaxLen}
/> />
${dialog} ${dialog}
${error} ${error}
+4 -4
View File
@@ -9,7 +9,7 @@ export default class NetworkForm extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.handleChange = this.handleChange.bind(this); this.handleInput = this.handleInput.bind(this);
this.handleSubmit = this.handleSubmit.bind(this); this.handleSubmit = this.handleSubmit.bind(this);
if (props.username) { if (props.username) {
@@ -17,9 +17,9 @@ export default class NetworkForm extends Component {
} }
} }
handleChange(event) { handleInput(event) {
let target = event.target; let target = event.target;
let value = target.type == "checkbox" ? target.checked : target.value; let value = target.type === "checkbox" ? target.checked : target.value;
this.setState({ [target.name]: value }); this.setState({ [target.name]: value });
} }
@@ -31,7 +31,7 @@ export default class NetworkForm extends Component {
render() { render() {
return html` return html`
<form onChange=${this.handleChange} onSubmit=${this.handleSubmit}> <form onInput=${this.handleInput} onSubmit=${this.handleSubmit}>
<label> <label>
Username:<br/> Username:<br/>
<input type="username" name="username" value=${this.state.username} required/> <input type="username" name="username" value=${this.state.username} required/>
+2 -3
View File
@@ -1,4 +1,4 @@
import { html, Component } from "../lib/index.js"; import { html } from "../lib/index.js";
import linkify from "../lib/linkify.js"; import linkify from "../lib/linkify.js";
import { strip as stripANSI } from "../lib/ansi.js"; import { strip as stripANSI } from "../lib/ansi.js";
import { BufferType, ServerStatus, getServerName } from "../state.js"; import { BufferType, ServerStatus, getServerName } from "../state.js";
@@ -113,7 +113,6 @@ export default function BufferHeader(props) {
} else { } else {
if (fullyConnected) { if (fullyConnected) {
actions.push(joinButton); actions.push(joinButton);
actions.push(settingsButton);
} else if (props.server.status === ServerStatus.DISCONNECTED) { } else if (props.server.status === ServerStatus.DISCONNECTED) {
actions.push(reconnectButton); actions.push(reconnectButton);
} }
@@ -215,7 +214,7 @@ export default function BufferHeader(props) {
} }
let name = props.buffer.name; let name = props.buffer.name;
if (props.buffer.type == BufferType.SERVER) { if (props.buffer.type === BufferType.SERVER) {
name = getServerName(props.server, props.bouncerNetwork); name = getServerName(props.server, props.bouncerNetwork);
} }
+25 -6
View File
@@ -1,6 +1,7 @@
import * as irc from "../lib/irc.js"; import * as irc from "../lib/irc.js";
import { html, Component } from "../lib/index.js"; import { strip as stripANSI } from "../lib/ansi.js";
import { BufferType, Unread, getBufferURL, getServerName } from "../state.js"; import { html } from "../lib/index.js";
import { BufferType, Unread, ServerStatus, getBufferURL, getServerName } from "../state.js";
function BufferItem(props) { function BufferItem(props) {
function handleClick(event) { function handleClick(event) {
@@ -15,22 +16,41 @@ function BufferItem(props) {
} }
let name = props.buffer.name; let name = props.buffer.name;
if (props.buffer.type == BufferType.SERVER) { if (props.buffer.type === BufferType.SERVER) {
name = getServerName(props.server, props.bouncerNetwork); name = getServerName(props.server, props.bouncerNetwork);
} }
let title;
let classes = ["type-" + props.buffer.type]; let classes = ["type-" + props.buffer.type];
if (props.active) { if (props.active) {
classes.push("active"); classes.push("active");
} }
if (props.buffer.unread != Unread.NONE) { if (props.buffer.unread !== Unread.NONE) {
classes.push("unread-" + props.buffer.unread); classes.push("unread-" + props.buffer.unread);
} }
switch (props.buffer.type) {
case BufferType.SERVER:
let isError = props.server.status === ServerStatus.DISCONNECTED;
if (props.bouncerNetwork && props.bouncerNetwork.error) {
isError = true;
}
if (isError) {
classes.push("error");
}
break;
case BufferType.NICK:
let user = props.server.users.get(name);
if (user && irc.isMeaningfulRealname(user.realname, name)) {
title = stripANSI(user.realname);
}
break;
}
return html` return html`
<li class="${classes.join(" ")}"> <li class="${classes.join(" ")}">
<a <a
href=${getBufferURL(props.buffer)} href=${getBufferURL(props.buffer)}
title=${title}
onClick=${handleClick} onClick=${handleClick}
onMouseDown=${handleMouseDown} onMouseDown=${handleMouseDown}
>${name}</a> >${name}</a>
@@ -38,7 +58,6 @@ function BufferItem(props) {
`; `;
} }
export default function BufferList(props) { export default function BufferList(props) {
let items = Array.from(props.buffers.values()).map((buf) => { let items = Array.from(props.buffers.values()).map((buf) => {
let server = props.servers.get(buf.server); let server = props.servers.get(buf.server);
@@ -56,7 +75,7 @@ export default function BufferList(props) {
bouncerNetwork=${bouncerNetwork} bouncerNetwork=${bouncerNetwork}
onClick=${() => props.onBufferClick(buf)} onClick=${() => props.onBufferClick(buf)}
onClose=${() => props.onBufferClose(buf)} onClose=${() => props.onBufferClose(buf)}
active=${props.activeBuffer == buf.id} active=${props.activeBuffer === buf.id}
/> />
`; `;
}); });
+157 -18
View File
@@ -2,7 +2,7 @@ import { html, Component } from "../lib/index.js";
import linkify from "../lib/linkify.js"; import linkify from "../lib/linkify.js";
import * as irc from "../lib/irc.js"; import * as irc from "../lib/irc.js";
import { strip as stripANSI } from "../lib/ansi.js"; import { strip as stripANSI } from "../lib/ansi.js";
import { BufferType, ServerStatus, BufferEventsDisplayMode, getNickURL, getChannelURL, getMessageURL, isMessageBeforeReceipt, SettingsContext } from "../state.js"; import { BufferType, ServerStatus, BufferEventsDisplayMode, getMessageURL, isMessageBeforeReceipt, SettingsContext } from "../state.js";
import * as store from "../store.js"; import * as store from "../store.js";
import Membership from "./membership.js"; import Membership from "./membership.js";
@@ -21,9 +21,19 @@ function Nick(props) {
props.onClick(); props.onClick();
} }
let title;
if (props.user && irc.isMeaningfulRealname(props.user.realname, props.nick)) {
title = stripANSI(props.user.realname);
}
let colorIndex = djb2(props.nick) % 16 + 1; let colorIndex = djb2(props.nick) % 16 + 1;
return html` return html`
<a href=${getNickURL(props.nick)} class="nick nick-${colorIndex}" onClick=${handleClick}>${props.nick}</a> <a
href=${irc.formatURL({ entity: props.nick })}
title=${title}
class="nick nick-${colorIndex}"
onClick=${handleClick}
>${props.nick}</a>
`; `;
} }
@@ -98,12 +108,16 @@ class LogLine extends Component {
function createNick(nick) { function createNick(nick) {
return html` return html`
<${Nick} nick=${nick} onClick=${() => onNickClick(nick)}/> <${Nick}
nick=${nick}
user=${server.users.get(nick)}
onClick=${() => onNickClick(nick)}
/>
`; `;
} }
function createChannel(channel) { function createChannel(channel) {
return html` return html`
<a href=${getChannelURL(channel)} onClick=${onChannelClick}> <a href=${irc.formatURL({ entity: channel })} onClick=${onChannelClick}>
${channel} ${channel}
</a> </a>
`; `;
@@ -120,7 +134,7 @@ class LogLine extends Component {
let ctcp = irc.parseCTCP(msg); let ctcp = irc.parseCTCP(msg);
if (ctcp) { if (ctcp) {
if (ctcp.command == "ACTION") { if (ctcp.command === "ACTION") {
lineClass = "me-tell"; lineClass = "me-tell";
content = html`* ${createNick(msg.prefix.name)} ${linkify(stripANSI(ctcp.param), onChannelClick)}`; content = html`* ${createNick(msg.prefix.name)} ${linkify(stripANSI(ctcp.param), onChannelClick)}`;
} else { } else {
@@ -131,13 +145,12 @@ class LogLine extends Component {
} else { } else {
lineClass = "talk"; lineClass = "talk";
let prefix = "<", suffix = ">"; let prefix = "<", suffix = ">";
if (msg.command == "NOTICE") { if (msg.command === "NOTICE") {
prefix = suffix = "-"; prefix = suffix = "-";
} }
content = html`${prefix}${createNick(msg.prefix.name)}${suffix} ${linkify(stripANSI(text), onChannelClick)}`; content = html`${prefix}${createNick(msg.prefix.name)}${suffix} ${linkify(stripANSI(text), onChannelClick)}`;
} }
let status = null;
let allowedPrefixes = server.statusMsg; let allowedPrefixes = server.statusMsg;
if (target !== buf.name && allowedPrefixes) { if (target !== buf.name && allowedPrefixes) {
let parts = irc.parseTargetPrefix(target, allowedPrefixes); let parts = irc.parseTargetPrefix(target, allowedPrefixes);
@@ -146,6 +159,10 @@ class LogLine extends Component {
} }
} }
if (msg.tags["+draft/channel-context"]) {
content = html`<em>(only visible to you)</em> ${content}`;
}
if (msg.isHighlight) { if (msg.isHighlight) {
lineClass += " highlight"; lineClass += " highlight";
} }
@@ -178,11 +195,80 @@ class LogLine extends Component {
break; break;
case "MODE": case "MODE":
target = msg.params[0]; target = msg.params[0];
let modeStr = msg.params[1];
let user = html`${createNick(msg.prefix.name)}`;
// TODO: use irc.forEachChannelModeUpdate()
if (buf.type === BufferType.CHANNEL && modeStr.length === 2 && server.cm(buf.name) === server.cm(target)) {
let plusMinus = modeStr[0];
let mode = modeStr[1];
let arg = msg.params[2];
let verb;
switch (mode) {
case "b":
verb = plusMinus === "+" ? "added" : "removed";
content = html`${user} has ${verb} a ban on ${arg}`;
break;
case "e":
verb = plusMinus === "+" ? "added" : "removed";
content = html`${user} has ${verb} a ban exemption on ${arg}`;
break;
case "l":
if (plusMinus === "+") {
content = html`${user} has set the channel user limit to ${arg}`;
} else {
content = html`${user} has unset the channel user limit`;
}
break;
case "i":
verb = plusMinus === "+" ? "marked": "unmarked";
content = html`${user} has ${verb} as invite-only`;
break;
case "m":
verb = plusMinus === "+" ? "marked": "unmarked";
content = html`${user} has ${verb} as moderated`;
break;
case "s":
verb = plusMinus === "+" ? "marked": "unmarked";
content = html`${user} has ${verb} as secret`;
break;
case "t":
verb = plusMinus === "+" ? "locked": "unlocked";
content = html`${user} has ${verb} the channel topic`;
break;
case "n":
verb = plusMinus === "+" ? "allowed": "denied";
content = html`${user} has ${verb} external messages to this channel`;
break;
}
if (content) {
break;
}
// Channel membership modes
let membership;
for (let prefix in irc.STD_MEMBERSHIP_MODES) {
if (irc.STD_MEMBERSHIP_MODES[prefix] === mode) {
membership = irc.STD_MEMBERSHIP_NAMES[prefix];
break;
}
}
if (membership && arg) {
let verb = plusMinus === "+" ? "granted" : "revoked";
let preposition = plusMinus === "+" ? "to" : "from";
content = html` content = html`
* ${createNick(msg.prefix.name)} sets mode ${msg.params.slice(1).join(" ")} ${user} has ${verb} ${membership} privileges ${preposition} ${createNick(arg)}
`; `;
// TODO: case-mapping break;
if (buf.name !== target) { }
}
content = html`
${user} sets mode ${msg.params.slice(1).join(" ")}
`;
if (server.cm(buf.name) !== server.cm(target)) {
content = html`${content} on ${target}`; content = html`${content} on ${target}`;
} }
break; break;
@@ -263,8 +349,15 @@ class LogLine extends Component {
let date = new Date(parseInt(msg.params[2], 10) * 1000); let date = new Date(parseInt(msg.params[2], 10) * 1000);
content = html`Channel was created on ${date.toLocaleString()}`; content = html`Channel was created on ${date.toLocaleString()}`;
break; break;
// MONITOR messages are only displayed in user buffers
case irc.RPL_MONONLINE:
content = html`${createNick(buf.name)} is online`;
break;
case irc.RPL_MONOFFLINE:
content = html`${createNick(buf.name)} is offline`;
break;
default: default:
if (irc.isError(msg.command) && msg.command != irc.ERR_NOMOTD) { if (irc.isError(msg.command) && msg.command !== irc.ERR_NOMOTD) {
lineClass = "error"; lineClass = "error";
} }
content = html`${msg.command} ${linkify(msg.params.join(" "))}`; content = html`${msg.command} ${linkify(msg.params.join(" "))}`;
@@ -314,11 +407,16 @@ class FoldGroup extends Component {
render() { render() {
let msgs = this.props.messages; let msgs = this.props.messages;
let buf = this.props.buffer; let buf = this.props.buffer;
let server = this.props.server;
let onNickClick = this.props.onNickClick; let onNickClick = this.props.onNickClick;
function createNick(nick) { function createNick(nick) {
return html` return html`
<${Nick} nick=${nick} onClick=${() => onNickClick(nick)}/> <${Nick}
nick=${nick}
user=${server.users.get(nick)}
onClick=${() => onNickClick(nick)}
/>
`; `;
} }
@@ -574,18 +672,17 @@ export default class Buffer extends Component {
} }
let server = this.props.server; let server = this.props.server;
let bouncerNetwork = this.props.bouncerNetwork;
let settings = this.props.settings; let settings = this.props.settings;
let serverName = server.name; let serverName = server.name;
let children = []; let children = [];
if (buf.type == BufferType.SERVER) { if (buf.type === BufferType.SERVER) {
children.push(html`<${NotificationNagger}/>`); children.push(html`<${NotificationNagger}/>`);
} }
if (buf.type == BufferType.SERVER && server.isBouncer && !server.bouncerNetID) { if (buf.type === BufferType.SERVER && server.isBouncer && !server.bouncerNetID) {
children.push(html`<${ProtocolHandlerNagger} bouncerName=${serverName}/>`); children.push(html`<${ProtocolHandlerNagger} bouncerName=${serverName}/>`);
} }
if (buf.type == BufferType.SERVER && server.status == ServerStatus.REGISTERED && server.supportsSASLPlain && !server.account) { if (buf.type === BufferType.SERVER && server.status === ServerStatus.REGISTERED && server.supportsSASLPlain && !server.account) {
children.push(html` children.push(html`
<${AccountNagger} <${AccountNagger}
server=${server} server=${server}
@@ -613,7 +710,38 @@ export default class Buffer extends Component {
`; `;
} }
function createFoldGroup(msgs) { function createFoldGroup(msgs) {
// Filter out PART → JOIN pairs // Merge NICK change chains
let nickChanges = new Map();
let mergedMsgs = [];
for (let msg of msgs) {
let keep = true;
switch (msg.command) {
case "PART":
case "QUIT":
nickChanges.delete(msg.prefix.name);
break;
case "NICK":
let prev = nickChanges.get(msg.prefix.name);
if (!prev) {
// Future NICK messages may mutate this one
msg = { ...msg };
nickChanges.set(msg.params[0], msg);
break;
}
prev.params = msg.params;
nickChanges.delete(msg.prefix.name);
nickChanges.set(msg.params[0], prev);
keep = false;
break;
}
if (keep) {
mergedMsgs.push(msg);
}
}
msgs = mergedMsgs;
// Filter out PART → JOIN pairs, as well as no-op NICKs from previous step
let partIndexes = new Map(); let partIndexes = new Map();
let keep = []; let keep = [];
msgs.forEach((msg, i) => { msgs.forEach((msg, i) => {
@@ -624,6 +752,8 @@ export default class Buffer extends Component {
keep[partIndexes.get(msg.prefix.name)] = false; keep[partIndexes.get(msg.prefix.name)] = false;
partIndexes.delete(msg.prefix.name); partIndexes.delete(msg.prefix.name);
keep.push(false); keep.push(false);
} else if (msg.command === "NICK" && msg.prefix.name === msg.params[0]) {
keep.push(false);
} else { } else {
keep.push(true); keep.push(true);
} }
@@ -649,6 +779,7 @@ export default class Buffer extends Component {
let hasUnreadSeparator = false; let hasUnreadSeparator = false;
let prevDate = new Date(); let prevDate = new Date();
let foldMessages = []; let foldMessages = [];
let lastMonitor = null;
buf.messages.forEach((msg) => { buf.messages.forEach((msg) => {
let sep = []; let sep = [];
@@ -656,7 +787,15 @@ export default class Buffer extends Component {
return; return;
} }
if (!hasUnreadSeparator && buf.type != BufferType.SERVER && !isMessageBeforeReceipt(msg, buf.prevReadReceipt)) { if (msg.command === irc.RPL_MONONLINE || msg.command === irc.RPL_MONOFFLINE) {
let skip = !lastMonitor || msg.command === lastMonitor;
lastMonitor = msg.command;
if (skip) {
return;
}
}
if (!hasUnreadSeparator && buf.type !== BufferType.SERVER && !isMessageBeforeReceipt(msg, buf.prevReadReceipt)) {
sep.push(html`<${UnreadSeparator} key="unread"/>`); sep.push(html`<${UnreadSeparator} key="unread"/>`);
hasUnreadSeparator = true; hasUnreadSeparator = true;
} }
+132 -1
View File
@@ -1,5 +1,16 @@
import { html, Component, createRef } from "../lib/index.js"; import { html, Component, createRef } from "../lib/index.js";
function encodeContentDisposition(filename) {
// Encode filename according to RFC 5987 if necessary. Note,
// encodeURIComponent will percent-encode a superset of attr-char.
let encodedFilename = encodeURIComponent(filename);
if (encodedFilename === filename) {
return "attachment; filename=\"" + filename + "\"";
} else {
return "attachment; filename*=UTF-8''" + encodedFilename;
}
}
export default class Composer extends Component { export default class Composer extends Component {
state = { state = {
text: "", text: "",
@@ -13,6 +24,9 @@ export default class Composer extends Component {
this.handleInput = this.handleInput.bind(this); this.handleInput = this.handleInput.bind(this);
this.handleSubmit = this.handleSubmit.bind(this); this.handleSubmit = this.handleSubmit.bind(this);
this.handleInputKeyDown = this.handleInputKeyDown.bind(this); this.handleInputKeyDown = this.handleInputKeyDown.bind(this);
this.handleInputPaste = this.handleInputPaste.bind(this);
this.handleDragOver = this.handleDragOver.bind(this);
this.handleDrop = this.handleDrop.bind(this);
this.handleWindowKeyDown = this.handleWindowKeyDown.bind(this); this.handleWindowKeyDown = this.handleWindowKeyDown.bind(this);
this.handleWindowPaste = this.handleWindowPaste.bind(this); this.handleWindowPaste = this.handleWindowPaste.bind(this);
} }
@@ -116,11 +130,119 @@ export default class Composer extends Component {
this.setState({ text: autocomplete.text }); this.setState({ text: autocomplete.text });
} }
canUploadFiles() {
let client = this.props.client;
return client && client.isupport.filehost() && !this.props.readOnly;
}
async uploadFile(file) {
let client = this.props.client;
let endpoint = client.isupport.filehost();
let auth;
if (client.params.saslPlain) {
let params = client.params.saslPlain;
auth = "Basic " + btoa(params.username + ":" + params.password);
} else if (client.params.saslOauthBearer) {
auth = "Bearer " + client.params.saslOauthBearer.token;
}
let headers = {
"Content-Length": file.size,
"Content-Disposition": encodeContentDisposition(file.name),
};
if (file.type) {
headers["Content-Type"] = file.type;
}
if (auth) {
headers["Authorization"] = auth;
}
// TODO: show a loading UI while uploading
// TODO: show a cancel button
let resp = await fetch(endpoint, {
method: "POST",
body: file,
headers,
credentials: "include",
});
if (!resp.ok) {
throw new Error(`HTTP request failed (${resp.status})`);
}
let loc = resp.headers.get("Location");
if (!loc) {
throw new Error("filehost response missing Location header field");
}
return new URL(loc, endpoint);
}
async uploadFileList(fileList) {
let promises = [];
for (let file of fileList) {
promises.push(this.uploadFile(file));
}
let urls = await Promise.all(promises);
this.setState((state) => {
if (state.text) {
return { text: state.text + " " + urls.join(" ") };
} else {
return { text: urls.join(" ") };
}
});
}
async handleInputPaste(event) {
if (event.clipboardData.files.length === 0 || !this.canUploadFiles()) {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
await this.uploadFileList(event.clipboardData.files);
}
handleDragOver(event) {
if (event.dataTransfer.items.length === 0 || !this.canUploadFiles()) {
return;
}
for (let item of event.dataTransfer.items) {
if (item.kind !== "file") {
return;
}
}
event.preventDefault();
}
async handleDrop(event) {
if (event.dataTransfer.files.length === 0 || !this.canUploadFiles()) {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
await this.uploadFileList(event.dataTransfer.files);
}
handleWindowKeyDown(event) { handleWindowKeyDown(event) {
// If an <input> or <button> is focused, ignore. // If an <input> or <button> is focused, ignore.
if (document.activeElement !== document.body && document.activeElement.tagName !== "SECTION") { if (document.activeElement && document.activeElement !== document.body) {
switch (document.activeElement.tagName.toLowerCase()) {
case "section":
case "a":
break;
default:
return; return;
} }
}
// If a modifier is pressed, reserve for key bindings. // If a modifier is pressed, reserve for key bindings.
if (event.altKey || event.ctrlKey || event.metaKey) { if (event.altKey || event.ctrlKey || event.metaKey) {
@@ -167,6 +289,11 @@ export default class Composer extends Component {
return; return;
} }
if (event.clipboardData.files.length > 0) {
this.handleInputPaste(event);
return;
}
let text = event.clipboardData.getData("text"); let text = event.clipboardData.getData("text");
event.preventDefault(); event.preventDefault();
@@ -222,6 +349,10 @@ export default class Composer extends Component {
placeholder=${placeholder} placeholder=${placeholder}
enterkeyhint="send" enterkeyhint="send"
onKeyDown=${this.handleInputKeyDown} onKeyDown=${this.handleInputKeyDown}
onPaste=${this.handleInputPaste}
onDragOver=${this.handleDragOver}
onDrop=${this.handleDrop}
maxlength=${this.props.maxLen}
/> />
</form> </form>
`; `;
+8 -5
View File
@@ -17,7 +17,7 @@ export default class ConnectForm extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.handleChange = this.handleChange.bind(this); this.handleInput = this.handleInput.bind(this);
this.handleSubmit = this.handleSubmit.bind(this); this.handleSubmit = this.handleSubmit.bind(this);
if (props.params) { if (props.params) {
@@ -32,9 +32,9 @@ export default class ConnectForm extends Component {
} }
} }
handleChange(event) { handleInput(event) {
let target = event.target; let target = event.target;
let value = target.type == "checkbox" ? target.checked : target.value; let value = target.type === "checkbox" ? target.checked : target.value;
this.setState({ [target.name]: value }); this.setState({ [target.name]: value });
} }
@@ -63,6 +63,8 @@ export default class ConnectForm extends Component {
}; };
} else if (this.props.auth === "external") { } else if (this.props.auth === "external") {
params.saslExternal = true; params.saslExternal = true;
} else if (this.props.auth === "oauth2") {
params.saslOauthBearer = this.props.params.saslOauthBearer;
} }
if (this.state.autojoin) { if (this.state.autojoin) {
@@ -110,7 +112,7 @@ export default class ConnectForm extends Component {
} }
let auth = null; let auth = null;
if (this.props.auth !== "disabled" && this.props.auth !== "external") { if (this.props.auth !== "disabled" && this.props.auth !== "external" && this.props.auth !== "oauth2") {
auth = html` auth = html`
<label> <label>
Password:<br/> Password:<br/>
@@ -145,7 +147,7 @@ export default class ConnectForm extends Component {
} }
return html` return html`
<form onChange=${this.handleChange} onSubmit=${this.handleSubmit}> <form onInput=${this.handleInput} onSubmit=${this.handleSubmit}>
<h2>Connect to IRC</h2> <h2>Connect to IRC</h2>
<label> <label>
@@ -157,6 +159,7 @@ export default class ConnectForm extends Component {
disabled=${disabled} disabled=${disabled}
ref=${this.nickInput} ref=${this.nickInput}
required required
autofocus
/> />
</label> </label>
<br/><br/> <br/><br/>
+2 -2
View File
@@ -21,13 +21,13 @@ export default class Dialog extends Component {
} }
handleBackdropClick(event) { handleBackdropClick(event) {
if (event.target.className == "dialog") { if (event.target.className === "dialog") {
this.dismiss(); this.dismiss();
} }
} }
handleKeyDown(event) { handleKeyDown(event) {
if (event.key == "Escape") { if (event.key === "Escape") {
this.dismiss(); this.dismiss();
} }
} }
+7 -2
View File
@@ -1,4 +1,4 @@
import { html, Component } from "../lib/index.js"; import { html } from "../lib/index.js";
import { keybindings } from "../keybindings.js"; import { keybindings } from "../keybindings.js";
import commands from "../commands.js"; import commands from "../commands.js";
@@ -6,7 +6,7 @@ function KeyBindingsHelp() {
let l = keybindings.map((binding) => { let l = keybindings.map((binding) => {
let keys = []; let keys = [];
if (binding.ctrlKey) { if (binding.ctrlKey) {
keys.psuh("Ctrl"); keys.push("Ctrl");
} }
if (binding.altKey) { if (binding.altKey) {
keys.push("Alt"); keys.push("Alt");
@@ -26,6 +26,11 @@ function KeyBindingsHelp() {
`; `;
}); });
l.push(html`
<dt><kbd>Tab</kbd></dt>
<dd>Automatically complete nickname or channel</dd>
`);
if (!window.matchMedia("(pointer: none)").matches) { if (!window.matchMedia("(pointer: none)").matches) {
l.push(html` l.push(html`
<dt><strong>Middle mouse click</strong></dt> <dt><strong>Middle mouse click</strong></dt>
+4 -4
View File
@@ -8,7 +8,7 @@ export default class JoinForm extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.handleChange = this.handleChange.bind(this); this.handleInput = this.handleInput.bind(this);
this.handleSubmit = this.handleSubmit.bind(this); this.handleSubmit = this.handleSubmit.bind(this);
if (props.channel) { if (props.channel) {
@@ -16,9 +16,9 @@ export default class JoinForm extends Component {
} }
} }
handleChange(event) { handleInput(event) {
let target = event.target; let target = event.target;
let value = target.type == "checkbox" ? target.checked : target.value; let value = target.type === "checkbox" ? target.checked : target.value;
this.setState({ [target.name]: value }); this.setState({ [target.name]: value });
} }
@@ -34,7 +34,7 @@ export default class JoinForm extends Component {
render() { render() {
return html` return html`
<form onChange=${this.handleChange} onSubmit=${this.handleSubmit}> <form onInput=${this.handleInput} onSubmit=${this.handleSubmit}>
<label> <label>
Channel:<br/> Channel:<br/>
<input type="text" name="channel" value=${this.state.channel} autofocus required/> <input type="text" name="channel" value=${this.state.channel} autofocus required/>
+3 -23
View File
@@ -1,5 +1,4 @@
import { html, Component } from "../lib/index.js"; import { html, Component } from "../lib/index.js";
import { getNickURL } from "../state.js";
import { strip as stripANSI } from "../lib/ansi.js"; import { strip as stripANSI } from "../lib/ansi.js";
import Membership from "./membership.js"; import Membership from "./membership.js";
import * as irc from "../lib/irc.js"; import * as irc from "../lib/irc.js";
@@ -23,26 +22,7 @@ class MemberItem extends Component {
} }
render() { render() {
// XXX: If we were feeling creative we could generate unique colors for let title;
// each item in ISUPPORT CHANMODES. But I am not feeling creative.
const membmap = {
"~": "owner",
"&": "admin",
"@": "op",
"%": "halfop",
"+": "voice",
};
const membclass = membmap[this.props.membership[0]] || "";
let membership = "";
if (this.props.membership) {
membership = html`
<span class="membership ${membclass}" title=${membclass}>
${this.props.membership}
</span>
`;
};
let title = null;
let user = this.props.user; let user = this.props.user;
let classes = ["nick"]; let classes = ["nick"];
if (user) { if (user) {
@@ -73,7 +53,7 @@ class MemberItem extends Component {
return html` return html`
<li> <li>
<a <a
href=${getNickURL(this.props.nick)} href=${irc.formatURL({ entity: this.props.nick, enttype: "user" })}
class=${classes.join(" ")} class=${classes.join(" ")}
title=${title} title=${title}
onClick=${this.handleClick} onClick=${this.handleClick}
@@ -101,7 +81,7 @@ function sortMembers(a, b) {
return i - j; return i - j;
} }
return nickA < nickB ? -1 : 1; return nickA.localeCompare(nickB);
} }
export default class MemberList extends Component { export default class MemberList extends Component {
+5 -12
View File
@@ -1,21 +1,14 @@
import { html, Component } from "../lib/index.js"; import { html } from "../lib/index.js";
import * as irc from "../lib/irc.js";
// XXX: If we were feeling creative we could generate unique colors for
// each item in ISUPPORT CHANMODES. But I am not feeling creative.
const names = {
"~": "owner",
"&": "admin",
"@": "op",
"%": "halfop",
"+": "voice",
};
export default function Membership(props) { export default function Membership(props) {
if (!this.props.value) { if (!this.props.value) {
return null; return null;
} }
const name = names[this.props.value[0]] || ""; // XXX: If we were feeling creative we could generate unique colors for
// each item in ISUPPORT CHANMODES. But I am not feeling creative.
const name = irc.STD_MEMBERSHIP_NAMES[this.props.value[0]] || "";
return html` return html`
<span class="membership ${name}" title=${name}> <span class="membership ${name}" title=${name}>
${this.props.value} ${this.props.value}
+6 -6
View File
@@ -22,7 +22,7 @@ export default class NetworkForm extends Component {
this.prevParams = { ...defaultParams }; this.prevParams = { ...defaultParams };
this.handleChange = this.handleChange.bind(this); this.handleInput = this.handleInput.bind(this);
this.handleSubmit = this.handleSubmit.bind(this); this.handleSubmit = this.handleSubmit.bind(this);
if (props.params) { if (props.params) {
@@ -35,9 +35,9 @@ export default class NetworkForm extends Component {
} }
} }
handleChange(event) { handleInput(event) {
let target = event.target; let target = event.target;
let value = target.type == "checkbox" ? target.checked : target.value; let value = target.type === "checkbox" ? target.checked : target.value;
this.setState({ [target.name]: value }); this.setState({ [target.name]: value });
} }
@@ -46,10 +46,10 @@ export default class NetworkForm extends Component {
let params = {}; let params = {};
Object.keys(defaultParams).forEach((k) => { Object.keys(defaultParams).forEach((k) => {
if (!this.props.isNew && this.prevParams[k] == this.state[k]) { if (!this.props.isNew && this.prevParams[k] === this.state[k]) {
return; return;
} }
if (this.props.isNew && defaultParams[k] == this.state[k]) { if (this.props.isNew && defaultParams[k] === this.state[k]) {
return; return;
} }
params[k] = this.state[k]; params[k] = this.state[k];
@@ -85,7 +85,7 @@ export default class NetworkForm extends Component {
} }
return html` return html`
<form onChange=${this.handleChange} onSubmit=${this.handleSubmit}> <form onInput=${this.handleInput} onSubmit=${this.handleSubmit}>
<label> <label>
Hostname:<br/> Hostname:<br/>
<input type="text" name="host" value=${this.state.host} autofocus required/> <input type="text" name="host" value=${this.state.host} autofocus required/>
+4 -4
View File
@@ -9,13 +9,13 @@ export default class RegisterForm extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.handleChange = this.handleChange.bind(this); this.handleInput = this.handleInput.bind(this);
this.handleSubmit = this.handleSubmit.bind(this); this.handleSubmit = this.handleSubmit.bind(this);
} }
handleChange(event) { handleInput(event) {
let target = event.target; let target = event.target;
let value = target.type == "checkbox" ? target.checked : target.value; let value = target.type === "checkbox" ? target.checked : target.value;
this.setState({ [target.name]: value }); this.setState({ [target.name]: value });
} }
@@ -27,7 +27,7 @@ export default class RegisterForm extends Component {
render() { render() {
return html` return html`
<form onChange=${this.handleChange} onSubmit=${this.handleSubmit}> <form onInput=${this.handleInput} onSubmit=${this.handleSubmit}>
<label> <label>
E-mail:<br/> E-mail:<br/>
<input <input
+10 -10
View File
@@ -1,4 +1,4 @@
import { html, Component } from "../lib/index.js"; import { Component } from "../lib/index.js";
let store = new Map(); let store = new Map();
@@ -11,10 +11,10 @@ export default class ScrollManager extends Component {
isAtBottom() { isAtBottom() {
let target = this.props.target.current; let target = this.props.target.current;
return target.scrollTop >= target.scrollHeight - target.offsetHeight; return Math.abs(target.scrollHeight - target.clientHeight - target.scrollTop) <= 10;
} }
saveScrollPosition() { saveScrollPosition(scrollKey) {
let target = this.props.target.current; let target = this.props.target.current;
let sticky = target.querySelectorAll(this.props.stickTo); let sticky = target.querySelectorAll(this.props.stickTo);
@@ -29,7 +29,7 @@ export default class ScrollManager extends Component {
} }
} }
store.set(this.props.scrollKey, stickToKey); store.set(scrollKey, stickToKey);
} }
restoreScrollPosition() { restoreScrollPosition() {
@@ -48,13 +48,13 @@ export default class ScrollManager extends Component {
} }
} }
if (target.scrollTop == 0) { if (target.scrollTop === 0) {
this.props.onScrollTop(); this.props.onScrollTop();
} }
} }
handleScroll() { handleScroll() {
if (this.props.target.current.scrollTop == 0) { if (this.props.target.current.scrollTop === 0) {
this.props.onScrollTop(); this.props.onScrollTop();
} }
} }
@@ -64,9 +64,9 @@ export default class ScrollManager extends Component {
this.props.target.current.addEventListener("scroll", this.handleScroll); this.props.target.current.addEventListener("scroll", this.handleScroll);
} }
componentWillReceiveProps(nextProps) { getSnapshotBeforeUpdate(prevProps) {
if (this.props.scrollKey !== nextProps.scrollKey || this.props.children !== nextProps.children) { if (this.props.scrollKey !== prevProps.scrollKey || this.props.children !== prevProps.children) {
this.saveScrollPosition(); this.saveScrollPosition(prevProps.scrollKey);
} }
} }
@@ -79,7 +79,7 @@ export default class ScrollManager extends Component {
componentWillUnmount() { componentWillUnmount() {
this.props.target.current.removeEventListener("scroll", this.handleScroll); this.props.target.current.removeEventListener("scroll", this.handleScroll);
this.saveScrollPosition(); this.saveScrollPosition(this.props.scrollKey);
} }
render() { render() {
+4 -4
View File
@@ -9,13 +9,13 @@ export default class SettingsForm extends Component {
this.state.secondsInTimestamps = props.settings.secondsInTimestamps; this.state.secondsInTimestamps = props.settings.secondsInTimestamps;
this.state.bufferEvents = props.settings.bufferEvents; this.state.bufferEvents = props.settings.bufferEvents;
this.handleChange = this.handleChange.bind(this); this.handleInput = this.handleInput.bind(this);
this.handleSubmit = this.handleSubmit.bind(this); this.handleSubmit = this.handleSubmit.bind(this);
} }
handleChange(event) { handleInput(event) {
let target = event.target; let target = event.target;
let value = target.type == "checkbox" ? target.checked : target.value; let value = target.type === "checkbox" ? target.checked : target.value;
this.setState({ [target.name]: value }, () => { this.setState({ [target.name]: value }, () => {
this.props.onChange(this.state); this.props.onChange(this.state);
}); });
@@ -56,7 +56,7 @@ export default class SettingsForm extends Component {
} }
return html` return html`
<form onChange=${this.handleChange} onSubmit=${this.handleSubmit}> <form onInput=${this.handleInput} onSubmit=${this.handleSubmit}>
<label> <label>
<input <input
type="checkbox" type="checkbox"
+171
View File
@@ -0,0 +1,171 @@
import { html, Component } from "../lib/index.js";
import { BufferType, getBufferURL, getServerName } from "../state.js";
import * as irc from "../lib/irc.js";
class SwitcherItem extends Component {
constructor(props) {
super(props);
this.handleClick = this.handleClick.bind(this);
}
handleClick(event) {
event.preventDefault();
this.props.onClick();
}
render() {
let class_ = this.props.selected ? "selected" : "";
return html`
<li>
<a
href=${getBufferURL(this.props.buffer)}
class=${class_}
onClick=${this.handleClick}
>
<span class="server">
${getServerName(this.props.server, this.props.bouncerNetwork)}
</span>
${this.props.buffer.name}
</a>
</li>
`;
}
}
function matchString(s, query) {
return s.toLowerCase().includes(query) ? 1 : 0;
}
function matchBuffer(buf, server, query) {
let score = 2 * matchString(buf.name, query);
switch (buf.type) {
case BufferType.CHANNEL:
score += matchString(buf.topic || "", query);
break;
case BufferType.NICK:
let user = server.users.get(buf.name);
if (user && user.realname && irc.isMeaningfulRealname(user.realname, buf.name)) {
score += matchString(user.realname, query);
}
break;
}
return score;
}
export default class SwitcherForm extends Component {
state = {
query: "",
selected: 0,
};
constructor(props) {
super(props);
this.handleInput = this.handleInput.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleKeyUp = this.handleKeyUp.bind(this);
}
getSuggestions() {
let query = this.state.query.toLowerCase();
let l = [];
let scores = new Map();
for (let buf of this.props.buffers.values()) {
if (buf.type === BufferType.SERVER) {
continue;
}
let score = 0;
if (query !== "") {
let server = this.props.servers.get(buf.server);
score = matchBuffer(buf, server, query);
if (!score) {
continue;
}
}
scores.set(buf.id, score);
l.push(buf);
}
l.sort((a, b) => {
return scores.get(b.id) - scores.get(a.id);
});
return l.slice(0, 20);
}
handleInput(event) {
let target = event.target;
this.setState({ [target.name]: target.value });
}
handleSubmit(event) {
event.preventDefault();
this.props.onSubmit(this.getSuggestions()[this.state.selected]);
}
handleKeyUp(event) {
switch (event.key) {
case "ArrowUp":
event.stopPropagation();
this.move(-1);
break;
case "ArrowDown":
event.stopPropagation();
this.move(1);
break;
}
}
move(delta) {
let numSuggestions = this.getSuggestions().length;
this.setState((state) => {
return {
selected: (state.selected + delta + numSuggestions) % numSuggestions,
};
});
}
render() {
let items = this.getSuggestions().map((buf, i) => {
let server = this.props.servers.get(buf.server);
let bouncerNetwork = null;
if (server.bouncerNetID) {
bouncerNetwork = this.props.bouncerNetworks.get(server.bouncerNetID);
}
return html`
<${SwitcherItem}
buffer=${buf}
server=${server}
bouncerNetwork=${bouncerNetwork}
selected=${this.state.selected === i}
onClick=${() => this.props.onSubmit(buf)}
/>
`;
});
return html`
<form
onInput=${this.handleInput}
onSubmit=${this.handleSubmit}
onKeyUp=${this.handleKeyUp}
>
<input
type="search"
name="query"
value=${this.state.query}
placeholder="Filter"
autocomplete="off"
autofocus
/>
<ul class="switcher-list">
${items}
</ul>
</form>
`;
}
}
+4 -4
View File
@@ -9,13 +9,13 @@ export default class RegisterForm extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.handleChange = this.handleChange.bind(this); this.handleInput = this.handleInput.bind(this);
this.handleSubmit = this.handleSubmit.bind(this); this.handleSubmit = this.handleSubmit.bind(this);
} }
handleChange(event) { handleInput(event) {
let target = event.target; let target = event.target;
let value = target.type == "checkbox" ? target.checked : target.value; let value = target.type === "checkbox" ? target.checked : target.value;
this.setState({ [target.name]: value }); this.setState({ [target.name]: value });
} }
@@ -27,7 +27,7 @@ export default class RegisterForm extends Component {
render() { render() {
return html` return html`
<form onChange=${this.handleChange} onSubmit=${this.handleSubmit}> <form onInput=${this.handleInput} onSubmit=${this.handleSubmit}>
<p>Your account <strong>${this.props.account}</strong> has been created, but a verification code is required to complete the registration.</p> <p>Your account <strong>${this.props.account}</strong> has been created, but a verification code is required to complete the registration.</p>
<p>${linkify(this.props.message)}</p> <p>${linkify(this.props.message)}</p>
+50
View File
@@ -0,0 +1,50 @@
# Configuration file
gamja can be configured using a `config.json` file at the root. Example:
```json
{
"server": {
"url": "wss://irc.example.org",
"autojoin": "#gamja"
},
"oauth2": {
"url": "https://auth.example.org",
"client_id": "asdf"
}
}
```
Errors while parsing the configuration file are logged in the
[browser's web console].
## IRC server
The `server` object configures the IRC server.
- `url` (string): WebSocket URL or path to connect to. Defaults to `/socket`.
- `autojoin` (string or array of strings): Channel(s) to automatically join
after connecting.
- `auth` (string): configure how the password UI is presented to the user. Set
to `mandatory` to require a password, `optional` to accept one but not
require it, `disabled` to never ask for a password, `external` to use SASL
EXTERNAL, `oauth2` to use SASL OAUTHBEARER. Defaults to `optional`.
- `nick` (string): default nickname. If it contains a `*` character, it will be
replaced with a random string.
- `autoconnect` (boolean): don't display the login UI, immediately connect to
the server
- `ping` (number): interval in seconds to send PING commands. Set to 0 to
disable, this is the default. Enabling PINGs can have an impact on client
power usage and should only be enabled if necessary.
## OAuth 2.0
The `oauth2` object configures OAuth 2.0 authentication.
- `url` (string): OAuth 2.0 server URL. The server must support OAuth 2.0
Authorization Server Metadata (RFC 8414) or OpenID Connect Discovery.
- `client_id` (string): OAuth 2.0 client ID.
- `client_secret` (string): OAuth 2.0 client secret.
- `scope` (string): OAuth 2.0 scope.
[browser's web console]: https://firefox-source-docs.mozilla.org/devtools-user/web_console/index.html
+68
View File
@@ -0,0 +1,68 @@
# Setting up gamja
An HTTP server must be configured to serve the gamja static files. Usually,
the same HTTP server is used as a reverse proxy for the IRC WebSocket.
## [soju]
Add a WebSocket listener to soju, e.g. `listen ws+insecure://127.0.0.1:8080`.
Then configure your reverse proxy to serve gamja files and proxy `/socket` to
soju.
## [webircgateway]
Setup webircgateway to serve gamja files:
```ini
[fileserving]
enabled = true
webroot = /path/to/gamja
```
Then configure gamja to connect to `/webirc/websocket/` (either by setting
`server.url` in the [configuration file], or by appending
`?server=/webirc/websocket/` to the URL).
## nginx
If you use nginx as a reverse HTTP proxy, make sure to bump the default read
timeout to a value higher than the IRC server PING interval. Example:
```
location / {
root /path/to/gamja;
}
location /socket {
proxy_pass http://127.0.0.1:8080;
proxy_read_timeout 600s;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
If you are unable to configure the proxy timeout accordingly, or if your IRC
server doesn't send PINGs, you can set the `server.ping` option in
`config.json` (see below).
## [kimchi]
Setup kimchi to serve gamja files and proxy the WebSocket connection:
```
site irc.example.org {
file_server /path/to/gamja
}
site irc.example.org/socket {
reverse_proxy http://127.0.0.1:8080
}
```
[soju]: https://soju.im
[webircgateway]: https://github.com/kiwiirc/webircgateway
[kimchi]: https://sr.ht/~emersion/kimchi/
[configuration file]: config-file.md
+15
View File
@@ -0,0 +1,15 @@
# URL parameters
gamja settings can be overridden using URL query parameters:
- `server`: path or URL to the WebSocket server
- `nick`: nickname (if the character `*` appears in the string, it will be
replaced with a randomly generated value)
- `channels`: comma-separated list of channels to join (`#` needs to be escaped)
- `open`: [IRC URL] to open
- `debug`: if set to 1, debug mode is enabled
Alternatively, the channels can be set with the URL fragment (ie, by just
appending the channel name to the gamja URL).
[IRC URL]: https://datatracker.ietf.org/doc/html/draft-butcher-irc-url-04
+41
View File
@@ -0,0 +1,41 @@
import globals from "globals";
import js from "@eslint/js";
import stylisticJs from "@stylistic/eslint-plugin-js";
export default [
{
ignores: ["dist/"],
},
js.configs.recommended,
{
languageOptions: {
globals: {
...globals.browser,
"process": "readonly",
},
},
plugins: { "@stylistic/js": stylisticJs },
rules: {
"no-case-declarations": "off",
"no-unused-vars": ["error", {
args: "none",
caughtErrorsIgnorePattern: "^_",
destructuredArrayIgnorePattern: "^_",
}],
"no-var": "error",
"eqeqeq": "error",
"no-invalid-this": "error",
"prefer-arrow-callback": "error",
"require-atomic-updates": "error",
"no-implicit-coercion": "warn",
"object-shorthand": "warn",
//"sort-imports": ["warn", { ignoreMemberSort: true, allowSeparatedGroups: true }],
//"func-style": ["warn", "declaration"],
"@stylistic/js/indent": ["warn", "tab"],
"@stylistic/js/quotes": ["warn", "double"],
"@stylistic/js/semi": "warn",
"@stylistic/js/comma-dangle": ["warn", "always-multiline"],
"@stylistic/js/arrow-parens": "warn",
},
},
];
+13 -3
View File
@@ -1,4 +1,4 @@
import { ReceiptType, Unread, BufferType, SERVER_BUFFER, receiptFromMessage } from "./state.js"; import { ReceiptType, Unread, BufferType, receiptFromMessage } from "./state.js";
function getSiblingBuffer(buffers, bufID, delta) { function getSiblingBuffer(buffers, bufID, delta) {
let bufList = Array.from(buffers.values()); let bufList = Array.from(buffers.values());
@@ -40,6 +40,8 @@ export const keybindings = [
}); });
}); });
return { buffers }; return { buffers };
}, () => {
app.updateDocumentTitle();
}); });
}, },
}, },
@@ -94,6 +96,14 @@ export const keybindings = [
} }
}, },
}, },
{
key: "k",
ctrlKey: true,
description: "Switch to a buffer",
execute: (app) => {
app.openDialog("switch");
},
},
]; ];
export function setup(app) { export function setup(app) {
@@ -111,9 +121,9 @@ export function setup(app) {
return; return;
} }
candidates = candidates.filter((binding) => { candidates = candidates.filter((binding) => {
return !!binding.altKey == event.altKey && !!binding.ctrlKey == event.ctrlKey; return Boolean(binding.altKey) === event.altKey && Boolean(binding.ctrlKey) === event.ctrlKey;
}); });
if (candidates.length != 1) { if (candidates.length !== 1) {
return; return;
} }
event.preventDefault(); event.preventDefault();
+24 -2
View File
@@ -10,10 +10,26 @@ const COLOR_HEX = "\x04";
const REVERSE_COLOR = "\x16"; const REVERSE_COLOR = "\x16";
const RESET = "\x0F"; const RESET = "\x0F";
const HEX_COLOR_LENGTH = 6;
function isDigit(ch) { function isDigit(ch) {
return ch >= "0" && ch <= "9"; return ch >= "0" && ch <= "9";
} }
function isHexColor(text) {
if (text.length < HEX_COLOR_LENGTH) {
return false;
}
for (let i = 0; i < HEX_COLOR_LENGTH; i++) {
let ch = text[i].toUpperCase();
let ok = (ch >= "0" && ch <= "9") || (ch >= "A" && ch <= "F");
if (!ok) {
return false;
}
}
return true;
}
export function strip(text) { export function strip(text) {
let out = ""; let out = "";
for (let i = 0; i < text.length; i++) { for (let i = 0; i < text.length; i++) {
@@ -35,7 +51,7 @@ export function strip(text) {
if (isDigit(text[i + 1])) { if (isDigit(text[i + 1])) {
i++; i++;
} }
if (text[i + 1] == "," && isDigit(text[i + 2])) { if (text[i + 1] === "," && isDigit(text[i + 2])) {
i += 2; i += 2;
if (isDigit(text[i + 1])) { if (isDigit(text[i + 1])) {
i++; i++;
@@ -43,7 +59,13 @@ export function strip(text) {
} }
break; break;
case COLOR_HEX: case COLOR_HEX:
i += 6; if (!isHexColor(text.slice(i + 1))) {
break;
}
i += HEX_COLOR_LENGTH;
if (text[i + 1] === "," && isHexColor(text.slice(i + 2))) {
i += 1 + HEX_COLOR_LENGTH;
}
break; break;
default: default:
out += ch; out += ch;
+10 -10
View File
@@ -12,26 +12,26 @@ export function encode(data) {
return btoa(data); return btoa(data);
} }
var encoder = new TextEncoder(); let encoder = new TextEncoder();
var bytes = encoder.encode(data); let bytes = encoder.encode(data);
var trailing = bytes.length % 3; let trailing = bytes.length % 3;
var out = ""; let out = "";
for (var i = 0; i < bytes.length - trailing; i += 3) { for (let i = 0; i < bytes.length - trailing; i += 3) {
var u24 = (bytes[i] << 16) + (bytes[i + 1] << 8) + bytes[i + 2]; let u24 = (bytes[i] << 16) + (bytes[i + 1] << 8) + bytes[i + 2];
out += alphabet[(u24 >> 18) & 0x3F]; out += alphabet[(u24 >> 18) & 0x3F];
out += alphabet[(u24 >> 12) & 0x3F]; out += alphabet[(u24 >> 12) & 0x3F];
out += alphabet[(u24 >> 6) & 0x3F]; out += alphabet[(u24 >> 6) & 0x3F];
out += alphabet[u24 & 0x3F]; out += alphabet[u24 & 0x3F];
} }
if (trailing == 1) { if (trailing === 1) {
var u8 = bytes[bytes.length - 1]; let u8 = bytes[bytes.length - 1];
out += alphabet[u8 >> 2]; out += alphabet[u8 >> 2];
out += alphabet[(u8 << 4) & 0x3F]; out += alphabet[(u8 << 4) & 0x3F];
out += "=="; out += "==";
} else if (trailing == 2) { } else if (trailing === 2) {
var u16 = (bytes[bytes.length - 2] << 8) + bytes[bytes.length - 1]; let u16 = (bytes[bytes.length - 2] << 8) + bytes[bytes.length - 1];
out += alphabet[u16 >> 10]; out += alphabet[u16 >> 10];
out += alphabet[(u16 >> 4) & 0x3F]; out += alphabet[(u16 >> 4) & 0x3F];
out += alphabet[(u16 << 2) & 0x3F]; out += alphabet[(u16 << 2) & 0x3F];
+99 -51
View File
@@ -1,5 +1,4 @@
import * as irc from "./irc.js"; import * as irc from "./irc.js";
import * as base64 from "./base64.js";
// Static list of capabilities that are always requested when supported by the // Static list of capabilities that are always requested when supported by the
// server // server
@@ -10,6 +9,7 @@ const permanentCaps = [
"chghost", "chghost",
"echo-message", "echo-message",
"extended-join", "extended-join",
"extended-monitor",
"invite-notify", "invite-notify",
"labeled-response", "labeled-response",
"message-tags", "message-tags",
@@ -21,9 +21,9 @@ const permanentCaps = [
"draft/account-registration", "draft/account-registration",
"draft/chathistory", "draft/chathistory",
"draft/extended-monitor", "draft/extended-monitor",
"draft/read-marker",
"soju.im/bouncer-networks", "soju.im/bouncer-networks",
"soju.im/read",
]; ];
const RECONNECT_MIN_DELAY_MSEC = 10 * 1000; // 10s const RECONNECT_MIN_DELAY_MSEC = 10 * 1000; // 10s
@@ -48,6 +48,8 @@ const WHOX_FIELDS = {
"realname": "r", "realname": "r",
}; };
const FALLBACK_SERVER_PREFIX = { name: "*" };
let lastLabel = 0; let lastLabel = 0;
let lastWhoxToken = 0; let lastWhoxToken = 0;
@@ -72,8 +74,8 @@ class IRCError extends Error {
class Backoff { class Backoff {
n = 0; n = 0;
constructor(min, max) { constructor(base, max) {
this.min = min; this.base = base;
this.max = max; this.max = max;
} }
@@ -84,10 +86,10 @@ class Backoff {
next() { next() {
if (this.n === 0) { if (this.n === 0) {
this.n = 1; this.n = 1;
return this.min; return 0;
} }
let dur = this.n * this.min; let dur = this.n * this.base;
if (dur > this.max) { if (dur > this.max) {
dur = this.max; dur = this.max;
} else { } else {
@@ -107,7 +109,7 @@ export default class Client extends EventTarget {
}; };
status = Client.Status.DISCONNECTED; status = Client.Status.DISCONNECTED;
serverPrefix = { name: "*" }; serverPrefix = FALLBACK_SERVER_PREFIX;
nick = null; nick = null;
supportsCap = false; supportsCap = false;
caps = new irc.CapRegistry(); caps = new irc.CapRegistry();
@@ -122,6 +124,7 @@ export default class Client extends EventTarget {
pass: null, pass: null,
saslPlain: null, saslPlain: null,
saslExternal: false, saslExternal: false,
saslOauthBearer: null,
bouncerNetwork: null, bouncerNetwork: null,
ping: 0, ping: 0,
eventPlayback: true, eventPlayback: true,
@@ -131,6 +134,7 @@ export default class Client extends EventTarget {
autoReconnect = true; autoReconnect = true;
reconnectTimeoutID = null; reconnectTimeoutID = null;
reconnectBackoff = new Backoff(RECONNECT_MIN_DELAY_MSEC, RECONNECT_MAX_DELAY_MSEC); reconnectBackoff = new Backoff(RECONNECT_MIN_DELAY_MSEC, RECONNECT_MAX_DELAY_MSEC);
lastReconnectDate = new Date(0);
pingIntervalID = null; pingIntervalID = null;
pendingCmds = { pendingCmds = {
WHO: Promise.resolve(null), WHO: Promise.resolve(null),
@@ -144,6 +148,8 @@ export default class Client extends EventTarget {
constructor(params) { constructor(params) {
super(); super();
this.handleOnline = this.handleOnline.bind(this);
this.params = { ...this.params, ...params }; this.params = { ...this.params, ...params };
this.reconnect(); this.reconnect();
@@ -156,6 +162,7 @@ export default class Client extends EventTarget {
console.log("Connecting to " + this.params.url); console.log("Connecting to " + this.params.url);
this.setStatus(Client.Status.CONNECTING); this.setStatus(Client.Status.CONNECTING);
this.lastReconnectDate = new Date();
try { try {
this.ws = new WebSocket(this.params.url); this.ws = new WebSocket(this.params.url);
@@ -188,7 +195,7 @@ export default class Client extends EventTarget {
this.ws = null; this.ws = null;
this.setStatus(Client.Status.DISCONNECTED); this.setStatus(Client.Status.DISCONNECTED);
this.nick = null; this.nick = null;
this.serverPrefix = null; this.serverPrefix = FALLBACK_SERVER_PREFIX;
this.caps = new irc.CapRegistry(); this.caps = new irc.CapRegistry();
this.batches = new Map(); this.batches = new Map();
Object.keys(this.pendingCmds).forEach((k) => { Object.keys(this.pendingCmds).forEach((k) => {
@@ -198,15 +205,16 @@ export default class Client extends EventTarget {
this.monitored = new irc.CaseMapMap(null, irc.CaseMapping.RFC1459); this.monitored = new irc.CaseMapMap(null, irc.CaseMapping.RFC1459);
if (this.autoReconnect) { if (this.autoReconnect) {
window.addEventListener("online", this.handleOnline);
if (!navigator.onLine) { if (!navigator.onLine) {
console.info("Waiting for network to go back online"); console.info("Waiting for network to go back online");
const handleOnline = () => {
window.removeEventListener("online", handleOnline);
this.reconnect();
};
window.addEventListener("online", handleOnline);
} else { } else {
let delay = this.reconnectBackoff.next(); let delay = this.reconnectBackoff.next();
let sinceLastReconnect = new Date().getTime() - this.lastReconnectDate.getTime();
if (sinceLastReconnect < RECONNECT_MIN_DELAY_MSEC) {
delay = Math.max(delay, RECONNECT_MIN_DELAY_MSEC);
}
console.info("Reconnecting to server in " + (delay / 1000) + " seconds"); console.info("Reconnecting to server in " + (delay / 1000) + " seconds");
clearTimeout(this.reconnectTimeoutID); clearTimeout(this.reconnectTimeoutID);
this.reconnectTimeoutID = setTimeout(() => { this.reconnectTimeoutID = setTimeout(() => {
@@ -223,6 +231,8 @@ export default class Client extends EventTarget {
clearTimeout(this.reconnectTimeoutID); clearTimeout(this.reconnectTimeoutID);
this.reconnectTimeoutID = null; this.reconnectTimeoutID = null;
window.removeEventListener("online", this.handleOnline);
this.setPingInterval(0); this.setPingInterval(0);
if (this.ws) { if (this.ws) {
@@ -242,6 +252,13 @@ export default class Client extends EventTarget {
this.dispatchEvent(new CustomEvent("error", { detail: err })); this.dispatchEvent(new CustomEvent("error", { detail: err }));
} }
handleOnline() {
window.removeEventListener("online", this.handleOnline);
if (this.autoReconnect && this.status === Client.Status.DISCONNECTED) {
this.reconnect();
}
}
handleOpen() { handleOpen() {
console.log("Connection opened"); console.log("Connection opened");
this.setStatus(Client.Status.REGISTERING); this.setStatus(Client.Status.REGISTERING);
@@ -283,16 +300,21 @@ export default class Client extends EventTarget {
return; return;
} }
let msg = irc.parseMessage(event.data); let raw = event.data;
if (this.debug) { if (this.debug) {
console.debug("Received:", msg); console.debug("Received:", raw);
} }
let msg = irc.parseMessage(raw);
// If the prefix is missing, assume it's coming from the server on the // If the prefix is missing, assume it's coming from the server on the
// other end of the connection // other end of the connection
if (!msg.prefix) { if (!msg.prefix) {
msg.prefix = this.serverPrefix; msg.prefix = this.serverPrefix;
} }
if (!msg.tags) {
msg.tags = {};
}
let msgBatch = null; let msgBatch = null;
if (msg.tags["batch"]) { if (msg.tags["batch"]) {
@@ -303,7 +325,6 @@ export default class Client extends EventTarget {
} }
let deleteBatch = null; let deleteBatch = null;
let k;
switch (msg.command) { switch (msg.command) {
case irc.RPL_WELCOME: case irc.RPL_WELCOME:
if (this.params.saslPlain && !this.supportsCap) { if (this.params.saslPlain && !this.supportsCap) {
@@ -347,7 +368,7 @@ export default class Client extends EventTarget {
case "AUTHENTICATE": case "AUTHENTICATE":
// Both PLAIN and EXTERNAL expect an empty challenge // Both PLAIN and EXTERNAL expect an empty challenge
let challengeStr = msg.params[0]; let challengeStr = msg.params[0];
if (challengeStr != "+") { if (challengeStr !== "+") {
this.dispatchError(new Error("Expected an empty challenge, got: " + challengeStr)); this.dispatchError(new Error("Expected an empty challenge, got: " + challengeStr));
this.send({ command: "AUTHENTICATE", params: ["*"] }); this.send({ command: "AUTHENTICATE", params: ["*"] });
} }
@@ -418,7 +439,7 @@ export default class Client extends EventTarget {
case irc.ERR_NOPERMFORHOST: case irc.ERR_NOPERMFORHOST:
case irc.ERR_YOUREBANNEDCREEP: case irc.ERR_YOUREBANNEDCREEP:
this.dispatchError(new IRCError(msg)); this.dispatchError(new IRCError(msg));
if (this.status != Client.Status.REGISTERED) { if (this.status !== Client.Status.REGISTERED) {
this.disconnect(); this.disconnect();
} }
break; break;
@@ -426,7 +447,6 @@ export default class Client extends EventTarget {
if (this.status === Client.Status.REGISTERED) { if (this.status === Client.Status.REGISTERED) {
break; break;
} }
let reason = msg.params[msg.params.length - 1];
if (msg.params[0] === "BOUNCER" && msg.params[2] === "BIND") { if (msg.params[0] === "BOUNCER" && msg.params[2] === "BIND") {
this.dispatchError(new Error("Failed to bind to bouncer network", { this.dispatchError(new Error("Failed to bind to bouncer network", {
cause: new IRCError(msg), cause: new IRCError(msg),
@@ -458,14 +478,16 @@ export default class Client extends EventTarget {
console.log(`Starting SASL ${mechanism} authentication`); console.log(`Starting SASL ${mechanism} authentication`);
// Send the first SASL response immediately to avoid a roundtrip // Send the first SASL response immediately to avoid a roundtrip
let initialResp = null; let initialResp;
switch (mechanism) { switch (mechanism) {
case "PLAIN": case "PLAIN":
let respStr = base64.encode("\0" + params.username + "\0" + params.password); initialResp = "\0" + params.username + "\0" + params.password;
initialResp = { command: "AUTHENTICATE", params: [respStr] };
break; break;
case "EXTERNAL": case "EXTERNAL":
initialResp = { command: "AUTHENTICATE", params: [base64.encode("")] }; initialResp = "";
break;
case "OAUTHBEARER":
initialResp = "n,,\x01auth=Bearer " + params.token + "\x01\x01";
break; break;
default: default:
throw new Error(`Unknown authentication mechanism '${mechanism}'`); throw new Error(`Unknown authentication mechanism '${mechanism}'`);
@@ -484,7 +506,9 @@ export default class Client extends EventTarget {
throw new IRCError(msg); throw new IRCError(msg);
} }
}); });
this.send(initialResp); for (let msg of irc.generateAuthenticateMessages(initialResp)) {
this.send(msg);
}
return promise; return promise;
} }
@@ -518,16 +542,19 @@ export default class Client extends EventTarget {
return this.roundtrip(msg, (msg) => { return this.roundtrip(msg, (msg) => {
switch (msg.command) { switch (msg.command) {
case irc.RPL_WHOREPLY: case irc.RPL_WHOREPLY:
msg.internal = true;
l.push(this.parseWhoReply(msg)); l.push(this.parseWhoReply(msg));
break; break;
case irc.RPL_WHOSPCRPL: case irc.RPL_WHOSPCRPL:
if (msg.params.length !== fields.length || msg.params[1] !== token) { if (msg.params.length !== fields.length + 1 || msg.params[1] !== token) {
break; break;
} }
msg.internal = true;
l.push(this.parseWhoReply(msg)); l.push(this.parseWhoReply(msg));
break; break;
case irc.RPL_ENDOFWHO: case irc.RPL_ENDOFWHO:
if (msg.params[1] === mask) { if (msg.params[1] === mask) {
msg.internal = true;
return l; return l;
} }
break; break;
@@ -643,7 +670,7 @@ export default class Client extends EventTarget {
switch (subCmd) { switch (subCmd) {
case "LS": case "LS":
this.supportsCap = true; this.supportsCap = true;
if (args[0] == "*") { if (args[0] === "*") {
break; break;
} }
@@ -658,6 +685,8 @@ export default class Client extends EventTarget {
promise = this.authenticate("PLAIN", this.params.saslPlain); promise = this.authenticate("PLAIN", this.params.saslPlain);
} else if (this.params.saslExternal) { } else if (this.params.saslExternal) {
promise = this.authenticate("EXTERNAL"); promise = this.authenticate("EXTERNAL");
} else if (this.params.saslOauthBearer) {
promise = this.authenticate("OAUTHBEARER", this.params.saslOauthBearer);
} }
(promise || Promise.resolve()).catch((err) => { (promise || Promise.resolve()).catch((err) => {
this.dispatchError(err); this.dispatchError(err);
@@ -695,9 +724,10 @@ export default class Client extends EventTarget {
if (!this.ws) { if (!this.ws) {
throw new Error("Failed to send IRC message " + msg.command + ": socket is closed"); throw new Error("Failed to send IRC message " + msg.command + ": socket is closed");
} }
this.ws.send(irc.formatMessage(msg)); let raw = irc.formatMessage(msg);
this.ws.send(raw);
if (this.debug) { if (this.debug) {
console.debug("Sent:", msg); console.debug("Sent:", raw);
} }
} }
@@ -712,7 +742,7 @@ export default class Client extends EventTarget {
} }
isMyNick(nick) { isMyNick(nick) {
return this.cm(nick) == this.cm(this.nick); return this.cm(nick) === this.cm(this.nick);
} }
isChannel(name) { isChannel(name) {
@@ -720,6 +750,11 @@ export default class Client extends EventTarget {
return chanTypes.indexOf(name[0]) >= 0; return chanTypes.indexOf(name[0]) >= 0;
} }
isNick(name) {
// A dollar sign is used for server-wide broadcasts
return !this.isServer(name) && !this.isChannel(name) && !name.startsWith("$");
}
setPingInterval(sec) { setPingInterval(sec) {
clearInterval(this.pingIntervalID); clearInterval(this.pingIntervalID);
this.pingIntervalID = null; this.pingIntervalID = null;
@@ -754,7 +789,7 @@ export default class Client extends EventTarget {
let msg = event.detail.message; let msg = event.detail.message;
let msgLabel = irc.getMessageLabel(msg); let msgLabel = irc.getMessageLabel(msg);
if (msgLabel && msgLabel != label) { if (msgLabel && msgLabel !== label) {
return; return;
} }
@@ -803,7 +838,9 @@ export default class Client extends EventTarget {
this.removeEventListener("status", handleStatus); this.removeEventListener("status", handleStatus);
}; };
this.addEventListener("message", handleMessage); // Turn on capture to handle messages before external users and
// have the opportunity to set the "internal" flag
this.addEventListener("message", handleMessage, { capture: true });
this.addEventListener("status", handleStatus); this.addEventListener("status", handleStatus);
this.send(msg); this.send(msg);
}); });
@@ -816,7 +853,7 @@ export default class Client extends EventTarget {
} }
let msg = { let msg = {
command: "JOIN", command: "JOIN",
params: params, params,
}; };
return this.roundtrip(msg, (msg) => { return this.roundtrip(msg, (msg) => {
switch (msg.command) { switch (msg.command) {
@@ -842,7 +879,6 @@ export default class Client extends EventTarget {
fetchBatch(msg, batchType) { fetchBatch(msg, batchType) {
let batchName = null; let batchName = null;
let messages = []; let messages = [];
let cmd = msg.command;
return this.roundtrip(msg, (msg) => { return this.roundtrip(msg, (msg) => {
if (batchName) { if (batchName) {
let batch = msg.batch; let batch = msg.batch;
@@ -886,53 +922,48 @@ export default class Client extends EventTarget {
} }
/* Fetch one page of history before the given date. */ /* Fetch one page of history before the given date. */
fetchHistoryBefore(target, before, limit) { async fetchHistoryBefore(target, before, limit) {
let max = Math.min(limit, this.isupport.chatHistory()); let max = Math.min(limit, this.isupport.chatHistory());
let params = ["BEFORE", target, "timestamp=" + before, max]; let params = ["BEFORE", target, "timestamp=" + before, max];
return this.roundtripChatHistory(params).then((messages) => { let messages = await this.roundtripChatHistory(params);
return { messages, more: messages.length >= max }; return { messages, more: messages.length >= max };
});
} }
/* Fetch history in ascending order. */ /* Fetch history in ascending order. */
fetchHistoryBetween(target, after, before, limit) { async fetchHistoryBetween(target, after, before, limit) {
let max = Math.min(limit, this.isupport.chatHistory()); let max = Math.min(limit, this.isupport.chatHistory());
let params = ["AFTER", target, "timestamp=" + after.time, max]; let params = ["AFTER", target, "timestamp=" + after.time, max];
return this.roundtripChatHistory(params).then((messages) => { let messages = await this.roundtripChatHistory(params);
limit -= messages.length; limit -= messages.length;
if (limit <= 0) { if (limit <= 0) {
throw new Error("Cannot fetch all chat history: too many messages"); throw new Error("Cannot fetch all chat history: too many messages");
} }
if (messages.length == max) { if (messages.length >= max) {
// There are still more messages to fetch // There are still more messages to fetch
after.time = messages[messages.length - 1].tags.time; after = { ...after, time: messages[messages.length - 1].tags.time };
return this.fetchHistoryBetween(target, after, before, limit); return await this.fetchHistoryBetween(target, after, before, limit);
} }
return { messages }; return { messages };
});
} }
fetchHistoryTargets(t1, t2) { async fetchHistoryTargets(t1, t2) {
let msg = { let msg = {
command: "CHATHISTORY", command: "CHATHISTORY",
params: ["TARGETS", "timestamp=" + t1, "timestamp=" + t2, 1000], params: ["TARGETS", "timestamp=" + t1, "timestamp=" + t2, 1000],
}; };
return this.fetchBatch(msg, "draft/chathistory-targets").then((batch) => { let batch = await this.fetchBatch(msg, "draft/chathistory-targets");
return batch.messages.map((msg) => { return batch.messages.map((msg) => {
if (msg.command != "CHATHISTORY" || msg.params[0] != "TARGETS") { console.assert(msg.command === "CHATHISTORY" && msg.params[0] === "TARGETS");
throw new Error("Cannot fetch chat history targets: unexpected message " + msg);
}
return { return {
name: msg.params[1], name: msg.params[1],
latestMessage: msg.params[2], latestMessage: msg.params[2],
}; };
}); });
});
} }
listBouncerNetworks() { async listBouncerNetworks() {
let req = { command: "BOUNCER", params: ["LISTNETWORKS"] }; let req = { command: "BOUNCER", params: ["LISTNETWORKS"] };
return this.fetchBatch(req, "soju.im/bouncer-networks").then((batch) => { let batch = await this.fetchBatch(req, "soju.im/bouncer-networks");
let networks = new Map(); let networks = new Map();
for (let msg of batch.messages) { for (let msg of batch.messages) {
console.assert(msg.command === "BOUNCER" && msg.params[0] === "NETWORK"); console.assert(msg.command === "BOUNCER" && msg.params[0] === "NETWORK");
@@ -941,7 +972,6 @@ export default class Client extends EventTarget {
networks.set(id, params); networks.set(id, params);
} }
return networks; return networks;
});
} }
monitor(target) { monitor(target) {
@@ -1015,4 +1045,22 @@ export default class Client extends EventTarget {
return { message: msg.params[2] }; return { message: msg.params[2] };
}); });
} }
supportsReadMarker() {
return this.caps.enabled.has("draft/read-marker");
}
fetchReadMarker(target) {
this.send({
command: "MARKREAD",
params: [target],
});
}
setReadMarker(target, t) {
this.send({
command: "MARKREAD",
params: [target, "timestamp="+t],
});
}
} }
+1 -1
View File
@@ -4,5 +4,5 @@ import { h } from "../node_modules/preact/dist/preact.module.js";
import htm from "../node_modules/htm/dist/htm.module.js"; import htm from "../node_modules/htm/dist/htm.module.js";
export const html = htm.bind(h); export const html = htm.bind(h);
import * as linkifyjs from "../node_modules/linkifyjs/dist/linkify.module.js"; import * as linkifyjs from "../node_modules/linkifyjs/dist/linkify.es.js";
export { linkifyjs }; export { linkifyjs };
+125 -25
View File
@@ -1,3 +1,5 @@
import * as base64 from "./base64.js";
// RFC 1459 // RFC 1459
export const RPL_WELCOME = "001"; export const RPL_WELCOME = "001";
export const RPL_YOURHOST = "002"; export const RPL_YOURHOST = "002";
@@ -52,6 +54,7 @@ export const ERR_BADCHANNELKEY = "475";
// RFC 2812 // RFC 2812
export const ERR_UNAVAILRESOURCE = "437"; export const ERR_UNAVAILRESOURCE = "437";
// Other // Other
export const RPL_CHANNEL_URL = "328";
export const RPL_CREATIONTIME = "329"; export const RPL_CREATIONTIME = "329";
export const RPL_QUIETLIST = "728"; export const RPL_QUIETLIST = "728";
export const RPL_ENDOFQUIETLIST = "729"; export const RPL_ENDOFQUIETLIST = "729";
@@ -71,8 +74,24 @@ export const ERR_SASLTOOLONG = "905";
export const ERR_SASLABORTED = "906"; export const ERR_SASLABORTED = "906";
export const ERR_SASLALREADY = "907"; export const ERR_SASLALREADY = "907";
export const STD_MEMBERSHIPS = "~&@%+"; export const STD_MEMBERSHIP_NAMES = {
export const STD_CHANTYPES = "#&+!"; "~": "owner",
"&": "admin",
"@": "operator",
"%": "halfop",
"+": "voice",
};
export const STD_MEMBERSHIP_MODES = {
"~": "q",
"&": "a",
"@": "o",
"%": "h",
"+": "v",
};
const STD_MEMBERSHIPS = "~&@%+";
const STD_CHANTYPES = "#&+!";
const tagEscapeMap = { const tagEscapeMap = {
";": "\\:", ";": "\\:",
@@ -101,10 +120,10 @@ export function parseTags(s) {
let parts = s.split("=", 2); let parts = s.split("=", 2);
let k = parts[0]; let k = parts[0];
let v = null; let v = null;
if (parts.length == 2) { if (parts.length === 2) {
v = unescapeTag(parts[1]); v = unescapeTag(parts[1]);
if (v.endsWith("\\")) { if (v.endsWith("\\")) {
v = v.slice(0, v.length - 1) v = v.slice(0, v.length - 1);
} }
} }
tags[k] = v; tags[k] = v;
@@ -126,12 +145,6 @@ export function formatTags(tags) {
} }
export function parsePrefix(s) { export function parsePrefix(s) {
let prefix = {
name: null,
user: null,
host: null,
};
let host = null; let host = null;
let i = s.indexOf("@"); let i = s.indexOf("@");
if (i > 0) { if (i > 0) {
@@ -228,7 +241,7 @@ export function formatMessage(msg) {
s += msg.command; s += msg.command;
if (msg.params && msg.params.length > 0) { if (msg.params && msg.params.length > 0) {
for (let i = 0; i < msg.params.length - 1; i++) { for (let i = 0; i < msg.params.length - 1; i++) {
s += " " + msg.params[i] s += " " + msg.params[i];
} }
let last = String(msg.params[msg.params.length - 1]); let last = String(msg.params[msg.params.length - 1]);
@@ -259,10 +272,11 @@ export function parseTargetPrefix(s, allowedPrefixes = STD_MEMBERSHIPS) {
const alphaNum = (() => { const alphaNum = (() => {
try { try {
return new RegExp(/^[\p{L}0-9]$/, "u"); return new RegExp(/^[\p{L}0-9]$/, "u");
} catch (e) { } catch (_e) {
return new RegExp(/^[a-zA-Z0-9]$/, "u"); return new RegExp(/^[a-zA-Z0-9]$/, "u");
} }
})(); })();
const space = new RegExp(/^\s$/);
function isWordBoundary(ch) { function isWordBoundary(ch) {
switch (ch) { switch (ch) {
@@ -275,14 +289,39 @@ function isWordBoundary(ch) {
} }
} }
function isURIPrefix(text) {
for (let i = text.length - 1; i >= 0; i--) {
if (space.test(text[i])) {
text = text.slice(i);
break;
}
}
let i = text.indexOf("://");
if (i <= 0) {
return false;
}
// See RFC 3986 section 3
let ch = text[i - 1];
switch (ch) {
case "+":
case "-":
case ".":
return true;
default:
return alphaNum.test(ch);
}
}
export function isHighlight(msg, nick, cm) { export function isHighlight(msg, nick, cm) {
if (msg.command != "PRIVMSG" && msg.command != "NOTICE") { if (msg.command !== "PRIVMSG" && msg.command !== "NOTICE") {
return false; return false;
} }
nick = cm(nick); nick = cm(nick);
if (msg.prefix && cm(msg.prefix.name) == nick) { if (msg.prefix && cm(msg.prefix.name) === nick) {
return false; // Our own messages aren't highlights return false; // Our own messages aren't highlights
} }
@@ -301,7 +340,7 @@ export function isHighlight(msg, nick, cm) {
if (i + nick.length < text.length) { if (i + nick.length < text.length) {
right = text[i + nick.length]; right = text[i + nick.length];
} }
if (isWordBoundary(left) && isWordBoundary(right)) { if (isWordBoundary(left) && isWordBoundary(right) && !isURIPrefix(text.slice(0, i))) {
return true; return true;
} }
@@ -310,7 +349,7 @@ export function isHighlight(msg, nick, cm) {
} }
export function isServerBroadcast(msg) { export function isServerBroadcast(msg) {
if (msg.command != "PRIVMSG" && msg.command != "NOTICE") { if (msg.command !== "PRIVMSG" && msg.command !== "NOTICE") {
return false; return false;
} }
return msg.params[0].startsWith("$"); return msg.params[0].startsWith("$");
@@ -348,7 +387,7 @@ export function formatDate(date) {
} }
export function parseCTCP(msg) { export function parseCTCP(msg) {
if (msg.command != "PRIVMSG" && msg.command != "NOTICE") { if (msg.command !== "PRIVMSG" && msg.command !== "NOTICE") {
return null; return null;
} }
@@ -468,7 +507,7 @@ export class Isupport {
return stdChanModes; return stdChanModes;
} }
let chanModes = this.raw.get("CHANMODES").split(","); let chanModes = this.raw.get("CHANMODES").split(",");
if (chanModes.length != 4) { if (chanModes.length !== 4) {
console.error("Invalid CHANMODES: ", this.raw.get("CHANMODES")); console.error("Invalid CHANMODES: ", this.raw.get("CHANMODES"));
return stdChanModes; return stdChanModes;
} }
@@ -478,6 +517,40 @@ export class Isupport {
bot() { bot() {
return this.raw.get("BOT"); return this.raw.get("BOT");
} }
userLen() {
if (!this.raw.has("USERLEN")) {
return 20;
}
return parseInt(this.raw.get("USERLEN"), 10);
}
hostLen() {
if (!this.raw.has("HOSTLEN")) {
return 63;
}
return parseInt(this.raw.get("HOSTLEN"), 10);
}
lineLen() {
if (!this.raw.has("LINELEN")) {
return 512;
}
return parseInt(this.raw.get("LINELEN"), 10);
}
filehost() {
return this.raw.get("SOJU.IM/FILEHOST");
}
}
export function getMaxPrivmsgLen(isupport, nick, target) {
let user = "_".repeat(isupport.userLen());
let host = "_".repeat(isupport.hostLen());
let prefix = { name: nick, user, host };
let msg = { prefix, command: "PRIVMSG", params: [target, ""] };
let raw = formatMessage(msg) + "\r\n";
return isupport.lineLen() - raw.length;
} }
export const CaseMapping = { export const CaseMapping = {
@@ -499,13 +572,13 @@ export const CaseMapping = {
let ch = str[i]; let ch = str[i];
if ("A" <= ch && ch <= "Z") { if ("A" <= ch && ch <= "Z") {
ch = ch.toLowerCase(); ch = ch.toLowerCase();
} else if (ch == "{") { } else if (ch === "{") {
ch = "["; ch = "[";
} else if (ch == "}") { } else if (ch === "}") {
ch = "]"; ch = "]";
} else if (ch == "\\") { } else if (ch === "\\") {
ch = "|"; ch = "|";
} else if (ch == "~") { } else if (ch === "~") {
ch = "^"; ch = "^";
} }
out += ch; out += ch;
@@ -519,11 +592,11 @@ export const CaseMapping = {
let ch = str[i]; let ch = str[i];
if ("A" <= ch && ch <= "Z") { if ("A" <= ch && ch <= "Z") {
ch = ch.toLowerCase(); ch = ch.toLowerCase();
} else if (ch == "{") { } else if (ch === "{") {
ch = "["; ch = "[";
} else if (ch == "}") { } else if (ch === "}") {
ch = "]"; ch = "]";
} else if (ch == "\\") { } else if (ch === "\\") {
ch = "|"; ch = "|";
} }
out += ch; out += ch;
@@ -808,6 +881,17 @@ export function parseURL(str) {
return { host, enttype, entity }; return { host, enttype, entity };
} }
export function formatURL({ host, enttype, entity } = {}) {
host = host || "";
entity = entity || "";
let s = "irc://" + host + "/" + encodeURIComponent(entity);
if (enttype) {
s += ",is" + enttype;
}
return s;
}
export class CapRegistry { export class CapRegistry {
available = new Map(); available = new Map();
enabled = new Set(); enabled = new Set();
@@ -870,3 +954,19 @@ export class CapRegistry {
return { command: "CAP", params: ["REQ", l.join(" ")] }; return { command: "CAP", params: ["REQ", l.join(" ")] };
} }
} }
const maxSASLLength = 400;
export function generateAuthenticateMessages(payload) {
let encoded = base64.encode(payload);
// <= instead of < because we need to send a final empty response if the
// last chunk is exactly 400 bytes long
let msgs = [];
for (let i = 0; i <= encoded.length; i += maxSASLLength) {
let chunk = encoded.substring(i, i + maxSASLLength);
msgs.push({ command: "AUTHENTICATE", params: [chunk || "+"] });
}
return msgs;
}
+21 -24
View File
@@ -4,36 +4,33 @@ linkifyjs.options.defaults.defaultProtocol = "https";
linkifyjs.registerCustomProtocol("irc"); linkifyjs.registerCustomProtocol("irc");
linkifyjs.registerCustomProtocol("ircs"); linkifyjs.registerCustomProtocol("ircs");
linkifyjs.registerCustomProtocol("geo", true);
linkifyjs.registerPlugin("ircChannel", ({ scanner, parser, utils }) => { const IRCChannelToken = linkifyjs.createTokenClass("ircChannel", {
const { POUND, DOMAIN, TLD, LOCALHOST, UNDERSCORE, DOT, HYPHEN } = scanner.tokens;
const START_STATE = parser.start;
const Channel = utils.createTokenClass("ircChannel", {
isLink: true, isLink: true,
toHref() { toHref() {
return "irc:///" + this.toString(); return "irc:///" + this.v;
}, },
}); });
const HASH_STATE = START_STATE.tt(POUND); linkifyjs.registerPlugin("ircChannel", ({ scanner, parser }) => {
const { POUND, UNDERSCORE, DOT, HYPHEN } = scanner.tokens;
const { alphanumeric } = scanner.tokens.groups;
const CHAN_STATE = HASH_STATE.tt(DOMAIN, Channel); const Prefix = parser.start.tt(POUND);
HASH_STATE.tt(TLD, CHAN_STATE); const Channel = new linkifyjs.State(IRCChannelToken);
HASH_STATE.tt(LOCALHOST, CHAN_STATE); const Divider = Channel.tt(DOT);
HASH_STATE.tt(POUND, CHAN_STATE);
CHAN_STATE.tt(UNDERSCORE, CHAN_STATE); Prefix.ta(alphanumeric, Channel);
CHAN_STATE.tt(DOMAIN, CHAN_STATE); Prefix.tt(POUND, Channel);
CHAN_STATE.tt(TLD, CHAN_STATE); Prefix.tt(UNDERSCORE, Channel);
CHAN_STATE.tt(LOCALHOST, CHAN_STATE); Prefix.tt(DOT, Divider);
Prefix.tt(HYPHEN, Channel);
const CHAN_DIVIDER_STATE = CHAN_STATE.tt(DOT); Channel.ta(alphanumeric, Channel);
Channel.tt(POUND, Channel);
CHAN_DIVIDER_STATE.tt(UNDERSCORE, CHAN_STATE); Channel.tt(UNDERSCORE, Channel);
CHAN_DIVIDER_STATE.tt(DOMAIN, CHAN_STATE); Channel.tt(HYPHEN, Channel);
CHAN_DIVIDER_STATE.tt(TLD, CHAN_STATE); Divider.ta(alphanumeric, Channel);
CHAN_DIVIDER_STATE.tt(LOCALHOST, CHAN_STATE);
}); });
export default function linkify(text, onClick) { export default function linkify(text, onClick) {
@@ -46,7 +43,7 @@ export default function linkify(text, onClick) {
return; return;
} }
const prefix = text.substring(last, match.start) const prefix = text.substring(last, match.start);
children.push(prefix); children.push(prefix);
children.push(html` children.push(html`
@@ -61,7 +58,7 @@ export default function linkify(text, onClick) {
last = match.end; last = match.end;
}); });
const suffix = text.substring(last) const suffix = text.substring(last);
children.push(suffix); children.push(suffix);
return children; return children;
+109
View File
@@ -0,0 +1,109 @@
function formatQueryString(params) {
let l = [];
for (let k in params) {
l.push(encodeURIComponent(k) + "=" + encodeURIComponent(params[k]));
}
return l.join("&");
}
export async function fetchServerMetadata(url) {
// TODO: handle path in config.oauth2.url
let resp;
try {
resp = await fetch(url + "/.well-known/oauth-authorization-server");
if (!resp.ok) {
throw new Error(`HTTP error: ${resp.status} ${resp.statusText}`);
}
} catch (err) {
console.warn("OAuth 2.0 server doesn't support Authorization Server Metadata (retrying with OpenID Connect Discovery): ", err);
resp = await fetch(url + "/.well-known/openid-configuration");
if (!resp.ok) {
throw new Error(`HTTP error: ${resp.status} ${resp.statusText}`);
}
}
let data = await resp.json();
if (!data.issuer) {
throw new Error("Missing issuer in response");
}
if (!data.authorization_endpoint) {
throw new Error("Missing authorization_endpoint in response");
}
if (!data.token_endpoint) {
throw new Error("Missing authorization_endpoint in response");
}
if (!data.response_types_supported.includes("code")) {
throw new Error("Server doesn't support authorization code response type");
}
return data;
}
export function redirectAuthorize({ serverMetadata, clientId, redirectUri, scope }) {
// TODO: move fragment to query string in redirect_uri
// TODO: use the state param to prevent cross-site request
// forgery
let params = {
response_type: "code",
client_id: clientId,
redirect_uri: redirectUri,
};
if (scope) {
params.scope = scope;
}
window.location.assign(serverMetadata.authorization_endpoint + "?" + formatQueryString(params));
}
function buildPostHeaders(clientId, clientSecret) {
let headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
};
if (clientSecret) {
headers["Authorization"] = "Basic " + btoa(encodeURIComponent(clientId) + ":" + encodeURIComponent(clientSecret));
}
return headers;
}
export async function exchangeCode({ serverMetadata, redirectUri, code, clientId, clientSecret }) {
let data = {
grant_type: "authorization_code",
code,
redirect_uri: redirectUri,
};
if (!clientSecret) {
data.client_id = clientId;
}
let resp = await fetch(serverMetadata.token_endpoint, {
method: "POST",
headers: buildPostHeaders(clientId, clientSecret),
body: formatQueryString(data),
});
if (!resp.ok) {
throw new Error(`HTTP error: ${resp.status} ${resp.statusText}`);
}
data = await resp.json();
if (data.error) {
throw new Error("Authentication failed: " + (data.error_description || data.error));
}
return data;
}
export async function introspectToken({ serverMetadata, token, clientId, clientSecret }) {
let resp = await fetch(serverMetadata.introspection_endpoint, {
method: "POST",
headers: buildPostHeaders(clientId, clientSecret),
body: formatQueryString({ token }),
});
if (!resp.ok) {
throw new Error(`HTTP error: ${resp.status} ${resp.statusText}`);
}
let data = await resp.json();
if (!data.active) {
throw new Error("Expired token");
}
return data;
}
+3074 -2864
View File
File diff suppressed because it is too large Load Diff
+9 -3
View File
@@ -3,12 +3,16 @@
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"htm": "^3.0.4", "htm": "^3.0.4",
"linkifyjs": "^3.0.2", "linkifyjs": "^4.1.3",
"preact": "^10.5.9" "preact": "10.17.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.11.1",
"@parcel/packager-raw-url": "^2.0.0", "@parcel/packager-raw-url": "^2.0.0",
"@parcel/transformer-webmanifest": "^2.0.0", "@parcel/transformer-webmanifest": "^2.0.0",
"@stylistic/eslint-plugin-js": "^2.8.0",
"eslint": "^9.11.1",
"globals": "^15.9.0",
"node-static": "^0.7.11", "node-static": "^0.7.11",
"parcel": "^2.0.0", "parcel": "^2.0.0",
"split": "^1.0.1", "split": "^1.0.1",
@@ -16,12 +20,14 @@
}, },
"scripts": { "scripts": {
"start": "node ./dev-server.js", "start": "node ./dev-server.js",
"build": "parcel build" "build": "parcel build",
"lint": "eslint"
}, },
"private": true, "private": true,
"targets": { "targets": {
"default": { "default": {
"source": "index.html", "source": "index.html",
"context": "browser",
"publicUrl": "." "publicUrl": "."
} }
}, },
+56 -46
View File
@@ -43,22 +43,14 @@ export const BufferEventsDisplayMode = {
export const SettingsContext = createContext("settings"); export const SettingsContext = createContext("settings");
export function getNickURL(nick) {
return "irc:///" + encodeURIComponent(nick) + ",isuser";
}
export function getChannelURL(channel) {
return "irc:///" + encodeURIComponent(channel);
}
export function getBufferURL(buf) { export function getBufferURL(buf) {
switch (buf.type) { switch (buf.type) {
case BufferType.SERVER: case BufferType.SERVER:
return "irc:///"; return irc.formatURL();
case BufferType.CHANNEL: case BufferType.CHANNEL:
return getChannelURL(buf.name); return irc.formatURL({ entity: buf.name });
case BufferType.NICK: case BufferType.NICK:
return getNickURL(buf.name); return irc.formatURL({ entity: buf.name, enttype: "user" });
} }
throw new Error("Unknown buffer type: " + buf.type); throw new Error("Unknown buffer type: " + buf.type);
} }
@@ -144,22 +136,45 @@ function updateState(state, updater) {
} }
function isServerBuffer(buf) { function isServerBuffer(buf) {
return buf.type == BufferType.SERVER; return buf.type === BufferType.SERVER;
}
function isChannelBuffer(buf) {
return buf.type === BufferType.CHANNEL;
}
function trimStartCharacter(s, c) {
let i = 0;
for (; i < s.length; ++i) {
if (s[i] !== c) {
break;
}
}
return s.substring(i);
} }
/* Returns 1 if a should appear after b, -1 if a should appear before b, or /* Returns 1 if a should appear after b, -1 if a should appear before b, or
* 0 otherwise. */ * 0 otherwise. */
function compareBuffers(a, b) { function compareBuffers(a, b) {
if (a.server != b.server) { if (a.server !== b.server) {
return a.server > b.server ? 1 : -1; return a.server > b.server ? 1 : -1;
} }
if (isServerBuffer(a) != isServerBuffer(b)) { if (isServerBuffer(a) !== isServerBuffer(b)) {
return isServerBuffer(b) ? 1 : -1; return isServerBuffer(b) ? 1 : -1;
} }
if (a.name != b.name) {
return a.name > b.name ? 1 : -1; if (isChannelBuffer(a) && isChannelBuffer(b)) {
const strippedA = trimStartCharacter(a.name, a.name[0]);
const strippedB = trimStartCharacter(b.name, b.name[0]);
const cmp = strippedA.localeCompare(strippedB);
if (cmp !== 0) {
return cmp;
} }
return 0; // if they are the same when stripped, fallthough to default logic
}
return a.name.localeCompare(b.name);
} }
function updateMembership(membership, letter, add, client) { function updateMembership(membership, letter, add, client) {
@@ -186,7 +201,7 @@ function updateMembership(membership, letter, add, client) {
/* Insert a message in an immutable list of sorted messages. */ /* Insert a message in an immutable list of sorted messages. */
function insertMessage(list, msg) { function insertMessage(list, msg) {
if (list.length == 0) { if (list.length === 0) {
return [msg]; return [msg];
} else if (!irc.findBatchByType(msg, "chathistory") || list[list.length - 1].tags.time <= msg.tags.time) { } else if (!irc.findBatchByType(msg, "chathistory") || list[list.length - 1].tags.time <= msg.tags.time) {
return list.concat(msg); return list.concat(msg);
@@ -326,7 +341,7 @@ export const State = {
let id = lastBufferID; let id = lastBufferID;
let type; let type;
if (name == SERVER_BUFFER) { if (name === SERVER_BUFFER) {
type = BufferType.SERVER; type = BufferType.SERVER;
} else if (client.isChannel(name)) { } else if (client.isChannel(name)) {
type = BufferType.CHANNEL; type = BufferType.CHANNEL;
@@ -343,6 +358,7 @@ export const State = {
serverInfo: null, // if server serverInfo: null, // if server
joined: false, // if channel joined: false, // if channel
topic: null, // if channel topic: null, // if channel
hasInitialWho: false, // if channel
members: new irc.CaseMapMap(null, client.cm), // if channel members: new irc.CaseMapMap(null, client.cm), // if channel
messages: [], messages: [],
unread: Unread.NONE, unread: Unread.NONE,
@@ -401,7 +417,7 @@ export const State = {
case irc.RPL_ISUPPORT: case irc.RPL_ISUPPORT:
buffers = new Map(state.buffers); buffers = new Map(state.buffers);
state.buffers.forEach((buf) => { state.buffers.forEach((buf) => {
if (buf.server != serverID) { if (buf.server !== serverID) {
return; return;
} }
let members = new irc.CaseMapMap(buf.members, client.cm); let members = new irc.CaseMapMap(buf.members, client.cm);
@@ -459,10 +475,18 @@ export const State = {
}); });
return { members }; return { members };
}); });
break; case irc.RPL_ENDOFWHO:
case irc.RPL_WHOREPLY: target = msg.params[1];
case irc.RPL_WHOSPCRPL: if (msg.list.length === 0 && !client.isChannel(target) && target.indexOf("*") < 0) {
who = client.parseWhoReply(msg); // Not a channel nor a mask, likely a nick
return updateUser(target, (user) => {
return { offline: true };
});
} else {
return updateServer((server) => {
let users = new irc.CaseMapMap(server.users);
for (let reply of msg.list) {
let who = client.parseWhoReply(reply);
if (who.flags !== undefined) { if (who.flags !== undefined) {
who.away = who.flags.indexOf("G") >= 0; // H for here, G for gone who.away = who.flags.indexOf("G") >= 0; // H for here, G for gone
@@ -476,21 +500,16 @@ export const State = {
who.offline = false; who.offline = false;
return updateUser(who.nick, who); users.set(who.nick, who);
case irc.RPL_ENDOFWHO: }
target = msg.params[1]; return { users };
if (msg.list.length == 0 && !client.isChannel(target) && target.indexOf("*") < 0) {
// Not a channel nor a mask, likely a nick
return updateUser(target, (user) => {
return { offline: true };
}); });
} }
break;
case "JOIN": case "JOIN":
channel = msg.params[0]; channel = msg.params[0];
if (client.isMyNick(msg.prefix.name)) { if (client.isMyNick(msg.prefix.name)) {
let [id, update] = State.createBuffer(state, channel, serverID, client); let [_id, update] = State.createBuffer(state, channel, serverID, client);
state = { ...state, ...update }; state = { ...state, ...update };
} }
@@ -548,7 +567,7 @@ export const State = {
case "QUIT": case "QUIT":
buffers = new Map(state.buffers); buffers = new Map(state.buffers);
state.buffers.forEach((buf) => { state.buffers.forEach((buf) => {
if (buf.server != serverID) { if (buf.server !== serverID) {
return; return;
} }
if (!buf.members.has(msg.prefix.name)) { if (!buf.members.has(msg.prefix.name)) {
@@ -574,7 +593,7 @@ export const State = {
buffers = new Map(state.buffers); buffers = new Map(state.buffers);
state.buffers.forEach((buf) => { state.buffers.forEach((buf) => {
if (buf.server != serverID) { if (buf.server !== serverID) {
return; return;
} }
if (!buf.members.has(msg.prefix.name)) { if (!buf.members.has(msg.prefix.name)) {
@@ -615,7 +634,7 @@ export const State = {
return updateUser(msg.prefix.name, { account }); return updateUser(msg.prefix.name, { account });
case "AWAY": case "AWAY":
let awayMessage = msg.params[0]; let awayMessage = msg.params[0];
return updateUser(msg.prefix.name, { away: !!awayMessage }); return updateUser(msg.prefix.name, { away: Boolean(awayMessage) });
case "TOPIC": case "TOPIC":
channel = msg.params[0]; channel = msg.params[0];
topic = msg.params[1]; topic = msg.params[1];
@@ -647,21 +666,12 @@ export const State = {
return { members }; return { members };
}); });
case irc.RPL_MONONLINE: case irc.RPL_MONONLINE:
targets = msg.params[1].split(",");
for (let target of targets) {
let prefix = irc.parsePrefix(target);
let update = updateUser(prefix.name, { offline: false });
state = { ...state, ...update };
}
return state;
case irc.RPL_MONOFFLINE: case irc.RPL_MONOFFLINE:
targets = msg.params[1].split(","); targets = msg.params[1].split(",");
for (let target of targets) { for (let target of targets) {
let prefix = irc.parsePrefix(target); let prefix = irc.parsePrefix(target);
let update = updateUser(prefix.name, { offline: true }); let update = updateUser(prefix.name, { offline: msg.command === irc.RPL_MONOFFLINE });
state = { ...state, ...update }; state = { ...state, ...update };
} }
+17 -8
View File
@@ -1,4 +1,4 @@
import { ReceiptType } from "./state.js"; import { ReceiptType, Unread } from "./state.js";
const PREFIX = "gamja_"; const PREFIX = "gamja_";
@@ -62,8 +62,6 @@ export class Buffer {
return JSON.stringify({ return JSON.stringify({
name: buf.name.toLowerCase(), name: buf.name.toLowerCase(),
server: { server: {
url: buf.server.url,
nick: buf.server.nick.toLowerCase(),
bouncerNetwork: buf.server.bouncerNetwork, bouncerNetwork: buf.server.bouncerNetwork,
}, },
}); });
@@ -87,7 +85,7 @@ export class Buffer {
let updated = !this.m.has(key); let updated = !this.m.has(key);
let prev = this.m.get(key) || {}; let prev = this.m.get(key) || {};
let unread = prev.unread; let unread = prev.unread || Unread.NONE;
if (buf.unread !== undefined && buf.unread !== prev.unread) { if (buf.unread !== undefined && buf.unread !== prev.unread) {
unread = buf.unread; unread = buf.unread;
updated = true; updated = true;
@@ -109,6 +107,12 @@ export class Buffer {
} }
} }
let closed = prev.closed || false;
if (buf.closed !== undefined && buf.closed !== prev.closed) {
closed = buf.closed;
updated = true;
}
if (!updated) { if (!updated) {
return false; return false;
} }
@@ -117,9 +121,8 @@ export class Buffer {
name: buf.name, name: buf.name,
unread, unread,
receipts, receipts,
closed,
server: { server: {
url: buf.server.url,
nick: buf.server.nick,
bouncerNetwork: buf.server.bouncerNetwork, bouncerNetwork: buf.server.bouncerNetwork,
}, },
}); });
@@ -134,19 +137,25 @@ export class Buffer {
} }
list(server) { list(server) {
// Some gamja versions would store the same buffer multiple times
let names = new Set();
let buffers = []; let buffers = [];
for (const buf of this.m.values()) { for (const buf of this.m.values()) {
if (buf.server.url !== server.url || buf.server.nick !== server.nick || buf.server.bouncerNetwork !== server.bouncerNetwork) { if (buf.server.bouncerNetwork !== server.bouncerNetwork) {
continue;
}
if (names.has(buf.name)) {
continue; continue;
} }
buffers.push(buf); buffers.push(buf);
names.add(buf.name);
} }
return buffers; return buffers;
} }
clear(server) { clear(server) {
if (server) { if (server) {
for (const buf of this.m.values()) { for (const buf of this.list(server)) {
this.m.delete(this.key(buf)); this.m.delete(this.key(buf));
} }
} else { } else {
+54 -17
View File
@@ -154,9 +154,8 @@ button.danger:hover {
padding: 2px 10px; padding: 2px 10px;
box-sizing: border-box; box-sizing: border-box;
} }
#buffer-list li.active a { #buffer-list li.error a {
color: white; color: red;
background-color: var(--gray);
} }
#buffer-list li.unread-message a { #buffer-list li.unread-message a {
color: #b37400; color: #b37400;
@@ -164,6 +163,10 @@ button.danger:hover {
#buffer-list li.unread-highlight a { #buffer-list li.unread-highlight a {
color: #22009b; color: #22009b;
} }
#buffer-list li.active a {
color: white;
background-color: var(--gray);
}
#buffer-list li:not(.type-server) a { #buffer-list li:not(.type-server) a {
padding-left: 20px; padding-left: 20px;
} }
@@ -299,7 +302,7 @@ button.danger:hover {
.membership.admin { .membership.admin {
color: blue; color: blue;
} }
.membership.op { .membership.operator {
color: var(--green); color: var(--green);
} }
.membership.halfop { .membership.halfop {
@@ -349,18 +352,27 @@ form input[type="text"],
form input[type="username"], form input[type="username"],
form input[type="password"], form input[type="password"],
form input[type="url"], form input[type="url"],
form input[type="email"] { form input[type="email"],
form input[type="search"] {
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
font-family: inherit;
font-size: inherit;
} }
a { a {
color: var(--green); color: var(--green);
} }
#buffer-list li a, a.timestamp, a.nick { #buffer-list li a, a.timestamp, a.nick {
color: var(--gray);
text-decoration: none; text-decoration: none;
} }
#buffer-list li a,
a.nick {
color: var(--main-color);
}
a.timestamp {
color: var(--gray);
}
#buffer-list li a:hover, #buffer-list li a:active, #buffer-list li a:hover, #buffer-list li a:active,
a.timestamp:hover, a.timestamp:active, a.timestamp:hover, a.timestamp:active,
a.nick:hover, a.nick:active { a.nick:hover, a.nick:active {
@@ -382,6 +394,7 @@ details summary[role="button"] {
} }
#buffer .logline { #buffer .logline {
white-space: pre-wrap; white-space: pre-wrap;
overflow: auto;
} }
#buffer .talk, #buffer .motd { #buffer .talk, #buffer .motd {
color: var(--main-color); color: var(--main-color);
@@ -556,21 +569,44 @@ kbd {
border-radius: 3px; border-radius: 3px;
} }
ul.switcher-list {
list-style-type: none;
margin: 0;
padding: 0;
margin-top: 10px;
}
ul.switcher-list li a {
display: inline-block;
width: 100%;
padding: 5px 10px;
margin: 4px 0;
box-sizing: border-box;
text-decoration: none;
color: inherit;
}
ul.switcher-list li a.selected {
background-color: rgba(0, 0, 0, 0.1);
}
ul.switcher-list .server {
float: right;
opacity: 0.8;
}
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
html { html {
scrollbar-color: var(--gray) transparent; scrollbar-color: var(--gray) transparent;
} }
#buffer-list li.active a {
color: var(--sidebar-background);
background-color: white;
}
#buffer-list li.unread-message a { #buffer-list li.unread-message a {
color: var(--green); color: var(--green);
} }
#buffer-list li.unread-highlight a { #buffer-list li.unread-highlight a {
color: #0062cc; color: #0062cc;
} }
#buffer-list li.active a {
color: var(--sidebar-background);
background-color: white;
}
#buffer-header .status-gone { #buffer-header .status-gone {
color: #fb885b; color: #fb885b;
@@ -583,7 +619,8 @@ kbd {
form input[type="username"], form input[type="username"],
form input[type="password"], form input[type="password"],
form input[type="url"], form input[type="url"],
form input[type="email"] { form input[type="email"],
form input[type="search"] {
color: #ffffff; color: #ffffff;
background: var(--sidebar-background); background: var(--sidebar-background);
border: 1px solid #495057; border: 1px solid #495057;
@@ -593,16 +630,12 @@ kbd {
form input[type="username"]:focus, form input[type="username"]:focus,
form input[type="password"]:focus, form input[type="password"]:focus,
form input[type="url"]:focus, form input[type="url"]:focus,
form input[type="email"]:focus { form input[type="email"]:focus,
form input[type="search"]:focus {
outline: 0; outline: 0;
border-color: #3897ff; border-color: #3897ff;
} }
#buffer-list li a,
a.nick {
color: var(--main-color);
}
#buffer { #buffer {
background: var(--main-background); background: var(--main-background);
} }
@@ -672,6 +705,10 @@ kbd {
border: 1px solid var(--outline-color); border: 1px solid var(--outline-color);
box-shadow: inset 0 -1px 0 var(--outline-color); box-shadow: inset 0 -1px 0 var(--outline-color);
} }
ul.switcher-list li a.selected {
background-color: rgba(255, 255, 255, 0.1);
}
} }
@media (max-width: 640px) { @media (max-width: 640px) {