166 Commits

Author SHA1 Message Date
Simon Ser
73db1a888e Upgrade dependencies 2022-02-26 10:47:55 +01:00
Simon Ser
3dc98ec797 Convert remaining simple quotes to double quotes 2022-02-26 09:54:31 +01:00
Simon Ser
e37d5f363b lib/irc: fix bound check in isHighlight
Doesn't seem like this was causing any issues, but let's fix the
logic regardless.
2022-02-25 11:38:00 +01:00
Simon Ser
221b1b6356 lib/irc: remove unnecessary non-breaking-space case
Handled by the default case already.
2022-02-25 11:37:18 +01:00
Simon Ser
86b1030b7a lib/irc: add missing num range to alphaNum regexp 2022-02-25 11:36:43 +01:00
Simon Ser
08578c9a21 components/app: fix missing semicolons 2022-02-21 15:26:12 +01:00
Simon Ser
26cc073f41 store: save buffer state when user navigates away
Avoids loosing some state on page unload.
2022-02-18 18:22:00 +01:00
Simon Ser
9e703698ca lib/irc: drop outdated CapRegistry TODO 2022-02-16 15:46:22 +01:00
Simon Ser
37d7f4a1c5 Refactor backlog fetching into function 2022-02-13 15:34:11 +01:00
Simon Ser
962c05c066 Prevent hole in history when reconnecting 2022-02-13 15:26:04 +01:00
Simon Ser
f2c9fd1d7f Update stored unread status on READ message 2022-02-12 10:24:56 +01:00
Simon Ser
a3eec9a351 store: add note about comparison in Buffer.put 2022-02-12 10:24:34 +01:00
Simon Ser
2ac7be6218 state: add isReceiptBefore 2022-02-12 10:21:11 +01:00
Simon Ser
5f8cd976e6 keybindings: fix error on alt+h
Fixes the following JS error:

    TypeError: e.setReceipt is not a function
2022-02-12 10:05:58 +01:00
Simon Ser
fbc42b6dab components/app: move lastErrorID declaration down
Move it right before App, rather than drown in-between unrelated
functions.
2022-02-11 21:17:35 +01:00
Simon Ser
dc398baa3b components/app: stop updating prevReadReceipt on READ message
prevReadReceipt is used for the unread marker. Let's not update it
before the user switches the current buffer.
2022-02-11 21:09:11 +01:00
Simon Ser
6a9a8e88f1 store: fix no-op read receipt update detection
If the old and new times are equal, the update is a no-op.
2022-02-11 21:07:49 +01:00
Simon Ser
f47d93af8a Don't fetch backlog before read receipt 2022-02-11 21:02:34 +01:00
Simon Ser
fce0936c20 components/app: introduce getReceipt 2022-02-11 20:59:31 +01:00
Simon Ser
0636544c40 components/app: close notifications when receiving READ message 2022-02-11 19:32:30 +01:00
Simon Ser
7c6f334dbf components/app: close notifications when switching buffer 2022-02-11 19:32:30 +01:00
Simon Ser
7ddd783150 components/app: make showNotification return null on error
We'll do more involved stuff with notifications soon, and don't
want to deal with buggy notification objects.
2022-02-11 19:32:30 +01:00
Simon Ser
bb42ff6a07 components/app: include server ID in notification tags 2022-02-11 19:32:30 +01:00
Simon Ser
db0ef39c6b Add support for soju.im/read 2022-02-11 19:32:26 +01:00
Simon Ser
77f54080e7 Make delivery receipts follow read receipts
If a message has been read, it's been delivered.

Fixes #23 at least partially.

References: https://todo.sr.ht/~emersion/gamja/23
2022-02-11 19:29:55 +01:00
Simon Ser
065b3f21fc Refactor receipts
They are now saved in the buffer store to allow for proper server
separation.
2022-02-11 19:29:55 +01:00
Simon Ser
d2bcea8c86 Introduce isMessageBeforeReceipt 2022-02-11 16:37:58 +01:00
Simon Ser
3d81466788 components/app: introduce receiptFromMessage 2022-02-11 16:30:46 +01:00
Simon Ser
f2923452c1 store: debounce buffer store saves 2022-02-11 16:24:32 +01:00
Simon Ser
39c36e7a7b Fix unread marker going back
Receipts must never go back in time.

Fixes: c428e504fe ("Don't show unread marker for outgoing messages")
2022-02-11 16:06:06 +01:00
Simon Ser
e91b044134 components/app: make switchBuffer state changes atomic
Instead of calling App.setBufferState inside the App.setState
callback invoked when the update is done, call State.updateBuffer.
2022-02-11 15:48:56 +01:00
delthas
4cb3abfa72 components/connect-form: make the server password field password-typed 2022-02-11 12:58:26 +01:00
Simon Ser
0063a5a372 Set min node version in package.json
v14.13.0 is required for CommonJS named imports to work properly.
2022-02-10 14:46:42 +01:00
Дамјан Георгиевски
1142145c6d fix ping after reconnect
client.setPingInterval was only called once in app.connect(),
but client.disconnect() disables it, and the ping timer is never again set,
even though the client can reconnect.

the change passes the ping time as a parameter to the client, and the
client calls setPingInterval() after a successful WS open event.
2022-02-04 15:54:23 +01:00
Simon Ser
f465e24adf components/buffer-header: fix dead space above description 2022-02-04 14:38:28 +01:00
Simon Ser
7f7a7c1aac components/buffer-list: remove pointless temporary variable 2022-02-04 14:32:29 +01:00
Simon Ser
e1bbe34ff2 state: add bouncerNetworks helpers 2022-02-04 14:22:50 +01:00
delthas
fab42ba2ee commands: add password param to /join 2022-02-02 20:45:18 +01:00
Simon Ser
9f93e200ed commands: add comment param to /kick usage 2022-01-31 18:30:48 +01:00
Simon Ser
bd48f36ade lib/irc: add missing Isupport.chanModes
It was called by forEachChannelModeUpdate, but wasn't implemented.
2022-01-31 18:24:34 +01:00
xse
393fd93253 components/buffer: use browser locale for date-separator 2022-01-14 23:26:05 +01:00
Simon Ser
a0f8f1f52f components/buffer: fix INVITE link
It was throwing a TypeError.
2022-01-10 10:32:37 +01:00
Simon Ser
5d6de11a4c commands: simplify /who usage string
As per https://modern.ircdocs.horse/#who-message
2022-01-09 19:30:01 +01:00
Simon Ser
6692ed0035 components/help: use bold for command name only 2022-01-09 19:28:23 +01:00
Simon Ser
5e34067d38 components/help: remove "/" keybinding, document middle mouse click 2022-01-09 19:20:44 +01:00
Isaac Freund
690845c2af Better handle long topics on small screen sizes
Currently long topics will cause the buffer header to take up an
arbitrarily large percentage of the screen on mobile. Additionaly, long
words like URLS are not broken and may cause the buffer header to extend
outside of the viewport in the x direction, rendering the buffer content
unreadable.

This patch fixes these two issues by limiting the buffer header size to
20% of the viewport and breaking long words such as URLs if they would
overflow.

Fixes: https://todo.sr.ht/~emersion/gamja/129
2022-01-07 16:02:33 +01:00
Noelle Leigh
0b59cf92b9 Display persistant command input on server buffer
This commit changes the composer to not be read-only on the server
buffer, which tells the user that they can send commands from that view.

On the server buffer, the placeholder is changed to
"Type a command (see /help)", which indicates to the user that this buffer
only accepts commands, and gives them a hint for how to learn what
commands are available.

Implements: https://todo.sr.ht/~emersion/gamja/38
2021-12-21 10:44:24 +01:00
Simon Ser
b11f58b975 state: fix prefix() call in MODE handler
Lost during a refactoring.

Fixes: ab3d4dd661 ("Refactor ISUPPORT handling")
2021-12-16 23:37:33 +01:00
Simon Ser
4704e0f12f ci: fix deploy skip 2021-12-16 23:14:10 +01:00
Rafael Castillo
43f1329fb0 Add away command 2021-12-13 17:35:41 +01:00
Simon Ser
4cabae89ff lib/irc: add CapRegistry 2021-12-10 15:34:51 +01:00
Simon Ser
f6895fed32 Add reconnect button 2021-12-07 13:39:02 +01:00
Simon Ser
fc93a8cef5 state: fix server bouncerNetID
Ooops.
2021-12-07 13:37:14 +01:00
Simon Ser
f3d38859d3 Move isBouncer props to server state
Avoids having to pass this around.
2021-12-07 13:16:07 +01:00
Simon Ser
f81c564d23 Implement exponential backoff for reconnections
Closes: https://todo.sr.ht/~emersion/gamja/118
2021-12-07 13:05:42 +01:00
Simon Ser
ab3d4dd661 Refactor ISUPPORT handling
Add a helper class to parse ISUPPORT tokens. Instead of having
manual ISUPPORT handling all over the place, use pre-processed
values.
2021-12-07 12:09:10 +01:00
Simon Ser
31b293fa03 lib/client: use Error objects for error events 2021-12-06 23:09:30 +01:00
Simon Ser
f9ec578fce Handle FAIL ACCOUNT_REQUIRED 2021-12-06 22:54:15 +01:00
Simon Ser
305f510501 Read nickname from RPL_WELCOME
References: https://github.com/ircdocs/modern-irc/pull/146
2021-12-06 17:55:47 +01:00
Simon Ser
05f7c6e9fe Add Client.join, show join errors in popup 2021-12-04 17:44:23 +01:00
Simon Ser
fc8aa30756 lib/client: add generic error handling to roundtrip() 2021-12-04 17:22:36 +01:00
Simon Ser
8c8bd43638 lib/client: introduce IRCError 2021-12-04 17:05:34 +01:00
Simon Ser
30e3ec392f Update channel join status when kicked 2021-12-04 16:52:38 +01:00
Simon Ser
ada9ff3b71 components/buffer-header: fix missing "join" button for parted channel 2021-12-03 19:09:52 +01:00
Simon Ser
93ba0e6443 Disable debug logs in production
console.debug logs cause some performance issues because the browser
is forced to save the logged objects just in case the user opens the
debugging tools.

They can be force-enabled back by adding ?debug=1 to the URL.

Only console.debug is disabled, console.log and other levels are a lot
less verbose and still enabled by default.
2021-12-01 11:40:59 +01:00
Simon Ser
07c9cdebb6 Add usage message to development server 2021-12-01 10:44:03 +01:00
Simon Ser
aef2812348 Add custom developement server
This implements a tiny WebSocket proxy useful for development
purposes.
2021-12-01 10:34:41 +01:00
Simon Ser
a1ff1be342 Mark auth dialog as loading 2021-11-30 16:05:08 +01:00
Simon Ser
47f56f06b9 Mark register/verify dialogs as loading 2021-11-30 15:49:52 +01:00
Simon Ser
1e84412172 Show "Manage network" even if upstream is disconnected
Fixes: 86853eb2e5 ("components/buffer-header: hide action buttons when disconnected")
2021-11-30 15:29:24 +01:00
Simon Ser
451bb4c73f Add link to verify account next to VERIFICATION_REQUIRED message 2021-11-30 15:13:34 +01:00
Simon Ser
be08302c1f Add support for draft/account-registration
A new UI to register and verify accounts is added.
2021-11-30 14:59:44 +01:00
Drew DeVault
b1d5f1436e Improve noscript UI appearance 2021-11-29 13:53:23 +01:00
Simon Ser
c4a78283af Linkify error messages
Sometimes servers will put links and channels in their error
messages. Make it easy for users to click them.
2021-11-29 13:38:07 +01:00
Simon Ser
25e69a551e Clear channel joined field when disconnected 2021-11-29 11:44:45 +01:00
Simon Ser
86853eb2e5 components/buffer-header: hide action buttons when disconnected 2021-11-29 11:44:28 +01:00
Simon Ser
1800b6bea1 components/member-list: re-render on State.users update 2021-11-28 20:13:08 +01:00
Simon Ser
fcce340846 Dim away users in member list
References: https://todo.sr.ht/~emersion/gamja/13
2021-11-28 20:09:48 +01:00
Simon Ser
e29ccf7220 Add embedded Content-Security-Policy
Add a baseline CSP applicable to all gamja deployments. Resources
can only be loaded from the current host, frames and objects are
disallowed, and scripts are allowed to connect to any host (to allow
cross-site WebSocket connections).

If the server returns a different CSP via an HTTP header, the
effective CSP will be the intersection.
2021-11-27 12:35:02 +01:00
Simon Ser
d8d2cbe0f7 readme: add nginx file server directive 2021-11-27 12:26:25 +01:00
Simon Ser
0d2067e33e components/connect-form: replace auto-join text field with checkbox
The intent of the auto-join field is to ask the user whether they
really want to join the pre-filled channel. Users rarely want to
customize this field, they can just manually click "Join" after
connecting if they want to join another channel.
2021-11-27 12:08:23 +01:00
Simon Ser
3e309e9dfe Ignore RPL_AWAY 2021-11-23 17:58:49 +01:00
Simon Ser
3e2ac307f6 Add post-connect UI to login via SASL
If the server supports SASL and if we aren't logged in with any
account, add a UI to authenticate via SASL. This allows users to
login anonymously then login via SASL.

This will also ease the draft/account-registration implementation.
2021-11-21 16:40:46 +01:00
Simon Ser
24b50a332c lib/client: make authenticate() return a promise
This lets the caller handle the success/failure.
2021-11-21 16:06:13 +01:00
Simon Ser
adefc620de lib/client: send BOUNCER BIND and CAP END immediately
Don't wait for auth to finish. This reduces the number of roundtrips.
2021-11-21 13:48:41 +01:00
Simon Ser
bc3abbec32 lib/client: catch handleMessage errors 2021-11-21 13:48:07 +01:00
Simon Ser
4f927b5536 lib/client: always request sasl cap when available
This will allow us to issue post-registration SASL commands.
2021-11-21 13:35:32 +01:00
Simon Ser
86b08296a0 lib/client: don't disconnect on SASL error if registered
This will let users try multiple auth attempts when we'll implement
post-registration auth.
2021-11-21 13:23:14 +01:00
Simon Ser
25dd6aabf6 lib/client: remove one roundtrip during SASL auth
Instead of waiting for the server's empty challenge, send two
AUTHENTICATE commands in a row.
2021-11-21 13:21:42 +01:00
Simon Ser
0af40a1a8e state: add account to server 2021-11-21 12:13:44 +01:00
Simon Ser
51bf8da3d6 lib/client: don't error out if SASL isn't available on RPL_WELCOME
Some servers (soju) might remove the sasl cap on connection
registration.
2021-11-19 19:32:13 +01:00
Cara Salter
723951a07b commands: Add LIST command
Signed-off-by: Cara Salter <cara@devcara.com>
2021-11-18 16:24:18 +01:00
Simon Ser
c4c0a77162 Avoid inline script in index.html
This helps Parcel generate a proper standalone JS bundle.
2021-11-17 10:58:02 +01:00
Simon Ser
3f2553291f ci: fix deploy branch check again, exclude config.json 2021-11-17 10:45:18 +01:00
Simon Ser
debd50f482 ci: fix deploy branch check 2021-11-17 10:33:16 +01:00
Simon Ser
a57428002f ci: add deploy task 2021-11-17 10:31:09 +01:00
Simon Ser
bbfeb5bcbc ci: add .build.yml 2021-11-17 10:20:49 +01:00
Simon Ser
0980983bdc readme: add link to IRC channel 2021-11-17 10:17:41 +01:00
Simon Ser
e37c2a2cec Auto-dismiss client error on reconnect
References: https://todo.sr.ht/~emersion/gamja/74
2021-11-17 10:12:36 +01:00
Simon Ser
82e5a2795d Properly handle port in irc:// URLs 2021-11-16 11:52:38 +01:00
Simon Ser
a0b250df3f Reword ProtocolHandlerNagger message 2021-11-16 11:30:33 +01:00
Simon Ser
321140327e Add UI to enable protocol handler 2021-11-16 11:19:25 +01:00
Simon Ser
be475026c8 lib/irc: fix handling for prefixes without host
name!user is a valid prefix.
2021-11-15 16:05:51 +01:00
Simon Ser
55361c5a2b Store WHO list in RPL_ENDOFWHO
This allows the state-tracker to figure out whether a WHO query
returned no result.
2021-11-10 10:32:23 +01:00
Simon Ser
c11bf6508a Only allow one WHO command at a time
Closes: https://todo.sr.ht/~emersion/gamja/120
2021-11-10 10:08:47 +01:00
Simon Ser
195e4ca371 Don't stop fetching backlog on error
Some servers allow fetching history from some targets but not
others. Don't completely stop fetching chat history on error.

The root cause was a variable shadowing in Client.fetchBatch.
2021-11-10 09:53:17 +01:00
Simon Ser
1206cfae37 Add support for draft/extended-monitor
References: https://github.com/ircv3/ircv3-specifications/pull/466
2021-11-09 12:50:11 +01:00
Simon Ser
df29650b98 Always insert non-chathistory messages at the end 2021-11-09 10:49:18 +01:00
Simon Ser
94901f1662 Request WHO info w/ empty message list in switchBuffer 2021-11-08 15:03:05 +01:00
Simon Ser
9475ffb8c6 Don't auto-join without prompting user 2021-11-08 13:01:54 +01:00
Simon Ser
f3c48a3748 Add "open" URL param
This can be set to an irc:// URL to open. This is useful for
bouncers.
2021-11-08 12:33:02 +01:00
Simon Ser
14031c594b Ask confirmation before JOIN on irc:// link click 2021-11-08 10:44:10 +01:00
Simon Ser
74fe6ee944 Auto-join when adding new network on irc:// link click
Closes: https://todo.sr.ht/~emersion/gamja/111
2021-11-07 19:47:49 +01:00
Simon Ser
a58befd6d7 s/var/let/ 2021-11-07 13:51:39 +01:00
Simon Ser
38a3075a2c Disconnect previous server on connect re-submit 2021-11-07 13:50:26 +01:00
Simon Ser
96dd8476ad De-duplicate nicks in folded JOIN/PART/QUIT lines 2021-11-05 15:00:08 +01:00
Simon Ser
800f5ceb6a Keep track of channel join status
This makes us behave better when we receive a self-PART message
from the server.
2021-11-05 11:49:56 +01:00
Simon Ser
7b19cf48a4 Add Parcel to dev dependencies
Closes: https://todo.sr.ht/~emersion/gamja/119
2021-11-04 12:21:21 +01:00
Simon Ser
50f10a43dd components/buffer: show MODE target if different from buffer name
This happens for user modes, for instance.
2021-11-03 21:58:26 +01:00
Simon Ser
eb66045371 lib/client: use Client.isMyNick to handle self-NICK messages
This handles case-mapping.
2021-11-03 21:50:33 +01:00
Simon Ser
a1ab87c71c Route self-NICK messages to server buffer 2021-11-03 21:49:53 +01:00
Simon Ser
8ebb61cb0e Route user MODE messages to server buffer 2021-11-03 21:44:24 +01:00
Simon Ser
8f90613951 components/buffer-header: add help text for user details
This makes it easier for users new to IRC to figure out what these
things mean. Additionally, it's not possible for a malicious user
to spoof the <abbr> style.
2021-11-03 17:23:32 +01:00
Simon Ser
0888af4a6f Request more messages for event-playback infinite scrolling
When the server supports draft/event-playback, some messages (like
join/part/etc) may be collapsed together. Request more messages to
avoid ending up with a half-filled page.
2021-11-03 16:31:12 +01:00
Simon Ser
08cd94d775 lib/irc: add "fullname" to isMeaningfulRealname 2021-11-02 18:12:18 +01:00
Simon Ser
eec4126562 components/buffer-header: mark unauthenticated users as such 2021-11-02 18:04:53 +01:00
Simon Ser
6acf6d544a components/buffer-header: skip account name if it matches nick 2021-11-02 18:01:07 +01:00
Simon Ser
ac7785aa7f lib/client: fix missing account in WHOX 2021-11-02 17:58:00 +01:00
Simon Ser
85e73d0ee8 Add RPL_WELCOME to server buffer 2021-11-02 15:27:24 +01:00
Simon Ser
483f0c65b1 Add hint in server operators buffer header 2021-11-01 18:45:16 +01:00
Simon Ser
33c3cf3278 Remove unnecessary irc.formatDate call 2021-10-29 16:34:50 +02:00
Simon Ser
40210f8b00 Upgrade http-server 2021-10-29 16:25:58 +02:00
Simon Ser
a1057092e0 state: move in QUIT and NICK update logic 2021-10-23 23:24:11 +02:00
Simon Ser
bf471abb1b Add App.routeMessage
This splits handleMessage into two functions: one decides in which
buffers the message should be appended to, the other performs
message side-effects like auto-join.
2021-10-23 23:01:32 +02:00
Simon Ser
c4a1f38b33 state: process RPL_NAMREPLY atomically
This allows updating the buf.members map only once when receiving
RPL_ENDOFNAMES, instead of repeatedly re-creating it each time a
RPL_NAMREPLY message is received.
2021-10-23 20:05:07 +02:00
Simon Ser
92043ded2c lib/client: generalize pendingWHOIS, store list in ENDOF* messages
This allows processing a list of replies atomically and receiving
the ENDOF* marker.
2021-10-23 20:03:57 +02:00
Simon Ser
b059e034e2 lib/client: rename whoisDB to pendingWHOIS, garbage collect 2021-10-23 19:48:04 +02:00
Simon Ser
49a59077b7 lib/irc: extend parseURL to support flags and skip auth + options 2021-10-20 14:33:16 +02:00
Simon Ser
a313363ed7 gitignore: add Parcel files 2021-10-20 10:55:49 +02:00
Simon Ser
ab2f8092a8 Add minimal Parcel integration
Closes: https://todo.sr.ht/~emersion/gamja/107
2021-10-19 00:50:02 +02:00
Simon Ser
4309cf44d3 Avoid using export * as namespace
This isn't supported by Safari.
2021-10-18 23:59:18 +02:00
Simon Ser
2d032259db Pretty-print RPL_LOGGEDIN and RPL_LOGGEDOUT 2021-10-18 22:11:14 +02:00
Simon Ser
3d09c43a91 Don't add RPL_YOURHOST to server buffer 2021-10-18 22:08:21 +02:00
Simon Ser
e7054eab13 Don't add RPL_SASLSUCCESS to server buffer 2021-10-18 22:05:25 +02:00
Simon Ser
d9f36c82ba Allow bouncers to set NETWORK in ISUPPORT
This allows bouncers to customize the name they appear with.
2021-10-18 19:51:30 +02:00
Simon Ser
12440691c9 Unescape ISUPPORT values
This allows ISUPPORT values to contain spaces.

References: https://github.com/ircdocs/modern-irc/pull/137
2021-10-18 13:29:11 +02:00
Simon Ser
34aea84dde Close buffer tabs on middle click 2021-10-17 19:33:02 +02:00
Simon Ser
a31976586c Fallback to bouncer network host if name is unset 2021-10-15 18:23:56 +02:00
Simon Ser
8bdde589bb lib/irc: "unknown" is not a meaningful realname 2021-10-15 17:44:33 +02:00
Simon Ser
bfef13824e Use ISUPPORT NETWORK if user hasn't specified custom name 2021-10-15 14:05:39 +02:00
Simon Ser
1a8d539c9e Use linkifyjs module 2021-10-14 20:55:55 +02:00
Simon Ser
a120d79585 Handle IRC URLs without channel name 2021-10-13 16:47:01 +02:00
Simon Ser
3562478946 Open dialog to create new network on IRC URL click
If we're running under a bouncer and the user clicks a link with
a server we aren't connected to yet, open the dialog to add a new
network.

References: https://todo.sr.ht/~emersion/gamja/71
2021-10-13 16:40:34 +02:00
Simon Ser
405bc51c26 Handle click on irc:// channel URLs inside buffers
References: https://todo.sr.ht/~emersion/gamja/71
2021-10-13 16:18:59 +02:00
Simon Ser
631f119061 Switch from anchorme to linkifyjs 2021-10-13 15:33:41 +02:00
Simon Ser
a7d3a3940a readme: mention server.ping when server doesn't send PINGs
For instance, soju doesn't send PINGs.
2021-10-12 20:18:29 +02:00
Simon Ser
21a4a71542 Add support for SASL EXTERNAL
Can be useful when the server is using e.g. a cookie for
authentication purposes.
2021-10-12 17:29:56 +02:00
Simon Ser
a890665775 Allow revealing server field with ?server 2021-10-09 13:33:01 +02:00
Simon Ser
a920914b4c Add nick to config.json 2021-10-09 10:45:44 +02:00
Simon Ser
47b12cc5d9 Add autoconnect to config.json 2021-10-09 10:45:44 +02:00
Simon Ser
312a3f812e Don't allow overriding server URL if set in config.json
This has security implications.
2021-10-09 10:34:51 +02:00
Simon Ser
e3e3315125 Inherit from default connectParams in handleConfig
When handleConfig is called, this.state.connectParams will be set
to its default value. Inherit from it so that autoconnect isn't
missing any. If we ever add a new connect parameter, we don't want
an old localStorage to break connect() because it's missing the
param.
2021-10-09 10:10:51 +02:00
Simon Ser
d2ac1e152a Add more type checks for config.json 2021-10-09 09:57:54 +02:00
Simon Ser
8cc61bf577 lib/client: handle MONITOR without value in ISUPPORT 2021-10-07 21:49:54 +02:00
Simon Ser
4577f0a27f components/buffer: pretty-print RPL_CHANNELMODEIS and RPL_CREATIONTIME 2021-10-06 12:12:49 +02:00
Simon Ser
19ee5553f6 components/buffer: add RPL_UMODEIS pretty-printing 2021-10-05 11:22:20 +02:00
30 changed files with 7141 additions and 1227 deletions

19
.build.yml Normal file
View File

@@ -0,0 +1,19 @@
image: alpine/latest
packages:
- npm
- rsync
sources:
- https://git.sr.ht/~emersion/gamja
secrets:
- 5874ac5a-905e-4596-a117-fed1401c60ce # deploy SSH key
tasks:
- setup: |
cd gamja
npm install --include=dev
npm run build
- deploy: |
cd gamja/dist
[ "$(git rev-parse HEAD)" = "$(git rev-parse origin/master)" ] || complete-build
rsync --rsh="ssh -o StrictHostKeyChecking=no" -rP \
--delete --exclude=config.json \
. deploy@emersion.fr:/srv/http/gamja

3
.gitignore vendored
View File

@@ -1,2 +1,5 @@
node_modules
/config.json
.parcel-cache
/dist

View File

@@ -12,6 +12,9 @@ First install dependencies:
npm install --production
Then configure an HTTP server to serve the gamja files. Below are some
server-specific instructions.
### [soju]
Add a WebSocket listener to soju, e.g. `listen wss://127.0.0.1:8080`.
@@ -37,6 +40,10 @@ 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;
@@ -48,19 +55,26 @@ location /socket {
}
```
If you are unable to configure the proxy timeout accordingly, you can set the
`server.ping` option in `config.json` to an interval, in seconds, between which
gamja will send opportunistic pings.
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
Start your IRC WebSocket server, e.g. on port 8080. Then run:
If you don't have an IRC WebSocket server at hand, gamja's development server
can be used. For instance, to run gamja on Libera Chat:
npm install
npm start
npm install --include=dev
npm start -- irc.libera.chat
This will start a development HTTP server for gamja. Connect to it and append
`?server=ws://localhost:8080` to the URL.
See `npm start -- -h` for a list of options.
### Production build
Optionally, [Parcel] can be used to build a minified version of gamja.
npm install --include=dev
npm run build
## Query parameters
@@ -69,6 +83,8 @@ gamja settings can be overridden using URL query 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).
@@ -87,9 +103,14 @@ gamja default settings can be set using a `config.json` file at the root:
"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, and "disabled" to never ask for a password. Defaults to
// "optional".
// 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.
@@ -101,7 +122,7 @@ gamja default settings can be set using a `config.json` file at the root:
## Contributing
Send patches on the [mailing list], report bugs on the [issue tracker]. Discuss
in #soju on Libera Chat.
in [#soju on Libera Chat].
## License
@@ -114,3 +135,6 @@ Copyright (C) 2020 The gamja Contributors
[webircgateway]: https://github.com/kiwiirc/webircgateway
[mailing list]: https://lists.sr.ht/~emersion/public-inbox
[issue tracker]: https://todo.sr.ht/~emersion/gamja
[Parcel]: https://parceljs.org
[IRC URL]: https://datatracker.ietf.org/doc/html/draft-butcher-irc-url-04
[#soju on Libera Chat]: ircs://irc.libera.chat/#soju

View File

@@ -54,19 +54,23 @@ function markServerBufferUnread(app) {
}
const join = {
usage: "<name>",
usage: "<name> [password]",
description: "Join a channel",
execute: (app, args) => {
let channel = args[0];
if (!channel) {
throw new Error("Missing channel name");
}
app.open(channel);
if (args.length > 1) {
app.open(channel, null, args[1]);
} else {
app.open(channel);
}
},
};
const kick = {
usage: "<nick>",
usage: "<nick> [comment]",
description: "Remove a user from the channel",
execute: (app, args) => {
let nick = args[0];
@@ -93,6 +97,17 @@ function givemode(app, args, mode) {
}
export default {
"away": {
usage: "[message]",
description: "Set away message",
execute: (app, args) => {
const params = []
if (args.length) {
params.push(args.join(" "));
}
getActiveClient(app).send({command: "AWAY", params});
},
},
"ban": {
usage: "[nick]",
description: "Ban a user from the channel, or display the current ban list",
@@ -322,10 +337,11 @@ export default {
execute: (app, args) => {
let newRealname = args.join(" ");
let client = getActiveClient(app);
if (!client.enabledCaps["setname"]) {
if (!client.caps.enabled.has("setname")) {
throw new Error("Server doesn't support changing the realname");
}
client.send({ command: "SETNAME", params: [newRealname] });
// TODO: save to local storage
},
},
"stats": {
@@ -381,7 +397,7 @@ export default {
execute: (app, args) => givemode(app, args, "+v"),
},
"who": {
usage: "[<mask> [o]]",
usage: "<mask>",
description: "Retrieve a list of users",
execute: (app, args) => {
getActiveClient(app).send({ command: "WHO", params: args });
@@ -411,4 +427,12 @@ export default {
markServerBufferUnread(app);
},
},
"list": {
usage: "[filter]",
description: "Retrieve a list of channels from a network",
execute: (app, args) => {
getActiveClient(app).send({ command: "LIST", params: args });
markServerBufferUnread(app);
},
},
};

File diff suppressed because it is too large Load Diff

51
components/auth-form.js Normal file
View File

@@ -0,0 +1,51 @@
import { html, Component } from "../lib/index.js";
export default class NetworkForm extends Component {
state = {
username: "",
password: "",
};
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
if (props.username) {
this.state.username = props.username;
}
}
handleChange(event) {
let target = event.target;
let value = target.type == "checkbox" ? target.checked : target.value;
this.setState({ [target.name]: value });
}
handleSubmit(event) {
event.preventDefault();
this.props.onSubmit(this.state.username, this.state.password);
}
render() {
return html`
<form onChange=${this.handleChange} onSubmit=${this.handleSubmit}>
<label>
Username:<br/>
<input type="username" name="username" value=${this.state.username} required/>
</label>
<br/><br/>
<label>
Password:<br/>
<input type="password" name="password" value=${this.state.password} required autofocus/>
</label>
<br/><br/>
<button>Login</button>
</form>
`;
}
}

View File

@@ -21,24 +21,12 @@ function NickStatus(props) {
}
export default function BufferHeader(props) {
function handleCloseClick(event) {
event.preventDefault();
props.onClose();
}
function handleJoinClick(event) {
event.preventDefault();
props.onJoin();
}
function handleAddNetworkClick(event) {
event.preventDefault();
props.onAddNetwork();
}
function handleManageNetworkClick(event) {
event.preventDefault();
props.onManageNetwork();
let fullyConnected = props.server.status === ServerStatus.REGISTERED;
if (props.bouncerNetwork) {
fullyConnected = fullyConnected && props.bouncerNetwork.state === "connected";
}
let description = null, actions = null;
let description = null, actions = [];
switch (props.buffer.type) {
case BufferType.SERVER:
switch (props.server.status) {
@@ -74,56 +62,95 @@ export default function BufferHeader(props) {
break;
}
if (props.isBouncer) {
if (props.server.isupport.get("BOUNCER_NETID")) {
actions = html`
<button
key="join"
onClick=${handleJoinClick}
>Join channel</button>
<button
key="manage"
onClick=${handleManageNetworkClick}
>Manage network</button>
`;
let joinButton = html`
<button
key="join"
onClick=${props.onJoin}
>Join channel</button>
`;
let reconnectButton = html`
<button
key="reconect"
onClick=${props.onReconnect}
>Reconnect</button>
`;
if (props.server.isBouncer) {
if (props.server.bouncerNetID) {
if (fullyConnected) {
actions.push(joinButton);
}
if (props.server.status === ServerStatus.REGISTERED) {
actions.push(html`
<button
key="manage"
onClick=${props.onManageNetwork}
>Manage network</button>
`);
}
} else {
actions = html`
<button
key="add"
onClick=${handleAddNetworkClick}
>Add network</button>
if (fullyConnected) {
actions.push(html`
<button
key="add"
onClick=${props.onAddNetwork}
>Add network</button>
`);
} else if (props.server.status === ServerStatus.DISCONNECTED) {
actions.push(reconnectButton);
}
actions.push(html`
<button
key="disconnect"
class="danger"
onClick=${handleCloseClick}
onClick=${props.onClose}
>Disconnect</button>
`;
`);
}
} else {
actions = html`
<button
key="join"
onClick=${handleJoinClick}
>Join channel</button>
if (fullyConnected) {
actions.push(joinButton);
} else if (props.server.status === ServerStatus.DISCONNECTED) {
actions.push(reconnectButton);
}
actions.push(html`
<button
key="disconnect"
class="danger"
onClick=${handleCloseClick}
onClick=${props.onClose}
>Disconnect</button>
`;
`);
}
break;
case BufferType.CHANNEL:
if (props.buffer.topic) {
description = linkify(stripANSI(props.buffer.topic), props.onChannelClick);
}
actions = html`
<button
key="part"
class="danger"
onClick=${handleCloseClick}
>Leave</button>
`;
if (props.buffer.joined) {
actions.push(html`
<button
key="part"
class="danger"
onClick=${props.onClose}
>Leave</button>
`);
} else {
if (fullyConnected) {
actions.push(html`
<button
key="join"
onClick=${props.onJoin}
>Join</button>
`);
}
actions.push(html`
<button
key="part"
class="danger"
onClick=${props.onClose}
>Close</button>
`);
}
break;
case BufferType.NICK:
if (props.user) {
@@ -144,9 +171,33 @@ export default function BufferHeader(props) {
details.push(`${props.user.username}@${props.user.hostname}`);
}
if (props.user.account) {
details.push(`authenticated as ${props.user.account}`);
let desc = `This user is verified and has logged in to the server with the account ${props.user.account}.`;
let item;
if (props.user.account === props.buffer.name) {
item = "authenticated";
} else {
item = `authenticated as ${props.user.account}`;
}
details.push(html`<abbr title=${desc}>${item}</abbr>`);
} else if (props.server.reliableUserAccounts) {
// If the server supports MONITOR and WHOX, we can faithfully
// keep user.account up-to-date for user queries
let desc = "This user has not been verified and is not logged in.";
details.push(html`<abbr title=${desc}>unauthenticated</abbr>`);
}
if (props.user.operator) {
let desc = "This user is a server operator, they have administrator privileges.";
details.push(html`<abbr title=${desc}>server operator</abbr>`);
}
details = details.map((item, i) => {
if (i === 0) {
return item;
}
return [", ", item];
});
if (details.length > 0) {
details = ["(", details, ")"];
}
details = details.length > 0 ? `(${details.join(", ")})` : null;
description = html`<${NickStatus} status=${status}/> ${realname} ${details}`;
}
@@ -155,7 +206,7 @@ export default function BufferHeader(props) {
<button
key="close"
class="danger"
onClick=${handleCloseClick}
onClick=${props.onClose}
>Close</button>
`;
break;
@@ -163,7 +214,7 @@ export default function BufferHeader(props) {
let name = props.buffer.name;
if (props.buffer.type == BufferType.SERVER) {
name = getServerName(props.server, props.bouncerNetwork, props.isBouncer);
name = getServerName(props.server, props.bouncerNetwork);
}
return html`

View File

@@ -7,10 +7,16 @@ function BufferItem(props) {
event.preventDefault();
props.onClick();
}
function handleMouseDown(event) {
if (event.button === 1) { // middle click
event.preventDefault();
props.onClose();
}
}
let name = props.buffer.name;
if (props.buffer.type == BufferType.SERVER) {
name = getServerName(props.server, props.bouncerNetwork, props.isBouncer);
name = getServerName(props.server, props.bouncerNetwork);
}
let classes = ["type-" + props.buffer.type];
@@ -23,7 +29,11 @@ function BufferItem(props) {
return html`
<li class="${classes.join(" ")}">
<a href=${getBufferURL(props.buffer)} onClick=${handleClick}>${name}</a>
<a
href=${getBufferURL(props.buffer)}
onClick=${handleClick}
onMouseDown=${handleMouseDown}
>${name}</a>
</li>
`;
}
@@ -34,9 +44,8 @@ export default function BufferList(props) {
let server = props.servers.get(buf.server);
let bouncerNetwork = null;
let bouncerNetID = server.isupport.get("BOUNCER_NETID");
if (bouncerNetID) {
bouncerNetwork = props.bouncerNetworks.get(bouncerNetID);
if (server.bouncerNetID) {
bouncerNetwork = props.bouncerNetworks.get(server.bouncerNetID);
}
return html`
@@ -44,9 +53,9 @@ export default function BufferList(props) {
key=${buf.id}
buffer=${buf}
server=${server}
isBouncer=${props.isBouncer}
bouncerNetwork=${bouncerNetwork}
onClick=${() => props.onBufferClick(buf)}
onClose=${() => props.onBufferClose(buf)}
active=${props.activeBuffer == buf.id}
/>
`;

View File

@@ -2,7 +2,8 @@ import { html, Component } from "../lib/index.js";
import linkify from "../lib/linkify.js";
import * as irc from "../lib/irc.js";
import { strip as stripANSI } from "../lib/ansi.js";
import { BufferType, getNickURL, getChannelURL, getMessageURL } from "../state.js";
import { BufferType, ServerStatus, getNickURL, getChannelURL, getMessageURL, isMessageBeforeReceipt } from "../state.js";
import * as store from "../store.js";
import Membership from "./membership.js";
function djb2(s) {
@@ -76,18 +77,16 @@ class LogLine extends Component {
let onNickClick = this.props.onNickClick;
let onChannelClick = this.props.onChannelClick;
let onVerifyClick = this.props.onVerifyClick;
function createNick(nick) {
return html`
<${Nick} nick=${nick} onClick=${() => onNickClick(nick)}/>
`;
}
function createChannel(channel) {
function onClick(event) {
event.preventDefault();
onChannelClick(channel);
}
return html`
<a href=${getChannelURL(channel)} onClick=${onClick}>
<a href=${getChannelURL(channel)} onClick=${onChannelClick}>
${channel}
</a>
`;
@@ -95,11 +94,11 @@ class LogLine extends Component {
let lineClass = "";
let content;
let invitee;
let invitee, target, account;
switch (msg.command) {
case "NOTICE":
case "PRIVMSG":
let target = msg.params[0];
target = msg.params[0];
let text = msg.params[1];
let ctcp = irc.parseCTCP(msg);
@@ -122,7 +121,7 @@ class LogLine extends Component {
}
let status = null;
let allowedPrefixes = server.isupport.get("STATUSMSG");
let allowedPrefixes = server.statusMsg;
if (target !== buf.name && allowedPrefixes) {
let parts = irc.parseTargetPrefix(target, allowedPrefixes);
if (parts.name === buf.name) {
@@ -161,9 +160,14 @@ class LogLine extends Component {
`;
break;
case "MODE":
target = msg.params[0];
content = html`
* ${createNick(msg.prefix.name)} sets mode ${msg.params.slice(1).join(" ")}
`;
// TODO: case-mapping
if (buf.name !== target) {
content = html`${content} on ${target}`;
}
break;
case "TOPIC":
let topic = msg.params[1];
@@ -186,6 +190,10 @@ class LogLine extends Component {
`;
}
break;
case irc.RPL_WELCOME:
let nick = msg.params[0];
content = html`Connected to server, your nickname is ${nick}`;
break;
case irc.RPL_INVITING:
invitee = msg.params[1];
content = html`${createNick(invitee)} has been invited to the channel`;
@@ -194,11 +202,59 @@ class LogLine extends Component {
lineClass = "motd";
content = linkify(stripANSI(msg.params[1]), onChannelClick);
break;
case irc.RPL_LOGGEDIN:
account = msg.params[2];
content = html`You are now authenticated as ${account}`;
break;
case irc.RPL_LOGGEDOUT:
content = html`You are now unauthenticated`;
break;
case "REGISTER":
account = msg.params[1];
let reason = msg.params[2];
function handleVerifyClick(event) {
event.preventDefault();
onVerifyClick(account, reason);
}
switch (msg.params[0]) {
case "SUCCESS":
content = html`A new account has been created, you are now authenticated as ${account}`;
break;
case "VERIFICATION_REQUIRED":
content = html`A new account has been created, but you need to <a href="#" onClick=${handleVerifyClick}>verify it</a>: ${linkify(reason)}`;
break;
}
break;
case "VERIFY":
account = msg.params[1];
content = html`The new account has been verified, you are now authenticated as ${account}`;
break;
case irc.RPL_UMODEIS:
let mode = msg.params[1];
if (mode) {
content = html`Your user mode is ${mode}`;
} else {
content = html`You have no user mode`;
}
break;
case irc.RPL_CHANNELMODEIS:
content = html`Channel mode is ${msg.params.slice(2).join(" ")}`;
break;
case irc.RPL_CREATIONTIME:
let date = new Date(parseInt(msg.params[2], 10) * 1000);
content = html`Channel was created on ${date.toLocaleString()}`;
break;
default:
if (irc.isError(msg.command) && msg.command != irc.ERR_NOMOTD) {
lineClass = "error";
}
content = html`${msg.command} ${msg.params.join(" ")}`;
content = html`${msg.command} ${linkify(msg.params.join(" "))}`;
}
if (!content) {
return null;
}
return html`
@@ -266,7 +322,9 @@ class FoldGroup extends Component {
return;
}
let plural = byCommand[cmd].length > 1;
let nicks = new Set(byCommand[cmd].map((msg) => msg.prefix.name));
let plural = nicks.size > 1;
let action;
switch (cmd) {
case "JOIN":
@@ -286,9 +344,7 @@ class FoldGroup extends Component {
content.push(", ");
}
let nicks = byCommand[cmd].map((msg) => msg.prefix.name);
content.push(createNickList(nicks, createNick));
content.push(createNickList([...nicks], createNick));
content.push(" " + action);
});
@@ -384,6 +440,82 @@ class NotificationNagger extends Component {
}
}
class ProtocolHandlerNagger extends Component {
state = { nag: true };
constructor(props) {
super(props);
this.handleClick = this.handleClick.bind(this);
this.state.nag = !store.naggedProtocolHandler.load();
}
handleClick(event) {
event.preventDefault();
let url = window.location.origin + window.location.pathname + "?open=%s";
try {
navigator.registerProtocolHandler("irc", url);
navigator.registerProtocolHandler("ircs", url);
} catch (err) {
console.error("Failed to register protocol handler: ", err);
}
store.naggedProtocolHandler.put(true);
this.setState({ nag: false });
}
render() {
if (!navigator.registerProtocolHandler || !this.state.nag) {
return null;
}
let name = this.props.bouncerName || "this bouncer";
return html`
<div class="logline">
<${Timestamp}/>
${" "}
<a href="#" onClick=${this.handleClick}>Register our protocol handler</a> to open IRC links with ${name}
</div>
`;
}
}
function AccountNagger({ server, onAuthClick, onRegisterClick }) {
let accDesc = "an account on this server";
if (server.name) {
accDesc = "a " + server.name + " account";
}
function handleAuthClick(event) {
event.preventDefault();
onAuthClick();
}
function handleRegisterClick(event) {
event.preventDefault();
onRegisterClick();
}
let msg = [html`
You are unauthenticated on this server,
${" "}
<a href="#" onClick=${handleAuthClick}>login</a>
${" "}
`];
if (server.supportsAccountRegistration) {
msg.push(html`or <a href="#" onClick=${handleRegisterClick}>register</a> ${accDesc}`);
} else {
msg.push(html`if you have ${accDesc}`);
}
return html`
<div class="logline">
<${Timestamp}/> ${msg}
</div>
`;
}
class DateSeparator extends Component {
constructor(props) {
super(props);
@@ -395,10 +527,7 @@ class DateSeparator extends Component {
render() {
let date = this.props.date;
let YYYY = date.getFullYear().toString().padStart(4, "0");
let MM = (date.getMonth() + 1).toString().padStart(2, "0");
let DD = date.getDate().toString().padStart(2, "0");
let text = `${YYYY}-${MM}-${DD}`;
let text = date.toLocaleDateString([], { year: "numeric", month: "2-digit", day: "2-digit" });
return html`
<div class="separator date-separator">
${text}
@@ -422,18 +551,35 @@ export default class Buffer extends Component {
render() {
let buf = this.props.buffer;
let server = this.props.server;
if (!buf) {
return null;
}
let server = this.props.server;
let bouncerNetwork = this.props.bouncerNetwork;
let serverName = server.name;
let children = [];
if (buf.type == BufferType.SERVER) {
children.push(html`<${NotificationNagger}/>`);
}
if (buf.type == BufferType.SERVER && server.isBouncer && !server.bouncerNetID) {
children.push(html`<${ProtocolHandlerNagger} bouncerName=${serverName}/>`);
}
if (buf.type == BufferType.SERVER && server.status == ServerStatus.REGISTERED && server.supportsSASLPlain && !server.account) {
children.push(html`
<${AccountNagger}
server=${server}
onAuthClick=${this.props.onAuthClick}
onRegisterClick=${this.props.onRegisterClick}
/>
`);
}
let onChannelClick = this.props.onChannelClick;
let onNickClick = this.props.onNickClick;
let onVerifyClick = this.props.onVerifyClick;
function createLogLine(msg) {
return html`
<${LogLine}
@@ -443,6 +589,7 @@ export default class Buffer extends Component {
server=${server}
onChannelClick=${onChannelClick}
onNickClick=${onNickClick}
onVerifyClick=${onVerifyClick}
/>
`;
}
@@ -486,7 +633,7 @@ export default class Buffer extends Component {
buf.messages.forEach((msg) => {
let sep = [];
if (!hasUnreadSeparator && buf.type != BufferType.SERVER && buf.prevReadReceipt && msg.tags.time > buf.prevReadReceipt.time) {
if (!hasUnreadSeparator && buf.type != BufferType.SERVER && !isMessageBeforeReceipt(msg, buf.prevReadReceipt)) {
sep.push(html`<${UnreadSeparator} key="unread"/>`);
hasUnreadSeparator = true;
}

View File

@@ -143,7 +143,7 @@ export default class Composer extends Component {
return;
}
if (this.props.readOnly && event.key !== "/") {
if (this.props.readOnly || (this.props.commandOnly && event.key !== "/")) {
return;
}
@@ -167,7 +167,7 @@ export default class Composer extends Component {
return;
}
let text = event.clipboardData.getData('text');
let text = event.clipboardData.getData("text");
event.preventDefault();
event.stopImmediatePropagation();
@@ -201,6 +201,11 @@ export default class Composer extends Component {
className = "read-only";
}
let placeholder = "Type a message";
if (this.props.commandOnly) {
placeholder = "Type a command (see /help)";
}
return html`
<form
id="composer"
@@ -214,7 +219,7 @@ export default class Composer extends Component {
ref=${this.textInput}
value=${this.state.text}
autocomplete="off"
placeholder="Type a message"
placeholder=${placeholder}
enterkeyhint="send"
onKeyDown=${this.handleInputKeyDown}
/>

View File

@@ -1,4 +1,5 @@
import { html, Component, createRef } from "../lib/index.js";
import linkify from "../lib/linkify.js";
export default class ConnectForm extends Component {
state = {
@@ -9,7 +10,7 @@ export default class ConnectForm extends Component {
rememberMe: false,
username: "",
realname: "",
autojoin: "",
autojoin: true,
};
nickInput = createRef();
@@ -27,7 +28,6 @@ export default class ConnectForm extends Component {
rememberMe: props.params.autoconnect || false,
username: props.params.username || "",
realname: props.params.realname || "",
autojoin: (props.params.autojoin || []).join(","),
};
}
}
@@ -61,15 +61,13 @@ export default class ConnectForm extends Component {
username: params.username || params.nick,
password: this.state.password,
};
} else if (this.props.auth === "external") {
params.saslExternal = true;
}
this.state.autojoin.split(",").forEach(function(ch) {
ch = ch.trim();
if (!ch) {
return;
}
params.autojoin.push(ch);
});
if (this.state.autojoin) {
params.autojoin = this.props.params.autojoin || [];
}
this.props.onSubmit(params);
}
@@ -107,12 +105,12 @@ export default class ConnectForm extends Component {
`;
} else if (this.props.error) {
status = html`
<p class="error-text">${this.props.error}</p>
<p class="error-text">${linkify(this.props.error)}</p>
`;
}
let auth = null;
if (this.props.auth !== "disabled") {
if (this.props.auth !== "disabled" && this.props.auth !== "external") {
auth = html`
<label>
Password:<br/>
@@ -129,22 +127,22 @@ export default class ConnectForm extends Component {
`;
}
let autojoin = html`
<label>
Auto-join channels:<br/>
<input
type="text"
name="autojoin"
value=${this.state.autojoin}
disabled=${disabled}
placeholder="Comma-separated list of channels"
/>
</label>
<br/>
`;
// Show autojoin field in advanced options, except if it's pre-filled
let isAutojoinAdvanced = (this.props.params.autojoin || []).length === 0;
let autojoin = null;
let channels = this.props.params.autojoin || [];
if (channels.length > 0) {
let s = channels.length > 1 ? "s" : "";
autojoin = html`
<label>
<input
type="checkbox"
name="autojoin"
checked=${this.state.autojoin}
/>
Auto-join channel${s} <strong>${channels.join(", ")}</strong>
</label>
<br/><br/>
`;
}
return html`
<form onChange=${this.handleChange} onSubmit=${this.handleSubmit}>
@@ -165,7 +163,7 @@ export default class ConnectForm extends Component {
${auth}
${!isAutojoinAdvanced ? [autojoin, html`<br/>`] : null}
${autojoin}
<label>
<input
@@ -212,7 +210,7 @@ export default class ConnectForm extends Component {
<label>
Server password:<br/>
<input
type="text"
type="password"
name="pass"
value=${this.state.pass}
disabled=${disabled}
@@ -220,8 +218,6 @@ export default class ConnectForm extends Component {
/>
</label>
<br/><br/>
${isAutojoinAdvanced ? autojoin : null}
</details>
<br/>

View File

@@ -26,27 +26,27 @@ function KeyBindingsHelp() {
`;
});
return html`
<dl>
<dt><kbd>/</kbd></dt>
<dd>Start writing a command</dd>
if (!window.matchMedia("(pointer: none)").matches) {
l.push(html`
<dt><strong>Middle mouse click</strong></dt>
<dd>Close buffer</dd>
`);
}
${l}
</dl>
`;
return html`<dl>${l}</dl>`;
}
function CommandsHelp() {
let l = Object.keys(commands).map((name) => {
let cmd = commands[name];
let usage = "/" + name;
let usage = [html`<strong>/${name}</strong>`];
if (cmd.usage) {
usage += " " + cmd.usage;
usage.push(" " + cmd.usage);
}
return html`
<dt><strong><code>${usage}</code></strong></dt>
<dt><code>${usage}</code></dt>
<dd>${cmd.description}</dd>
`;
});

View File

@@ -10,6 +10,10 @@ export default class JoinForm extends Component {
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
if (props.channel) {
this.state.channel = props.channel;
}
}
handleChange(event) {

View File

@@ -13,7 +13,8 @@ class MemberItem extends Component {
shouldComponentUpdate(nextProps) {
return this.props.nick !== nextProps.nick
|| this.props.membership !== nextProps.membership;
|| this.props.membership !== nextProps.membership
|| this.props.user !== nextProps.user;
}
handleClick(event) {
@@ -43,6 +44,7 @@ class MemberItem extends Component {
let title = null;
let user = this.props.user;
let classes = ["nick"];
if (user) {
let mask = "";
if (user.username && user.hostname) {
@@ -61,13 +63,18 @@ class MemberItem extends Component {
if (user.account) {
title += `\nAuthenticated as ${user.account}`;
}
if (user.away) {
classes.push("away");
title += "\nAway";
}
}
return html`
<li>
<a
href=${getNickURL(this.props.nick)}
class="nick"
class=${classes.join(" ")}
title=${title}
onClick=${this.handleClick}
>
@@ -99,7 +106,8 @@ function sortMembers(a, b) {
export default class MemberList extends Component {
shouldComponentUpdate(nextProps) {
return this.props.members !== nextProps.members;
return this.props.members !== nextProps.members
|| this.props.users !== nextProps.users;
}
render() {

View File

@@ -14,7 +14,7 @@ export default class NetworkForm extends Component {
prevParams = null;
state = {
...defaultParams,
isNew: true,
autojoin: true,
};
constructor(props) {
@@ -25,8 +25,6 @@ export default class NetworkForm extends Component {
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.state.isNew = !props.params;
if (props.params) {
Object.keys(defaultParams).forEach((k) => {
if (props.params[k] !== undefined) {
@@ -48,18 +46,22 @@ export default class NetworkForm extends Component {
let params = {};
Object.keys(defaultParams).forEach((k) => {
if (this.prevParams[k] == this.state[k]) {
if (!this.props.isNew && this.prevParams[k] == this.state[k]) {
return;
}
if (this.props.isNew && defaultParams[k] == this.state[k]) {
return;
}
params[k] = this.state[k];
});
this.props.onSubmit(params);
let autojoin = this.state.autojoin ? this.props.autojoin : null;
this.props.onSubmit(params, autojoin);
}
render() {
let removeNetwork = null;
if (!this.state.isNew) {
if (!this.props.isNew) {
removeNetwork = html`
<button type="button" class="danger" onClick=${() => this.props.onRemove()}>
Remove network
@@ -67,6 +69,21 @@ export default class NetworkForm extends Component {
`;
}
let autojoin = null;
if (this.props.autojoin) {
autojoin = html`
<label>
<input
type="checkbox"
name="autojoin"
checked=${this.state.autojoin}
/>
Auto-join channel <strong>${this.props.autojoin}</strong>
</label>
<br/><br/>
`;
}
return html`
<form onChange=${this.handleChange} onSubmit=${this.handleSubmit}>
<label>
@@ -75,6 +92,8 @@ export default class NetworkForm extends Component {
</label>
<br/><br/>
${autojoin}
<details>
<summary role="button">Advanced options</summary>
@@ -121,7 +140,7 @@ export default class NetworkForm extends Component {
${removeNetwork}
${" "}
<button>
${this.state.isNew ? "Add network" : "Save network"}
${this.props.isNew ? "Add network" : "Save network"}
</button>
</form>
`;

View File

@@ -0,0 +1,54 @@
import { html, Component } from "../lib/index.js";
export default class RegisterForm extends Component {
state = {
email: "",
password: "",
};
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleChange(event) {
let target = event.target;
let value = target.type == "checkbox" ? target.checked : target.value;
this.setState({ [target.name]: value });
}
handleSubmit(event) {
event.preventDefault();
this.props.onSubmit(this.state.email, this.state.password);
}
render() {
return html`
<form onChange=${this.handleChange} onSubmit=${this.handleSubmit}>
<label>
E-mail:<br/>
<input
type="email"
name="email"
value=${this.state.email}
required=${this.props.emailRequired}
placeholder=${this.props.emailRequired ? null : "(optional)"}
autofocus
/>
</label>
<br/><br/>
<label>
Password:<br/>
<input type="password" name="password" value=${this.state.password} required/>
</label>
<br/><br/>
<button>Register</button>
</form>
`;
}
}

45
components/verify-form.js Normal file
View File

@@ -0,0 +1,45 @@
import { html, Component } from "../lib/index.js";
import linkify from "../lib/linkify.js";
export default class RegisterForm extends Component {
state = {
code: "",
};
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleChange(event) {
let target = event.target;
let value = target.type == "checkbox" ? target.checked : target.value;
this.setState({ [target.name]: value });
}
handleSubmit(event) {
event.preventDefault();
this.props.onSubmit(this.state.code);
}
render() {
return html`
<form onChange=${this.handleChange} 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>${linkify(this.props.message)}</p>
<label>
Verification code:<br/>
<input type="text" name="code" value=${this.state.code} required autofocus autocomplete="off"/>
</label>
<br/><br/>
<button>Verify account</button>
</form>
`;
}
}

78
dev-server.js Normal file
View File

@@ -0,0 +1,78 @@
import * as http from "http";
import * as tls from "tls";
import split from "split";
import { Server as StaticServer } from "node-static";
import { WebSocketServer } from "ws";
const WS_BAD_GATEWAY = 1014;
const usage = `usage: [options...] [host]
Starts an HTTP server delivering static files. If [host] is specified, the
server will proxy WebSocket connections to the specified remote IRC server.
Options:
-p <port> Listening port (default: 8080)
-h Show help message
`;
let localPort = 8080;
let remoteHost;
let remotePort = 6697;
let args = process.argv.slice(2);
while (args.length > 0 && args[0].startsWith("-")) {
switch (args[0]) {
case "-p":
localPort = parseInt(args[1], 10);
args = args.slice(2);
break;
default:
console.log(usage);
process.exit(args[0] === "-h" ? 0 : 1);
}
}
remoteHost = args[0];
let staticServer = new StaticServer(".");
let server = http.createServer((req, res) => {
staticServer.serve(req, res);
});
if (remoteHost) {
let wsServer = new WebSocketServer({ server });
wsServer.on("connection", (ws) => {
let client = tls.connect(remotePort, remoteHost, {
ALPNProtocols: ["irc"],
});
ws.on("message", (data) => {
client.write(data.toString() + "\r\n");
});
ws.on("close", () => {
client.destroy();
});
client.pipe(split()).on("data", (data) => {
ws.send(data.toString());
});
client.on("end", () => {
ws.close();
});
client.on("error", () => {
ws.close(WS_BAD_GATEWAY);
});
});
}
server.listen(localPort, "localhost");
let msg = "HTTP server listening on http://localhost:" + localPort;
if (remoteHost) {
msg += " and proxying WebSockets to " + remoteHost;
}
console.log(msg);

View File

@@ -2,20 +2,16 @@
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; frame-src 'none'; object-src 'none'; connect-src *;">
<title>gamja IRC client</title>
<link rel="stylesheet" href="./style.css">
<script type="module" src="./main.js"></script>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="manifest" href="manifest.json">
</head>
<body>
<noscript>
<p>Unfortunately gamja requires JavaScript. Please enable it!</p>
<p>This application requires JavaScript. Please enable it!</p>
</noscript>
<script type="module">
import { html, render } from "./lib/index.js";
import App from "./components/app.js";
render(html`<${App}/>`, document.body);
</script>
</body>
</html>

View File

@@ -1,4 +1,4 @@
import { ReceiptType, Unread, BufferType, SERVER_BUFFER } from "./state.js";
import { ReceiptType, Unread, BufferType, SERVER_BUFFER, receiptFromMessage } from "./state.js";
function getSiblingBuffer(buffers, bufID, delta) {
let bufList = Array.from(buffers.values());
@@ -19,22 +19,24 @@ export const keybindings = [
app.setState((state) => {
let buffers = new Map();
state.buffers.forEach((buf) => {
if (buf.messages.length > 0) {
let lastMsg = buf.messages[buf.messages.length - 1];
app.setReceipt(buf.name, ReceiptType.READ, lastMsg);
}
buffers.set(buf.id, {
...buf,
unread: Unread.NONE,
prevReadReceipt: null,
});
let receipts = {};
if (buf.messages.length > 0) {
let lastMsg = buf.messages[buf.messages.length - 1];
receipts[ReceiptType.READ] = receiptFromMessage(lastMsg);
}
let client = app.clients.get(buf.server);
app.bufferStore.put({
name: buf.name,
server: client.params,
unread: Unread.NONE,
receipts,
});
});
return { buffers };

View File

@@ -13,16 +13,21 @@ const permanentCaps = [
"labeled-response",
"message-tags",
"multi-prefix",
"sasl",
"server-time",
"setname",
"draft/account-registration",
"draft/chathistory",
"draft/event-playback",
"draft/extended-monitor",
"soju.im/bouncer-networks",
"soju.im/read",
];
const RECONNECT_DELAY_SEC = 10;
const RECONNECT_MIN_DELAY_MSEC = 10 * 1000; // 10s
const RECONNECT_MAX_DELAY_MSEC = 10 * 60 * 1000; // 10min
// WebSocket status codes
// https://www.rfc-editor.org/rfc/rfc6455.html#section-7.4.1
@@ -46,6 +51,53 @@ const WHOX_FIELDS = {
let lastLabel = 0;
let lastWhoxToken = 0;
class IRCError extends Error {
constructor(msg) {
let text;
if (msg.params.length > 0) {
// IRC errors have a human-readable message as last param
text = msg.params[msg.params.length - 1];
} else {
text = `unknown error (${msg.command})`;
}
super(text);
this.msg = msg;
}
}
/**
* Implements a simple exponential backoff.
*/
class Backoff {
n = 0;
constructor(min, max) {
this.min = min;
this.max = max;
}
reset() {
this.n = 0;
}
next() {
if (this.n === 0) {
this.n = 1;
return this.min;
}
let dur = this.n * this.min;
if (dur > this.max) {
dur = this.max;
} else {
this.n *= 2;
}
return dur;
}
}
export default class Client extends EventTarget {
static Status = {
DISCONNECTED: "disconnected",
@@ -57,9 +109,9 @@ export default class Client extends EventTarget {
status = Client.Status.DISCONNECTED;
serverPrefix = { name: "*" };
nick = null;
availableCaps = {};
enabledCaps = {};
isupport = new Map();
supportsCap = false;
caps = new irc.CapRegistry();
isupport = new irc.Isupport();
ws = null;
params = {
@@ -69,16 +121,23 @@ export default class Client extends EventTarget {
nick: null,
pass: null,
saslPlain: null,
saslExternal: false,
bouncerNetwork: null,
ping: 0,
};
debug = false;
batches = new Map();
autoReconnect = true;
reconnectTimeoutID = null;
reconnectBackoff = new Backoff(RECONNECT_MIN_DELAY_MSEC, RECONNECT_MAX_DELAY_MSEC);
pingIntervalID = null;
pendingHistory = Promise.resolve(null);
pendingCmds = {
WHO: Promise.resolve(null),
CHATHISTORY: Promise.resolve(null),
};
cm = irc.CaseMapping.RFC1459;
monitored = new irc.CaseMapMap(null, irc.CaseMapping.RFC1459);
whoisDB = new irc.CaseMapMap(null, irc.CaseMapping.RFC1459);
pendingLists = new irc.CaseMapMap(null, irc.CaseMapping.RFC1459);
whoxQueries = new Map();
constructor(params) {
@@ -102,30 +161,39 @@ export default class Client extends EventTarget {
} catch (err) {
console.error("Failed to create connection:", err);
setTimeout(() => {
this.dispatchEvent(new CustomEvent("error", { detail: "Failed to create connection: " + err }));
this.dispatchError(new Error("Failed to create connection", { cause: err }));
this.setStatus(Client.Status.DISCONNECTED);
}, 0);
return;
}
this.ws.addEventListener("open", this.handleOpen.bind(this));
this.ws.addEventListener("message", this.handleMessage.bind(this));
this.ws.addEventListener("message", (event) => {
try {
this.handleMessage(event);
} catch (err) {
this.dispatchError(err);
this.disconnect();
}
});
this.ws.addEventListener("close", (event) => {
console.log("Connection closed (code: " + event.code + ")");
if (event.code !== NORMAL_CLOSURE && event.code !== GOING_AWAY) {
this.dispatchEvent(new CustomEvent("error", { detail: "Connection error" }));
this.dispatchError(new Error("Connection error"));
}
this.ws = null;
this.setStatus(Client.Status.DISCONNECTED);
this.nick = null;
this.serverPrefix = null;
this.availableCaps = {};
this.enabledCaps = {};
this.caps = new irc.CapRegistry();
this.batches = new Map();
this.pendingHistory = Promise.resolve(null);
this.isupport = new Map();
Object.keys(this.pendingCmds).forEach((k) => {
this.pendingCmds[k] = Promise.resolve(null);
});
this.isupport = new irc.Isupport();
this.monitored = new irc.CaseMapMap(null, irc.CaseMapping.RFC1459);
if (this.autoReconnect) {
@@ -137,11 +205,12 @@ export default class Client extends EventTarget {
};
window.addEventListener("online", handleOnline);
} else {
console.info("Reconnecting to server in " + RECONNECT_DELAY_SEC + " seconds");
let delay = this.reconnectBackoff.next();
console.info("Reconnecting to server in " + (delay / 1000) + " seconds");
clearTimeout(this.reconnectTimeoutID);
this.reconnectTimeoutID = setTimeout(() => {
this.reconnect();
}, RECONNECT_DELAY_SEC * 1000);
}, delay);
}
}
});
@@ -168,10 +237,17 @@ export default class Client extends EventTarget {
this.dispatchEvent(new CustomEvent("status"));
}
dispatchError(err) {
this.dispatchEvent(new CustomEvent("error", { detail: err }));
}
handleOpen() {
console.log("Connection opened");
this.setStatus(Client.Status.REGISTERING);
this.reconnectBackoff.reset();
this.setPingInterval(this.params.ping);
this.nick = this.params.nick;
this.send({ command: "CAP", params: ["LS", "302"] });
@@ -185,6 +261,20 @@ export default class Client extends EventTarget {
});
}
pushPendingList(k, msg) {
let l = this.pendingLists.get(k);
if (!l) {
l = [];
this.pendingLists.set(k, l);
}
l.push(msg);
}
endPendingList(k, msg) {
msg.list = this.pendingLists.get(k) || [];
this.pendingLists.delete(k);
}
handleMessage(event) {
if (typeof event.data !== "string") {
console.error("Received unsupported data type:", event.data);
@@ -193,7 +283,9 @@ export default class Client extends EventTarget {
}
let msg = irc.parseMessage(event.data);
console.debug("Received:", msg);
if (this.debug) {
console.debug("Received:", msg);
}
// If the prefix is missing, assume it's coming from the server on the
// other end of the connection
@@ -210,10 +302,11 @@ export default class Client extends EventTarget {
}
let deleteBatch = null;
let k;
switch (msg.command) {
case irc.RPL_WELCOME:
if (this.params.saslPlain && this.availableCaps["sasl"] === undefined) {
this.dispatchEvent(new CustomEvent("error", { detail: "Server doesn't support SASL PLAIN" }));
if (this.params.saslPlain && !this.supportsCap) {
this.dispatchError(new Error("Server doesn't support SASL PLAIN"));
this.disconnect();
return;
}
@@ -221,34 +314,42 @@ export default class Client extends EventTarget {
if (msg.prefix) {
this.serverPrefix = msg.prefix;
}
this.nick = msg.params[0];
console.log("Registration complete");
this.setStatus(Client.Status.REGISTERED);
break;
case irc.RPL_ISUPPORT:
let prevMaxMonitorTargets = this.isupport.monitor();
let tokens = msg.params.slice(1, -1);
let changed = irc.parseISUPPORT(tokens, this.isupport);
if (changed.indexOf("CASEMAPPING") >= 0) {
this.setCaseMapping(this.isupport.get("CASEMAPPING"));
}
if (changed.indexOf("MONITOR") >= 0 && this.isupport.has("MONITOR") && this.monitored.size > 0) {
let targets = Array.from(this.monitored.keys()).slice(0, this.maxMonitorTargets());
this.isupport.parse(tokens);
this.updateCaseMapping();
let maxMonitorTargets = this.isupport.monitor();
if (prevMaxMonitorTargets === 0 && this.monitored.size > 0 && maxMonitorTargets > 0) {
let targets = Array.from(this.monitored.keys()).slice(0, maxMonitorTargets);
this.send({ command: "MONITOR", params: ["+", targets.join(",")] });
}
break;
case irc.RPL_ENDOFMOTD:
case irc.ERR_NOMOTD:
// These messages are used to indicate the end of the ISUPPORT list
if (!this.isupport.has("CASEMAPPING")) {
if (!this.isupport.raw.has("CASEMAPPING")) {
// Server didn't send any CASEMAPPING token, assume RFC 1459
this.setCaseMapping("rfc1459");
this.updateCaseMapping();
}
break;
case "CAP":
this.handleCap(msg);
break;
case "AUTHENTICATE":
this.handleAuthenticate(msg);
// Both PLAIN and EXTERNAL expect an empty challenge
let challengeStr = msg.params[0];
if (challengeStr != "+") {
this.dispatchError(new Error("Expected an empty challenge, got: " + challengeStr));
this.send({ command: "AUTHENTICATE", params: ["*"] });
}
break;
case irc.RPL_LOGGEDIN:
console.log("Logged in");
@@ -256,41 +357,35 @@ export default class Client extends EventTarget {
case irc.RPL_LOGGEDOUT:
console.log("Logged out");
break;
case irc.RPL_SASLSUCCESS:
console.log("SASL authentication success");
if (this.status != Client.Status.REGISTERED) {
if (this.enabledCaps["soju.im/bouncer-networks"] && this.params.bouncerNetwork) {
this.send({ command: "BOUNCER", params: ["BIND", this.params.bouncerNetwork] });
}
this.send({ command: "CAP", params: ["END"] });
}
case irc.RPL_NAMREPLY:
this.pushPendingList("NAMES " + msg.params[2], msg);
break;
case irc.RPL_ENDOFNAMES:
this.endPendingList("NAMES " + msg.params[1], msg);
break;
case irc.RPL_WHOISUSER:
case irc.RPL_WHOISSERVER:
case irc.RPL_WHOISOPERATOR:
case irc.RPL_WHOISIDLE:
case irc.RPL_WHOISCHANNELS:
case irc.RPL_ENDOFWHOIS:
let nick = msg.params[1];
if (!this.whoisDB.has(nick)) {
this.whoisDB.set(nick, {});
}
this.whoisDB.get(nick)[msg.command] = msg;
this.pushPendingList("WHOIS " + msg.params[1], msg);
break;
case irc.ERR_NICKLOCKED:
case irc.ERR_SASLFAIL:
case irc.ERR_SASLTOOLONG:
case irc.ERR_SASLABORTED:
case irc.ERR_SASLALREADY:
this.dispatchEvent(new CustomEvent("error", { detail: "SASL error (" + msg.command + "): " + msg.params[1] }));
this.disconnect();
case irc.RPL_ENDOFWHOIS:
this.endPendingList("WHOIS " + msg.params[1], msg);
break;
case irc.RPL_WHOREPLY:
case irc.RPL_WHOSPCRPL:
this.pushPendingList("WHO", msg);
break;
case irc.RPL_ENDOFWHO:
this.endPendingList("WHO", msg);
break;
case "PING":
this.send({ command: "PONG", params: [msg.params[0]] });
break;
case "NICK":
let newNick = msg.params[0];
if (msg.prefix.name == this.nick) {
if (this.isMyNick(msg.prefix.name)) {
this.nick = newNick;
}
break;
@@ -311,7 +406,7 @@ export default class Client extends EventTarget {
}
break;
case "ERROR":
this.dispatchEvent(new CustomEvent("error", { detail: "Fatal IRC error: " + msg.params[0] }));
this.dispatchError(new IRCError(msg));
this.disconnect();
break;
case irc.ERR_PASSWDMISMATCH:
@@ -321,18 +416,26 @@ export default class Client extends EventTarget {
case irc.ERR_UNAVAILRESOURCE:
case irc.ERR_NOPERMFORHOST:
case irc.ERR_YOUREBANNEDCREEP:
this.dispatchEvent(new CustomEvent("error", { detail: "Error (" + msg.command + "): " + msg.params[msg.params.length - 1] }));
this.dispatchError(new IRCError(msg));
if (this.status != Client.Status.REGISTERED) {
this.disconnect();
}
break;
case "FAIL":
if (this.status === Client.Status.REGISTERED) {
break;
}
let reason = msg.params[msg.params.length - 1];
if (msg.params[0] === "BOUNCER" && msg.params[2] === "BIND") {
this.dispatchEvent(new CustomEvent("error", {
detail: "Failed to bind to bouncer network: " + msg.params[3],
this.dispatchError(new Error("Failed to bind to bouncer network", {
cause: new IRCError(msg),
}));
this.disconnect();
}
if (msg.params[1] === "ACCOUNT_REQUIRED") {
this.dispatchError(new IRCError(msg));
this.disconnect();
}
break;
}
@@ -347,11 +450,48 @@ export default class Client extends EventTarget {
}
}
authenticate(mechanism, params) {
if (!this.supportsSASL(mechanism)) {
throw new Error(`${mechanism} authentication not supported by the server`);
}
console.log(`Starting SASL ${mechanism} authentication`);
// Send the first SASL response immediately to avoid a roundtrip
let initialResp = null;
switch (mechanism) {
case "PLAIN":
let respStr = btoa("\0" + params.username + "\0" + params.password);
initialResp = { command: "AUTHENTICATE", params: [respStr] };
break;
case "EXTERNAL":
initialResp = { command: "AUTHENTICATE", params: [btoa("")] };
break;
default:
throw new Error(`Unknown authentication mechanism '${mechanism}'`);
}
let startMsg = { command: "AUTHENTICATE", params: [mechanism] };
let promise = this.roundtrip(startMsg, (msg) => {
switch (msg.command) {
case irc.RPL_SASLSUCCESS:
return true;
case irc.ERR_NICKLOCKED:
case irc.ERR_SASLFAIL:
case irc.ERR_SASLTOOLONG:
case irc.ERR_SASLABORTED:
case irc.ERR_SASLALREADY:
throw new IRCError(msg);
}
});
this.send(initialResp);
return promise;
}
who(mask, options) {
let params = [mask];
let fields = "", token = "";
if (options && this.isupport.has("WHOX")) {
if (options && this.isupport.whox()) {
let match = ""; // Matches exact channel or nick
fields = "t"; // Always include token in reply
@@ -373,27 +513,30 @@ export default class Client extends EventTarget {
let msg = { command: "WHO", params };
let l = [];
return this.roundtrip(msg, (msg) => {
switch (msg.command) {
case irc.RPL_WHOREPLY:
// TODO: match with mask
l.push(this.parseWhoReply(msg));
break;
case irc.RPL_WHOSPCRPL:
if (msg.params.length !== fields.length || msg.params[1] !== token) {
let promise = this.pendingCmds.WHO.then(() => {
return this.roundtrip(msg, (msg) => {
switch (msg.command) {
case irc.RPL_WHOREPLY:
l.push(this.parseWhoReply(msg));
break;
case irc.RPL_WHOSPCRPL:
if (msg.params.length !== fields.length || msg.params[1] !== token) {
break;
}
l.push(this.parseWhoReply(msg));
break;
case irc.RPL_ENDOFWHO:
if (msg.params[1] === mask) {
return l;
}
break;
}
l.push(this.parseWhoReply(msg));
break;
case irc.RPL_ENDOFWHO:
if (msg.params[1] === mask) {
return l;
}
break;
}
}).finally(() => {
this.whoxQueries.delete(token);
}).finally(() => {
this.whoxQueries.delete(token);
});
});
this.pendingCmds.WHO = promise.catch(() => {});
return promise;
}
parseWhoReply(msg) {
@@ -424,6 +567,10 @@ export default class Client extends EventTarget {
who[k] = msg.params[2 + i];
i++;
});
if (who.account === "0") {
// WHOX uses "0" to mean "no account"
who.account = null;
}
return who;
default:
throw new Error("Not a WHO reply: " + msg.command);
@@ -439,146 +586,120 @@ export default class Client extends EventTarget {
case irc.RPL_ENDOFWHOIS:
nick = msg.params[1];
if (this.cm(nick) === targetCM) {
return this.whoisDB.get(nick);
let whois = {};
msg.list.forEach((reply) => {
whois[reply.command] = reply;
});
return whois;
}
break;
case irc.ERR_NOSUCHNICK:
nick = msg.params[1];
if (this.cm(nick) === targetCM) {
throw msg;
throw new IRCError(msg);
}
break;
}
});
}
addAvailableCaps(s) {
let l = s.split(" ");
l.forEach((s) => {
let i = s.indexOf("=");
let k = s, v = "";
if (i >= 0) {
k = s.slice(0, i);
v = s.slice(i + 1);
}
this.availableCaps[k.toLowerCase()] = v;
});
}
supportsSASL(mech) {
let saslCap = this.availableCaps["sasl"];
let saslCap = this.caps.available.get("sasl");
if (saslCap === undefined) {
return false;
}
return saslCap.split(",").includes(mech);
}
requestCaps(extra) {
let reqCaps = extra || [];
checkAccountRegistrationCap(k) {
let v = this.caps.available.get("draft/account-registration");
if (v === undefined) {
return false;
}
return v.split(",").includes(k);
}
permanentCaps.forEach((cap) => {
if (this.availableCaps[cap] !== undefined && !this.enabledCaps[cap]) {
reqCaps.push(cap);
}
});
requestCaps() {
let wantCaps = [].concat(permanentCaps);
if (!this.params.bouncerNetwork) {
wantCaps.push("soju.im/bouncer-networks-notify");
}
if (reqCaps.length > 0) {
this.send({ command: "CAP", params: ["REQ", reqCaps.join(" ")] });
let msg = this.caps.requestAvailable(wantCaps);
if (msg) {
this.send(msg);
}
}
handleCap(msg) {
this.caps.parse(msg);
let subCmd = msg.params[1];
let args = msg.params.slice(2);
switch (subCmd) {
case "LS":
this.addAvailableCaps(args[args.length - 1]);
if (args[0] != "*") {
console.log("Available server caps:", this.availableCaps);
this.supportsCap = true;
if (args[0] == "*") {
break;
}
let reqCaps = [];
let capEnd = true;
if (this.params.saslPlain && this.supportsSASL("PLAIN")) {
// CAP END is deferred after authentication finishes
reqCaps.push("sasl");
capEnd = false;
console.log("Available server caps:", this.caps.available);
this.requestCaps();
if (this.status !== Client.Status.REGISTERED) {
if (this.caps.available.has("sasl")) {
let promise;
if (this.params.saslPlain) {
promise = this.authenticate("PLAIN", this.params.saslPlain);
} else if (this.params.saslExternal) {
promise = this.authenticate("EXTERNAL");
}
(promise || Promise.resolve()).catch((err) => {
this.dispatchError(err);
this.disconnect();
});
}
if (!this.params.bouncerNetwork && this.availableCaps["soju.im/bouncer-networks-notify"] !== undefined) {
reqCaps.push("soju.im/bouncer-networks-notify");
if (this.caps.available.has("soju.im/bouncer-networks") && this.params.bouncerNetwork) {
this.send({ command: "BOUNCER", params: ["BIND", this.params.bouncerNetwork] });
}
this.requestCaps(reqCaps);
if (this.status != Client.Status.REGISTERED && capEnd) {
this.send({ command: "CAP", params: ["END"] });
}
this.send({ command: "CAP", params: ["END"] });
}
break;
case "NEW":
this.addAvailableCaps(args[0]);
console.log("Server added available caps:", args[0]);
this.requestCaps();
break;
case "DEL":
args[0].split(" ").forEach((cap) => {
cap = cap.toLowerCase();
delete this.availableCaps[cap];
delete this.enabledCaps[cap];
});
console.log("Server removed available caps:", args[0]);
break;
case "ACK":
console.log("Server ack'ed caps:", args[0]);
args[0].split(" ").forEach((cap) => {
cap = cap.toLowerCase();
this.enabledCaps[cap] = true;
if (cap == "sasl" && this.params.saslPlain) {
console.log("Starting SASL PLAIN authentication");
this.send({ command: "AUTHENTICATE", params: ["PLAIN"] });
}
});
break;
case "NAK":
console.log("Server nak'ed caps:", args[0]);
if (this.status != Client.Status.REGISTERED) {
if (this.status !== Client.Status.REGISTERED) {
this.send({ command: "CAP", params: ["END"] });
}
break;
}
}
handleAuthenticate(msg) {
let challengeStr = msg.params[0];
// For now only PLAIN is supported
if (challengeStr != "+") {
this.dispatchEvent(new CustomEvent("error", { detail: "Expected an empty challenge, got: " + challengeStr }));
this.send({ command: "AUTHENTICATE", params: ["*"] });
return;
}
let respStr = btoa("\0" + this.params.saslPlain.username + "\0" + this.params.saslPlain.password);
this.send({ command: "AUTHENTICATE", params: [respStr] });
}
send(msg) {
if (!this.ws) {
throw new Error("Failed to send IRC message " + msg.command + ": socket is closed");
}
this.ws.send(irc.formatMessage(msg));
console.debug("Sent:", msg);
if (this.debug) {
console.debug("Sent:", msg);
}
}
setCaseMapping(name) {
this.cm = irc.CaseMapping.byName(name);
if (!this.cm) {
console.error("Unsupported case-mapping '" + name + "', falling back to RFC 1459");
this.cm = irc.CaseMapping.RFC1459;
}
this.whoisDB = new irc.CaseMapMap(this.whoisDB, this.cm);
updateCaseMapping() {
this.cm = this.isupport.caseMapping();
this.pendingLists = new irc.CaseMapMap(this.pendingLists, this.cm);
this.monitored = new irc.CaseMapMap(this.monitored, this.cm);
}
@@ -591,7 +712,7 @@ export default class Client extends EventTarget {
}
isChannel(name) {
let chanTypes = this.isupport.get("CHANTYPES") || irc.STD_CHANTYPES;
let chanTypes = this.isupport.chanTypes();
return chanTypes.indexOf(name[0]) >= 0;
}
@@ -613,8 +734,10 @@ export default class Client extends EventTarget {
/* Execute a command that expects a response. `done` is called with message
* events until it returns a truthy value. */
roundtrip(msg, done) {
let cmd = msg.command;
let label;
if (this.enabledCaps["labeled-response"]) {
if (this.caps.enabled.has("labeled-response")) {
lastLabel++;
label = String(lastLabel);
msg.tags = { ...msg.tags, label };
@@ -631,6 +754,24 @@ export default class Client extends EventTarget {
return;
}
let isError = false;
switch (msg.command) {
case "FAIL":
isError = msg.params[0] === cmd;
break;
case irc.ERR_UNKNOWNERROR:
case irc.ERR_UNKNOWNCOMMAND:
case irc.ERR_NEEDMOREPARAMS:
case irc.RPL_TRYAGAIN:
isError = msg.params[1] === cmd;
break;
}
if (isError) {
removeEventListeners();
reject(new IRCError(msg));
return;
}
let result;
try {
result = done(msg);
@@ -664,9 +805,40 @@ export default class Client extends EventTarget {
});
}
join(channel, password) {
let params = [channel];
if (password) {
params.push(password);
}
let msg = {
command: "JOIN",
params: params,
};
return this.roundtrip(msg, (msg) => {
switch (msg.command) {
case irc.ERR_NOSUCHCHANNEL:
case irc.ERR_TOOMANYCHANNELS:
case irc.ERR_BADCHANNELKEY:
case irc.ERR_BANNEDFROMCHAN:
case irc.ERR_CHANNELISFULL:
case irc.ERR_INVITEONLYCHAN:
if (this.cm(msg.params[1]) === this.cm(channel)) {
throw new IRCError(msg);
}
break;
case "JOIN":
if (this.isMyNick(msg.prefix.name) && this.cm(msg.params[0]) === this.cm(channel)) {
return true;
}
break;
}
});
}
fetchBatch(msg, batchType) {
let batchName = null;
let messages = [];
let cmd = msg.command;
return this.roundtrip(msg, (msg) => {
if (batchName) {
let batch = msg.batch;
@@ -679,23 +851,18 @@ export default class Client extends EventTarget {
}
}
switch (msg.command) {
case "BATCH":
let enter = msg.params[0].startsWith("+");
let name = msg.params[0].slice(1);
if (enter && msg.params[1] === batchType) {
batchName = name;
break;
}
if (!enter && name === batchName) {
return { ...this.batches.get(name), messages };
}
break;
case "FAIL":
if (msg.params[0] === msg.command) {
throw msg;
}
break;
if (msg.command !== "BATCH") {
return;
}
let enter = msg.params[0].startsWith("+");
let name = msg.params[0].slice(1);
if (enter && msg.params[1] === batchType) {
batchName = name;
return;
}
if (!enter && name === batchName) {
return { ...this.batches.get(name), messages };
}
});
}
@@ -703,29 +870,20 @@ export default class Client extends EventTarget {
roundtripChatHistory(params) {
// Don't send multiple CHATHISTORY commands in parallel, we can't
// properly handle batches and errors.
this.pendingHistory = this.pendingHistory.catch(() => {}).then(() => {
let promise = this.pendingCmds.CHATHISTORY.then(() => {
let msg = {
command: "CHATHISTORY",
params,
};
return this.fetchBatch(msg, "chathistory").then((batch) => batch.messages);
});
return this.pendingHistory;
}
chatHistoryPageSize() {
if (this.isupport.has("CHATHISTORY")) {
let pageSize = parseInt(this.isupport.get("CHATHISTORY"), 10);
if (pageSize > 0) {
return pageSize;
}
}
return 100;
this.pendingCmds.CHATHISTORY = promise.catch(() => {});
return promise;
}
/* Fetch one page of history before the given date. */
fetchHistoryBefore(target, before, limit) {
let max = Math.min(limit, this.chatHistoryPageSize());
let max = Math.min(limit, this.isupport.chatHistory());
let params = ["BEFORE", target, "timestamp=" + before, max];
return this.roundtripChatHistory(params).then((messages) => {
return { more: messages.length >= max };
@@ -734,7 +892,7 @@ export default class Client extends EventTarget {
/* Fetch history in ascending order. */
fetchHistoryBetween(target, after, before, limit) {
let max = Math.min(limit, this.chatHistoryPageSize());
let max = Math.min(limit, this.isupport.chatHistory());
let params = ["AFTER", target, "timestamp=" + after.time, max];
return this.roundtripChatHistory(params).then((messages) => {
limit -= messages.length;
@@ -769,10 +927,6 @@ export default class Client extends EventTarget {
}
listBouncerNetworks() {
if (!this.enabledCaps["soju.im/bouncer-networks"]) {
return Promise.reject(new Error("Server doesn't support the BOUNCER extension"));
}
let req = { command: "BOUNCER", params: ["LISTNETWORKS"] };
return this.fetchBatch(req, "soju.im/bouncer-networks").then((batch) => {
let networks = new Map();
@@ -786,13 +940,6 @@ export default class Client extends EventTarget {
});
}
maxMonitorTargets() {
if (!this.isupport.has("MONITOR")) {
return 0;
}
return parseInt(this.isupport.get("MONITOR"), 10);
}
monitor(target) {
if (this.monitored.has(target)) {
return;
@@ -801,7 +948,7 @@ export default class Client extends EventTarget {
this.monitored.set(target, true);
// TODO: add poll-based fallback when MONITOR is not supported
if (this.monitored.size + 1 > this.maxMonitorTargets()) {
if (this.monitored.size + 1 > this.isupport.monitor()) {
return;
}
@@ -815,10 +962,53 @@ export default class Client extends EventTarget {
this.monitored.delete(target);
if (!this.isupport.has("MONITOR")) {
if (this.isupport.monitor() <= 0) {
return;
}
this.send({ command: "MONITOR", params: ["-", target] });
}
createBouncerNetwork(attrs) {
let msg = {
command: "BOUNCER",
params: ["ADDNETWORK", irc.formatTags(attrs)],
};
return this.roundtrip(msg, (msg) => {
if (msg.command === "BOUNCER" && msg.params[0] === "ADDNETWORK") {
return msg.params[1];
}
});
}
registerAccount(email, password) {
let msg = {
command: "REGISTER",
params: ["*", email || "*", password],
};
return this.roundtrip(msg, (msg) => {
if (msg.command !== "REGISTER") {
return;
}
let result = msg.params[0];
return {
verificationRequired: result === "VERIFICATION_REQUIRED",
account: msg.params[1],
message: msg.params[2],
};
});
}
verifyAccount(account, code) {
let msg = {
command: "VERIFY",
params: [account, code],
};
return this.roundtrip(msg, (msg) => {
if (msg.command !== "VERIFY") {
return;
}
return { message: msg.params[2] };
});
}
}

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";
export const html = htm.bind(h);
import "../node_modules/anchorme/dist/browser/anchorme.min.js";
export const anchorme = window.anchorme;
import * as linkifyjs from "../node_modules/linkifyjs/dist/linkify.module.js";
export { linkifyjs };

View File

@@ -4,6 +4,9 @@ export const RPL_YOURHOST = "002";
export const RPL_CREATED = "003";
export const RPL_MYINFO = "004";
export const RPL_ISUPPORT = "005";
export const RPL_UMODEIS = "221";
export const RPL_TRYAGAIN = "263";
export const RPL_AWAY = "301";
export const RPL_WHOISUSER = "311";
export const RPL_WHOISSERVER = "312";
export const RPL_WHOISOPERATOR = "313";
@@ -12,7 +15,6 @@ export const RPL_ENDOFWHOIS = "318";
export const RPL_WHOISCHANNELS = "319";
export const RPL_ENDOFWHO = "315";
export const RPL_CHANNELMODEIS = "324";
export const RPL_CREATIONTIME = "329";
export const RPL_NOTOPIC = "331";
export const RPL_TOPIC = "332";
export const RPL_TOPICWHOTIME = "333";
@@ -30,17 +32,27 @@ export const RPL_ENDOFBANLIST = "368";
export const RPL_MOTD = "372";
export const RPL_MOTDSTART = "375";
export const RPL_ENDOFMOTD = "376";
export const ERR_UNKNOWNERROR = "400";
export const ERR_NOSUCHNICK = "401";
export const ERR_NOSUCHCHANNEL = "403";
export const ERR_TOOMANYCHANNELS = "405";
export const ERR_UNKNOWNCOMMAND = "421";
export const ERR_NOMOTD = "422";
export const ERR_ERRONEUSNICKNAME = "432";
export const ERR_NICKNAMEINUSE = "433";
export const ERR_NICKCOLLISION = "436";
export const ERR_NEEDMOREPARAMS = "461";
export const ERR_NOPERMFORHOST = "463";
export const ERR_PASSWDMISMATCH = "464";
export const ERR_YOUREBANNEDCREEP = "465";
export const ERR_CHANNELISFULL = "471";
export const ERR_INVITEONLYCHAN = "473";
export const ERR_BANNEDFROMCHAN = "474";
export const ERR_BADCHANNELKEY = "475";
// RFC 2812
export const ERR_UNAVAILRESOURCE = "437";
// Other
export const RPL_CREATIONTIME = "329";
export const RPL_QUIETLIST = "728";
export const RPL_ENDOFQUIETLIST = "729";
// IRCv3 MONITOR: https://ircv3.net/specs/extensions/monitor
@@ -61,7 +73,6 @@ export const ERR_SASLALREADY = "907";
export const STD_MEMBERSHIPS = "~&@%+";
export const STD_CHANTYPES = "#&+!";
export const STD_CHANMODES = "beI,k,l,imnst";
const tagEscapeMap = {
";": "\\:",
@@ -121,32 +132,32 @@ export function parsePrefix(s) {
host: null,
};
let host = null;
let i = s.indexOf("@");
if (i < 0) {
prefix.name = s;
return prefix;
if (i > 0) {
host = s.slice(i + 1);
s = s.slice(0, i);
}
prefix.host = s.slice(i + 1);
s = s.slice(0, i);
let user = null;
i = s.indexOf("!");
if (i < 0) {
prefix.name = s;
return prefix;
if (i > 0) {
user = s.slice(i + 1);
s = s.slice(0, i);
}
prefix.name = s.slice(0, i);
prefix.user = s.slice(i + 1);
return prefix;
return { name: s, user, host };
}
function formatPrefix(prefix) {
if (!prefix.host) {
return prefix.name;
let s = prefix.name;
if (prefix.user) {
s += "!" + prefix.user;
}
if (!prefix.user) {
return prefix.name + "@" + prefix.host;
if (prefix.host) {
s += "@" + prefix.host;
}
return prefix.name + "!" + prefix.user + "@" + prefix.host;
return s;
}
export function parseMessage(s) {
@@ -247,7 +258,7 @@ export function parseTargetPrefix(s, allowedPrefixes = STD_MEMBERSHIPS) {
const alphaNum = (() => {
try {
return new RegExp(/^\p{L}$/, "u");
return new RegExp(/^[\p{L}0-9]$/, "u");
} catch (e) {
return new RegExp(/^[a-zA-Z0-9]$/, "u");
}
@@ -259,8 +270,6 @@ function isWordBoundary(ch) {
case "_":
case "|":
return false;
case "\u00A0":
return true;
default:
return !alphaNum.test(ch);
}
@@ -289,7 +298,7 @@ export function isHighlight(msg, nick, cm) {
if (i > 0) {
left = text[i - 1];
}
if (i < text.length) {
if (i + nick.length < text.length) {
right = text[i + nick.length];
}
if (isWordBoundary(left) && isWordBoundary(right)) {
@@ -363,28 +372,108 @@ export function parseCTCP(msg) {
return ctcp;
}
export function parseISUPPORT(tokens, params) {
let changed = [];
tokens.forEach((tok) => {
if (tok.startsWith("-")) {
let k = tok.slice(1);
params.delete(k.toUpperCase());
return;
}
let i = tok.indexOf("=");
let k = tok, v = "";
if (i >= 0) {
k = tok.slice(0, i);
v = tok.slice(i + 1);
}
k = k.toUpperCase();
params.set(k, v);
changed.push(k);
function unescapeISUPPORTValue(s) {
return s.replace(/\\x[0-9A-Z]{2}/gi, (esc) => {
let hex = esc.slice(2);
return String.fromCharCode(parseInt(hex, 16));
});
return changed;
}
export class Isupport {
raw = new Map();
parse(tokens) {
tokens.forEach((tok) => {
if (tok.startsWith("-")) {
let k = tok.slice(1);
this.raw.delete(k.toUpperCase());
return;
}
let i = tok.indexOf("=");
let k = tok, v = "";
if (i >= 0) {
k = tok.slice(0, i);
v = unescapeISUPPORTValue(tok.slice(i + 1));
}
k = k.toUpperCase();
this.raw.set(k, v);
});
}
caseMapping() {
let name = this.raw.get("CASEMAPPING");
if (!name) {
return CaseMapping.RFC1459;
}
let cm = CaseMapping.byName(name);
if (!cm) {
console.error("Unsupported case-mapping '" + name + "', falling back to RFC 1459");
return CaseMapping.RFC1459;
}
return cm;
}
monitor() {
if (!this.raw.has("MONITOR")) {
return 0;
}
let v = this.raw.get("MONITOR");
if (v === "") {
return Infinity;
}
return parseInt(v, 10);
}
whox() {
return this.raw.has("WHOX");
}
prefix() {
return this.raw.get("PREFIX") || "";
}
chanTypes() {
return this.raw.get("CHANTYPES") || STD_CHANTYPES;
}
statusMsg() {
return this.raw.get("STATUSMSG");
}
network() {
return this.raw.get("NETWORK");
}
chatHistory() {
if (!this.raw.has("CHATHISTORY")) {
return 0;
}
let n = parseInt(this.raw.get("CHATHISTORY"), 10);
if (n <= 0) {
return Infinity;
}
return n;
}
bouncerNetID() {
return this.raw.get("BOUNCER_NETID");
}
chanModes() {
const stdChanModes = ["beI", "k", "l", "imnst"];
if (!this.raw.has("CHANMODES")) {
return stdChanModes;
}
let chanModes = this.raw.get("CHANMODES").split(",");
if (chanModes.length != 4) {
console.error("Invalid CHANMODES: ", this.raw.get("CHANMODES"));
return stdChanModes;
}
return chanModes;
}
}
export const CaseMapping = {
@@ -593,11 +682,10 @@ export function getMessageLabel(msg) {
}
export function forEachChannelModeUpdate(msg, isupport, callback) {
let chanmodes = isupport.get("CHANMODES") || STD_CHANMODES;
let prefix = isupport.get("PREFIX") || "";
let [a, b, c, d] = isupport.chanModes();
let prefix = isupport.prefix();
let typeByMode = new Map();
let [a, b, c, d] = chanmodes.split(",");
Array.from(a).forEach((mode) => typeByMode.set(mode, "A"));
Array.from(b).forEach((mode) => typeByMode.set(mode, "B"));
Array.from(c).forEach((mode) => typeByMode.set(mode, "C"));
@@ -640,6 +728,8 @@ export function forEachChannelModeUpdate(msg, isupport, callback) {
}
/**
* Check if a realname is worth displaying.
*
* Since the realname is mandatory, many clients set a meaningless realname.
*/
export function isMeaningfulRealname(realname, nick) {
@@ -647,7 +737,7 @@ export function isMeaningfulRealname(realname, nick) {
return false;
}
if (realname.toLowerCase() === "realname") {
if (realname.toLowerCase() === "realname" || realname.toLowerCase() === "unknown" || realname.toLowerCase() === "fullname") {
return false;
}
@@ -655,3 +745,124 @@ export function isMeaningfulRealname(realname, nick) {
return true;
}
/* Parse an irc:// URL.
*
* See: https://datatracker.ietf.org/doc/html/draft-butcher-irc-url-04
*/
export function parseURL(str) {
if (!str.startsWith("irc://") && !str.startsWith("ircs://")) {
return null;
}
str = str.slice(str.indexOf(":") + "://".length);
let loc;
let i = str.indexOf("/");
if (i < 0) {
loc = str;
str = "";
} else {
loc = str.slice(0, i);
str = str.slice(i + 1);
}
let host = loc;
i = loc.indexOf("@");
if (i >= 0) {
host = loc.slice(i + 1);
// TODO: parse authinfo
}
i = str.indexOf("?");
if (i >= 0) {
str = str.slice(0, i);
// TODO: parse options
}
let enttype;
i = str.indexOf(",");
if (i >= 0) {
let flags = str.slice(i + 1).split(",");
str = str.slice(0, i);
if (flags.indexOf("isuser") >= 0) {
enttype = "user";
} else if (flags.indexOf("ischannel") >= 0) {
enttype = "channel";
}
// TODO: parse hosttype
}
let entity = decodeURIComponent(str);
if (!enttype) {
// TODO: technically we should use the PREFIX ISUPPORT here
enttype = entity.startsWith("#") ? "channel" : "user";
}
return { host, enttype, entity };
}
export class CapRegistry {
available = new Map();
enabled = new Set();
addAvailable(s) {
let l = s.split(" ");
l.forEach((s) => {
let i = s.indexOf("=");
let k = s, v = "";
if (i >= 0) {
k = s.slice(0, i);
v = s.slice(i + 1);
}
this.available.set(k.toLowerCase(), v);
});
}
parse(msg) {
if (msg.command !== "CAP") {
return;
}
let subCmd = msg.params[1];
let args = msg.params.slice(2);
switch (subCmd) {
case "LS":
this.addAvailable(args[args.length - 1]);
break;
case "NEW":
this.addAvailable(args[0]);
break;
case "DEL":
args[0].split(" ").forEach((cap) => {
cap = cap.toLowerCase();
this.available.delete(cap);
this.enabled.delete(cap);
});
break;
case "ACK":
args[0].split(" ").forEach((cap) => {
cap = cap.toLowerCase();
if (cap.startsWith("-")) {
this.enabled.delete(cap.slice(1));
} else {
this.enabled.add(cap);
}
});
break;
}
}
requestAvailable(l) {
l = l.filter((cap) => {
return this.available.has(cap) && !this.enabled.has(cap);
});
if (l.length === 0) {
return null;
}
return { command: "CAP", params: ["REQ", l.join(" ")] };
}
}

View File

@@ -1,65 +1,68 @@
import { anchorme, html } from "./index.js";
import { linkifyjs, html } from "./index.js";
function linkifyChannel(text, transformChannel) {
// Don't match punctuation at the end of the channel name
const channelRegex = /(^|\s)(#[^\s]+[^\s.?!…():;,])/gi;
linkifyjs.options.defaults.defaultProtocol = "https";
let children = [];
let match;
let last = 0;
while ((match = channelRegex.exec(text)) !== null) {
let channel = match[2];
let start = match.index + match[1].length;
let end = start + match[2].length;
linkifyjs.registerCustomProtocol("irc");
linkifyjs.registerCustomProtocol("ircs");
children.push(text.substring(last, start));
children.push(transformChannel(channel));
linkifyjs.registerPlugin("ircChannel", ({ scanner, parser, utils }) => {
const { POUND, DOMAIN, TLD, LOCALHOST, UNDERSCORE, DOT, HYPHEN } = scanner.tokens;
const START_STATE = parser.start;
last = end;
}
children.push(text.substring(last));
const Channel = utils.createTokenClass("ircChannel", {
isLink: true,
toHref() {
return "irc:///" + this.toString();
},
});
return children;
}
const HASH_STATE = START_STATE.tt(POUND);
export default function linkify(text, onChannelClick) {
function transformChannel(channel) {
function onClick(event) {
event.preventDefault();
onChannelClick(channel);
}
return html`
<a
href="irc:///${encodeURIComponent(channel)}"
onClick=${onClick}
>${channel}</a>`;
}
const CHAN_STATE = HASH_STATE.tt(DOMAIN, Channel);
HASH_STATE.tt(TLD, CHAN_STATE);
HASH_STATE.tt(LOCALHOST, CHAN_STATE);
HASH_STATE.tt(POUND, CHAN_STATE);
let links = anchorme.list(text);
CHAN_STATE.tt(UNDERSCORE, CHAN_STATE);
CHAN_STATE.tt(DOMAIN, CHAN_STATE);
CHAN_STATE.tt(TLD, CHAN_STATE);
CHAN_STATE.tt(LOCALHOST, CHAN_STATE);
const CHAN_DIVIDER_STATE = CHAN_STATE.tt(DOT);
CHAN_DIVIDER_STATE.tt(UNDERSCORE, CHAN_STATE);
CHAN_DIVIDER_STATE.tt(DOMAIN, CHAN_STATE);
CHAN_DIVIDER_STATE.tt(TLD, CHAN_STATE);
CHAN_DIVIDER_STATE.tt(LOCALHOST, CHAN_STATE);
});
export default function linkify(text, onClick) {
let links = linkifyjs.find(text);
let children = [];
let last = 0;
links.forEach((match) => {
if (!match.isLink) {
return;
}
const prefix = text.substring(last, match.start)
children.push(...linkifyChannel(prefix, transformChannel));
children.push(prefix);
let proto = match.protocol || "https://";
if (match.isEmail) {
proto = "mailto:";
}
let url = match.string;
if (!url.startsWith(proto)) {
url = proto + url;
}
children.push(html`<a href=${url} target="_blank" rel="noreferrer noopener">${match.string}</a>`);
children.push(html`
<a
href=${match.href}
target="_blank"
rel="noreferrer noopener"
onClick=${onClick}
>${match.value}</a>
`);
last = match.end;
});
const suffix = text.substring(last)
children.push(...linkifyChannel(suffix, transformChannel));
children.push(suffix);
return children;
}

4
main.js Normal file
View File

@@ -0,0 +1,4 @@
import { html, render } from "./lib/index.js";
import App from "./components/app.js";
render(html`<${App}/>`, document.body);

5025
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,31 @@
{
"name": "gamja",
"type": "module",
"dependencies": {
"anchorme": "^2.1.2",
"htm": "^3.0.4",
"linkifyjs": "^3.0.2",
"preact": "^10.5.9"
},
"devDependencies": {
"http-server": "^13.0.2"
"@parcel/packager-raw-url": "^2.0.0",
"@parcel/transformer-webmanifest": "^2.0.0",
"node-static": "^0.7.11",
"parcel": "^2.0.0",
"split": "^1.0.1",
"ws": "^8.3.0"
},
"scripts": {
"start": "http-server ."
"start": "node ./dev-server.js",
"build": "parcel build"
},
"private": true
"private": true,
"targets": {
"default": {
"source": "index.html",
"publicUrl": "."
}
},
"engines": {
"node": ">=14.13.0"
}
}

198
state.js
View File

@@ -63,20 +63,62 @@ export function getMessageURL(buf, msg) {
}
}
export function getServerName(server, bouncerNetwork, isBouncer) {
if (bouncerNetwork && bouncerNetwork.name) {
export function getServerName(server, bouncerNetwork) {
let netName = server.name;
if (bouncerNetwork && bouncerNetwork.name && bouncerNetwork.name !== bouncerNetwork.host) {
// User has picked a custom name for the network, use that
return bouncerNetwork.name;
}
if (isBouncer) {
return "bouncer";
}
let netName = server.isupport.get("NETWORK");
if (netName) {
// Server has specified a name
return netName;
}
return "server";
if (bouncerNetwork) {
return bouncerNetwork.name || bouncerNetwork.host || "server";
} else if (server.isBouncer) {
return "bouncer";
} else {
return "server";
}
}
export function receiptFromMessage(msg) {
// At this point all messages are supposed to have a time tag.
// App.addMessage ensures this is the case even if the server doesn't
// support server-time.
if (!msg.tags.time) {
throw new Error("Missing time message tag");
}
return { time: msg.tags.time };
}
export function isReceiptBefore(a, b) {
if (!b) {
return false;
}
if (!a) {
return true;
}
if (!a.time || !b.time) {
throw new Error("Missing receipt time");
}
return a.time <= b.time;
}
export function isMessageBeforeReceipt(msg, receipt) {
if (!receipt) {
return false;
}
if (!msg.tags.time) {
throw new Error("Missing time message tag");
}
if (!receipt.time) {
throw new Error("Missing receipt time");
}
return msg.tags.time <= receipt.time;
}
function updateState(state, updater) {
@@ -112,7 +154,7 @@ function compareBuffers(a, b) {
}
function updateMembership(membership, letter, add, client) {
let prefix = client.isupport.get("PREFIX") || "";
let prefix = client.isupport.prefix();
let prefixPrivs = new Map(irc.parseMembershipModes(prefix).map((membership, i) => {
return [membership.prefix, i];
@@ -137,7 +179,7 @@ function updateMembership(membership, letter, add, client) {
function insertMessage(list, msg) {
if (list.length == 0) {
return [msg];
} else if (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);
}
@@ -166,6 +208,7 @@ export const State = {
servers: new Map(),
buffers: new Map(),
activeBuffer: null,
bouncerNetworks: new Map(),
};
},
updateServer(state, id, updater) {
@@ -225,7 +268,7 @@ export const State = {
let cm = irc.CaseMapping.RFC1459;
let server = state.servers.get(serverID);
if (server) {
cm = irc.CaseMapping.byName(server.isupport.get("CASEMAPPING")) || cm;
cm = server.cm;
}
let nameCM = cm(name);
@@ -246,9 +289,17 @@ export const State = {
let servers = new Map(state.servers);
servers.set(id, {
id,
name: null, // from ISUPPORT NETWORK
status: ServerStatus.DISCONNECTED,
isupport: new Map(),
cm: irc.CaseMapping.RFC1459,
users: new irc.CaseMapMap(null, irc.CaseMapping.RFC1459),
account: null,
supportsSASLPlain: false,
supportsAccountRegistration: false,
reliableUserAccounts: false,
statusMsg: null, // from ISUPPORT STATUSMSG
isBouncer: false,
bouncerNetID: null,
});
return [id, { servers }];
},
@@ -277,6 +328,7 @@ export const State = {
type,
server: serverID,
serverInfo: null, // if server
joined: false, // if channel
topic: null, // if channel
members: new irc.CaseMapMap(null, client.cm), // if channel
messages: [],
@@ -287,6 +339,19 @@ export const State = {
let buffers = new Map(bufferList.map((buf) => [buf.id, buf]));
return [id, { buffers }];
},
storeBouncerNetwork(state, id, attrs) {
let bouncerNetworks = new Map(state.bouncerNetworks);
bouncerNetworks.set(id, {
...bouncerNetworks.get(id),
...attrs,
});
return { bouncerNetworks };
},
deleteBouncerNetwork(state, id) {
let bouncerNetworks = new Map(state.bouncerNetworks);
bouncerNetworks.delete(id);
return { bouncerNetworks };
},
handleMessage(state, msg, serverID, client) {
function updateServer(updater) {
return State.updateServer(state, serverID, updater);
@@ -311,7 +376,7 @@ export const State = {
return;
}
let target, channel, topic, targets, who;
let target, channel, topic, targets, who, update, buffers;
switch (msg.command) {
case irc.RPL_MYINFO:
// TODO: parse available modes
@@ -321,7 +386,7 @@ export const State = {
};
return updateBuffer(SERVER_BUFFER, { serverInfo });
case irc.RPL_ISUPPORT:
let buffers = new Map(state.buffers);
buffers = new Map(state.buffers);
state.buffers.forEach((buf) => {
if (buf.server != serverID) {
return;
@@ -333,11 +398,31 @@ export const State = {
buffers,
...updateServer((server) => {
return {
isupport: new Map(client.isupport),
name: client.isupport.network(),
cm: client.cm,
users: new irc.CaseMapMap(server.users, client.cm),
reliableUserAccounts: client.isupport.monitor() > 0 && client.isupport.whox(),
statusMsg: client.isupport.statusMsg(),
bouncerNetID: client.isupport.bouncerNetID(),
};
}),
};
case "CAP":
return updateServer({
supportsSASLPlain: client.supportsSASL("PLAIN"),
supportsAccountRegistration: client.caps.enabled.has("draft/account-registration"),
isBouncer: client.caps.enabled.has("soju.im/bouncer-networks"),
});
case irc.RPL_LOGGEDIN:
return updateServer({ account: msg.params[2] });
case irc.RPL_LOGGEDOUT:
return updateServer({ account: null });
case "REGISTER":
case "VERIFY":
if (msg.params[0] === "SUCCESS") {
return updateServer({ account: msg.params[1] });
}
break;
case irc.RPL_NOTOPIC:
channel = msg.params[1];
return updateBuffer(channel, { topic: null });
@@ -348,19 +433,19 @@ export const State = {
case irc.RPL_TOPICWHOTIME:
// Ignore
break;
case irc.RPL_NAMREPLY:
channel = msg.params[2];
let membersList = msg.params[3].split(" ");
case irc.RPL_ENDOFNAMES:
channel = msg.params[1];
return updateBuffer(channel, (buf) => {
let members = new irc.CaseMapMap(buf.members);
membersList.forEach((s) => {
let member = irc.parseTargetPrefix(s);
members.set(member.name, member.prefix);
let members = new irc.CaseMapMap(null, buf.members.caseMap);
msg.list.forEach((namreply) => {
let membersList = namreply.params[3].split(" ");
membersList.forEach((s) => {
let member = irc.parseTargetPrefix(s);
members.set(member.name, member.prefix);
});
});
return { members };
});
case irc.RPL_ENDOFNAMES:
break;
case irc.RPL_WHOREPLY:
case irc.RPL_WHOSPCRPL:
@@ -368,6 +453,7 @@ export const State = {
if (who.flags !== undefined) {
who.away = who.flags.indexOf("G") >= 0; // H for here, G for gone
who.operator = who.flags.indexOf("*") >= 0;
delete who.flags;
}
@@ -376,14 +462,9 @@ export const State = {
return updateUser(who.nick, who);
case irc.RPL_ENDOFWHO:
target = msg.params[1];
if (!client.isChannel(target) && target.indexOf("*") < 0) {
if (msg.list.length == 0 && !client.isChannel(target) && target.indexOf("*") < 0) {
// Not a channel nor a mask, likely a nick
return updateUser(target, (user) => {
// TODO: mark user offline if we have old WHO info but this
// WHO reply is empty
if (user) {
return;
}
return { offline: true };
});
}
@@ -396,10 +477,13 @@ export const State = {
state = { ...state, ...update };
}
let update = updateBuffer(channel, (buf) => {
update = updateBuffer(channel, (buf) => {
let members = new irc.CaseMapMap(buf.members);
members.set(msg.prefix.name, "");
return { members };
let joined = buf.joined || client.isMyNick(msg.prefix.name);
return { members, joined };
});
state = { ...state, ...update };
@@ -427,7 +511,10 @@ export const State = {
return updateBuffer(channel, (buf) => {
let members = new irc.CaseMapMap(buf.members);
members.delete(msg.prefix.name);
return { members };
let joined = buf.joined && !client.isMyNick(msg.prefix.name);
return { members, joined };
});
case "KICK":
channel = msg.params[0];
@@ -436,18 +523,54 @@ export const State = {
return updateBuffer(channel, (buf) => {
let members = new irc.CaseMapMap(buf.members);
members.delete(nick);
return { members };
let joined = buf.joined && !client.isMyNick(nick);
return { members, joined };
});
case "QUIT":
return updateUser(msg.prefix.name, (user) => {
buffers = new Map(state.buffers);
state.buffers.forEach((buf) => {
if (buf.server != serverID) {
return;
}
if (!buf.members.has(msg.prefix.name)) {
return;
}
let members = new irc.CaseMapMap(buf.members);
members.delete(msg.prefix.name);
buffers.set(buf.id, { ...buf, members });
});
state = { ...state, buffers };
update = updateUser(msg.prefix.name, (user) => {
if (!user) {
return;
}
return { offline: true };
});
state = { ...state, ...update };
return state;
case "NICK":
let newNick = msg.params[0];
return updateServer((server) => {
buffers = new Map(state.buffers);
state.buffers.forEach((buf) => {
if (buf.server != serverID) {
return;
}
if (!buf.members.has(msg.prefix.name)) {
return;
}
let members = new irc.CaseMapMap(buf.members);
members.set(newNick, members.get(msg.prefix.name));
members.delete(msg.prefix.name);
buffers.set(buf.id, { ...buf, members });
});
state = { ...state, buffers };
update = updateServer((server) => {
let users = new irc.CaseMapMap(server.users);
let user = users.get(msg.prefix.name);
if (!user) {
@@ -457,6 +580,9 @@ export const State = {
users.delete(msg.prefix.name);
return { users };
});
state = { ...state, ...update };
return state;
case "SETNAME":
return updateUser(msg.prefix.name, { realname: msg.params[0] });
case "CHGHOST":
@@ -484,7 +610,7 @@ export const State = {
return; // TODO: handle user mode changes too
}
let prefix = client.isupport.get("PREFIX") || "";
let prefix = client.isupport.prefix();
let prefixByMode = new Map(irc.parseMembershipModes(prefix).map((membership) => {
return [membership.mode, membership.prefix];
}));

View File

@@ -1,3 +1,5 @@
import { ReceiptType } from "./state.js";
const PREFIX = "gamja_";
class Item {
@@ -23,18 +25,18 @@ class Item {
}
export const autoconnect = new Item("autoconnect");
export const naggedProtocolHandler = new Item("naggedProtocolHandler");
const rawReceipts = new Item("receipts");
export const receipts = {
load() {
let v = rawReceipts.load();
return new Map(Object.entries(v || {}));
},
put(m) {
rawReceipts.put(Object.fromEntries(m));
},
};
function debounce(f, delay) {
let timeout = null;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => {
timeout = null;
f(...args);
}, delay);
};
}
export class Buffer {
raw = new Item("buffers");
@@ -43,6 +45,15 @@ export class Buffer {
constructor() {
let obj = this.raw.load();
this.m = new Map(Object.entries(obj || {}));
let saveImmediately = this.save.bind(this);
this.save = debounce(saveImmediately, 500);
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") {
saveImmediately();
}
});
}
key(buf) {
@@ -71,14 +82,39 @@ export class Buffer {
put(buf) {
let key = this.key(buf);
let prev = this.m.get(key);
if (prev && prev.unread === buf.unread) {
return;
let updated = !this.m.has(key);
let prev = this.m.get(key) || {};
let unread = prev.unread;
if (buf.unread !== undefined && buf.unread !== prev.unread) {
unread = buf.unread;
updated = true;
}
let receipts = { ...prev.receipts };
if (buf.receipts) {
Object.keys(buf.receipts).forEach((k) => {
// Use a not-equals comparison here so that no-op receipt
// changes are correctly handled
if (!receipts[k] || receipts[k].time < buf.receipts[k].time) {
receipts[k] = buf.receipts[k];
updated = true;
}
});
if (receipts[ReceiptType.DELIVERED] < receipts[ReceiptType.READ]) {
receipts[ReceiptType.DELIVERED] = receipts[ReceiptType.READ];
updated = true;
}
}
if (!updated) {
return false;
}
this.m.set(this.key(buf), {
name: buf.name,
unread: buf.unread,
unread,
receipts,
server: {
url: buf.server.url,
nick: buf.server.nick,
@@ -87,6 +123,7 @@ export class Buffer {
});
this.save();
return true;
}
delete(buf) {

View File

@@ -58,6 +58,13 @@ body {
font-family: monospace;
}
noscript {
display: block;
margin: 0 auto;
max-width: 600px;
grid-column-start: 2;
}
button {
background: var(--button-background);
transition: background 0.25s linear;
@@ -179,7 +186,7 @@ button.danger:hover {
grid-column: 2;
display: grid;
grid-template-rows: auto auto;
grid-template-rows: 1fr auto;
grid-template-columns: 1fr auto;
}
@@ -195,6 +202,9 @@ button.danger:hover {
padding: 5px 10px;
grid-row: 2;
grid-column: 1;
max-height: 20vh;
overflow-y: auto;
word-break: break-word;
}
#buffer-header .actions {
@@ -279,6 +289,9 @@ button.danger:hover {
padding: 2px 10px;
box-sizing: border-box;
}
#member-list li a.away {
color: var(--gray);
}
.membership.owner {
color: red;