Compare commits

..

369 Commits

Author SHA1 Message Date
Simon Ser
caf6e9978b Upgrade @stylistic/eslint-plugin-js to v4 2025-03-20 20:03:05 +01:00
Simon Ser
fbfa123dca Upgrade globals to v16 2025-03-20 20:02:57 +01:00
Simon Ser
cd45ead256 Upgrade dependencies 2025-03-20 19:59:59 +01:00
Simon Ser
fcc80a85e3 Only send PING to registered connections on focus 2025-02-28 17:43:13 +01:00
Simon Ser
76b6931ebb components/buffer: flatten separators when appending to children
Nested "key" attributes in arrays don't work. "key" attributes
need to all be in the same array. (Alternatively, we could've used
a fragment and set a key on that.)
2025-02-24 00:29:11 +01:00
Simon Ser
7d068fd1fe Enable @stylistic/js/comma-spacing lint 2025-02-20 17:58:24 +01:00
Simon Ser
735dd8fd8c Enable @stylistic/js/block-spacing lint 2025-02-20 17:57:08 +01:00
Simon Ser
c461f4903e Enable @stylistic/js/arrow-spacing lint 2025-02-20 17:56:19 +01:00
Simon Ser
f897e7d11b Enable @stylistic/js/array-bracket-spacing lint 2025-02-20 17:51:46 +01:00
Simon Ser
95749ba516 Enable @stylistic/js/array-bracket-newline lint 2025-02-20 17:50:35 +01:00
Simon Ser
39a2bc4a3d Enable @stylistic/js/object-curly-newline lint 2025-02-20 17:47:52 +01:00
Simon Ser
614ed5c895 Enable @stylistic/js/brace-style lint 2025-02-20 17:43:17 +01:00
Simon Ser
8d96f93fb5 Enable @stylistic/js/object-curly-spacing lint 2025-02-20 17:42:19 +01:00
Simon Ser
9922d11654 Upgrade @stylistic/eslint-plugin-js to v3 2025-02-17 00:18:13 +01:00
Simon Ser
57c5f2b1cc Upgrade dependencies 2025-02-17 00:15:12 +01:00
Simon Ser
0cc1c53fa4 ci: switch to alpine/latest 2025-02-10 14:09:51 +01:00
Simon Ser
93d7d22726 ci: upload build as artifact 2025-02-10 14:08:43 +01:00
Calvin Lee
136353b2b5 Sort servers alphanumerically 2025-02-07 22:43:03 +00:00
delthas
7dd21177bc Add support for incoming REDACT
This does not include support for redacting messages, only reading
incoming REDACT messages.

See: https://github.com/ircv3/ircv3-specifications/pull/524
2025-02-07 00:26:02 +00:00
Simon Ser
ca0cfdcc28 readme: fix screenshot 2025-02-05 22:40:54 +01:00
vyneer
1e3903c014
Fix /help not showing any commands 2025-01-28 17:03:17 +03:00
Calvin Lee
5146b0cad8 eslint: add lint enforcing camelCase
snake_case is needed in one place in the codebase to format URL arguments.
Co-authored-by: Calvin Lee <pounce@integraldoma.in>
Co-committed-by: Calvin Lee <pounce@integraldoma.in>
2025-01-27 16:29:58 +00:00
Markus Unterwaditzer
513cf825a5 Add nick-caret class
I'd like to apply a userstyle to this text, and in order to do that I need a CSS class.
Co-authored-by: Markus Unterwaditzer <markus-tarpit+git@unterwaditzer.net>
Co-committed-by: Markus Unterwaditzer <markus-tarpit+git@unterwaditzer.net>
2025-01-27 12:25:26 +00:00
Simon Ser
9fef11564d Upgrade dependencies 2025-01-20 23:16:11 +01:00
Simon Ser
9dda4ee438 eslint: add a few more rules 2025-01-20 23:02:23 +01:00
Simon Ser
9299f79bab Make debug=0 URL param disable debug logs 2025-01-19 21:11:58 +01:00
Simon Ser
e4088304bf Upgrade preact, once again
Seems to not cause regressions like it did last time. Crossing
fingers now.
2025-01-07 17:06:08 +01:00
Simon Ser
6ea3601718 dev-server: print remote server errors 2025-01-05 21:52:15 +01:00
emersion
bcf3741ab4 readme: fix screenshot link 2024-12-20 21:37:04 +00:00
Simon Ser
ec5e67336f components/buffer: handle TOPIC clear messages 2024-12-03 09:19:53 +01:00
Umar Getagazov
4d988c98d0 Fix tag name typo in the Timestamp component 2024-11-28 12:37:51 +00:00
Simon Ser
62895d59ff Upgrade dependencies 2024-11-23 21:02:08 +01:00
Simon Ser
2f1bf8a2fe lib/client: don't mutate input argument in fetchHistoryBetween() 2024-11-16 12:45:08 +01:00
Simon Ser
75eb175e24 eslint: enable object-shorthand 2024-11-16 12:28:17 +01:00
Simon Ser
db0a69dcfd commands: use Map instead of object 2024-11-16 12:27:34 +01:00
Simon Ser
9c2beac7dd eslint: enable no-implicit-coercion 2024-11-16 12:18:17 +01:00
Simon Ser
8ff1cd8317 eslint: add no-invalid-this and prefer-arrow-callback 2024-11-15 02:19:28 +01:00
Simon Ser
f6e8f83d4e components/app: simplify connectParams object field 2024-11-15 02:19:28 +01:00
Simon Ser
18fa0ebc6a readme: switch from --production to --omit=dev
The former is deprecated.
2024-11-13 01:18:58 +01:00
Simon Ser
afa09cfc25 lib/client: fix typo
That one turned out to be surprisingly tricky to dig out.
2024-11-12 23:11:10 +01:00
Simon Ser
977752e0f2 lib/client: bind handleOnline to this
It's used as a callback to removeEventListener().
2024-11-12 23:10:38 +01:00
Simon Ser
4bce52f162 ci: temporarily switch to alpine/edge
It has a more up-to-date nodejs version which doesn't deadlock in
"npm install".
2024-11-12 23:10:34 +01:00
Simon Ser
75ec7cd212 lib/client: don't throttle reconnections if opened long ago
If a connection was opened a long time ago, and recently got broken,
try to reconnect immediately.
2024-11-12 23:10:30 +01:00
Simon Ser
24e6767cab client: reconnect immediately if network comes online during backoff 2024-11-12 23:10:22 +01:00
Simon Ser
ad165389f0 Fix nick colors in members list
Closes: https://todo.sr.ht/~emersion/gamja/164
2024-11-10 21:04:28 +01:00
Simon Ser
daef362931 Upgrade dependencies 2024-10-23 20:31:08 +02:00
Calvin Lee
3ba0bfe3e6 change sorting of channels in the sidebar 2024-10-23 01:54:51 +00:00
Simon Ser
b67cd10c64 Remove usage of == and != 2024-10-14 00:56:36 +02:00
Simon Ser
205a617c51 Move to Codeberg 2024-10-11 15:07:43 +02:00
Simon Ser
4145907d36 ci: use new SSH key for deployments 2024-10-11 00:25:02 +02:00
Simon Ser
c6e63d5724 components/composer: add support for multiple file upload 2024-09-29 15:31:25 +02:00
xse
2f6efb56de components/composer: handle drag and drop file upload 2024-09-29 15:21:56 +02:00
Simon Ser
cf54beacc2 lint: turn on @stylistic/js/arrow-parens 2024-09-29 11:54:21 +02:00
Simon Ser
69485716a0 lint: turn on @stylistic/js/comma-dangle 2024-09-29 11:50:57 +02:00
Simon Ser
b9d12bc8cd lint: turn on @stylistic/js/semi 2024-09-29 11:49:17 +02:00
Simon Ser
b93db7ac0e ci: fail when ESLint reports warnings 2024-09-29 11:45:56 +02:00
Simon Ser
d96e34da79 Wire up stylistic to ESLint 2024-09-29 11:45:42 +02:00
Simon Ser
78bfd16f25 Remove two remaining single quote strings 2024-09-29 11:45:13 +02:00
Simon Ser
07ae5f7167 Disallow var keyword 2024-09-28 22:07:39 +02:00
Simon Ser
312c755c11 eslint: fix global ignore for dist/ 2024-09-28 21:57:35 +02:00
Simon Ser
a03ad28438 Update dependencies 2024-09-28 21:50:03 +02:00
Simon Ser
f389ea6ffd eslint: ignore dist/ directory 2024-09-28 21:49:13 +02:00
Simon Ser
7c445d0bc9 Add ESLint 2024-09-28 21:45:45 +02:00
Simon Ser
97920ff7f6 Prefix unused variables with an underscore 2024-09-28 21:44:23 +02:00
Simon Ser
b89fd604d0 Remove unnecessary break statements 2024-09-28 21:44:03 +02:00
Simon Ser
6693cc0c78 Remove unused variables 2024-09-28 21:43:23 +02:00
Simon Ser
6747c03a75 components/app: add missing break 2024-09-28 21:36:35 +02:00
Simon Ser
35e924258a components/buffer: drop leading asterisk for MODE messages
This is inconsistent with other messages.
2024-09-08 12:50:00 +02:00
Simon Ser
26792ec386 components/buffer: add human-readable channel mode changes
References: https://todo.sr.ht/~emersion/gamja/162
2024-09-08 12:48:07 +02:00
Simon Ser
a3b375ab3f components/membership: fix operator color 2024-09-08 12:47:37 +02:00
Simon Ser
e1a15ceeb9 components/membership: fix missing import 2024-09-07 17:59:01 +02:00
Simon Ser
9e68316467 components/buffer: use case-mapping when displaying MODE messages 2024-09-07 12:45:49 +02:00
Simon Ser
6be24e8ed9 lib/irc: unexport STD_MEMBERSHIPS and STD_CHANTYPES 2024-09-07 12:37:10 +02:00
Simon Ser
301f133272 lib/irc: move over STD_MEMBERSHIP_NAMES 2024-09-07 12:36:13 +02:00
Simon Ser
9bcfd088c2 components/member-list: remove dead code 2024-09-07 12:31:07 +02:00
Simon Ser
39de184734 readme: accept patches on Codeberg 2024-09-03 21:08:13 +02:00
Simon Ser
2c0f2a80e9 lib/irc: remove stray hardcoded constant 2024-08-13 00:29:00 +02:00
Simon Ser
1c5dc652a9 Downgrade preact to v10.17.1 once again
Seeing this again:

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

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

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

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

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

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

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

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

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

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

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

Previously we only supporteed <CODE><COLOR>.

This patch enables stripping colors for all valid color formats.

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

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

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

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

Fixes: 1428ec4d4991 ("Add support for draft/read-marker")
2022-09-03 14:40:46 +02:00
Simon Ser
4780b9c709 Fetch read marker before backlog for user targets 2022-09-03 14:31:56 +02:00
Simon Ser
e7b69cec9a Limit composer length
Often times IRC servers will truncate messages which are too big.
2022-08-28 19:16:41 +02:00
xse
cfbd91d257 Make use of destBuffers when fetching history.
Fixes an issue where messages intended to go on the server's buffer end up on their own
2022-08-22 12:46:03 +02:00
Simon Ser
7138e43710 Ignore RPL_CHANNEL_URL 2022-08-22 10:35:50 +02:00
Simon Ser
89647472ae components/app: don't open buffer for CTCP messages
These are usually completely uninteresting messages, e.g. CTCP
VERSION or whatever.
2022-08-22 10:30:56 +02:00
Simon Ser
e2dc32c0d3 Update dependencies 2022-07-11 21:02:12 +02:00
Simon Ser
1bcd9d3607 ci: deploy to new server 2022-07-09 12:26:39 +02:00
Simon Ser
e4ebf5eb80 ci: fix deploy host
emersion.fr is now an alias for the new server. gamja hasn't been
migrated yet.
2022-07-08 21:24:09 +02:00
Simon Ser
1428ec4d49 Add support for draft/read-marker
References: https://github.com/ircv3/ircv3-specifications/pull/489
2022-07-01 13:35:27 +02:00
Arik
839e46360e Use monospace on <input> too
It looks like having "font-family: monospace" on <body> doesn't set it
for <input> too.
2022-07-01 13:34:22 +02:00
Simon Ser
d0064dd647 components/buffer: show disclaimer for +draft/channel-context messages 2022-06-28 15:55:35 +02:00
delthas
b9693d53ec Support @+draft/channel-context
See: https://github.com/ircv3/ircv3-specifications/pull/498
2022-06-28 15:33:38 +02:00
Simon Ser
f6ba40046f components/buffer-header: fix duplicate settings button 2022-06-28 15:11:48 +02:00
Simon Ser
54453c5f44 Fix invalid relative import
Worked locally because it's served at the root…
2022-06-27 17:16:33 +02:00
Simon Ser
fa80a56516 Add button to enable protocol handler in settings 2022-06-27 17:01:15 +02:00
Simon Ser
7cabb6f85b Add a setting for seconds in timestamps 2022-06-27 16:34:41 +02:00
Simon Ser
505a6fd5ab Workaround the sad state of base64 web APIs
This is necessary to make usernames/passwords with UTF-8 in them
work correctly.
2022-06-24 23:59:18 +02:00
Simon Ser
8e30806fec Upgrade dependencies 2022-06-14 19:58:50 +02:00
Simon Ser
f0c398a10c components/buffer-header: print bouncer network error if any 2022-06-09 15:54:29 +02:00
Simon Ser
baaf576d82 Add a settings dialog
Add an option to hide chat events or always expand them.

Closes: https://todo.sr.ht/~emersion/gamja/73
2022-06-08 16:57:16 +02:00
Simon Ser
e3c2d85a94 Fix ping config lost in ConnectForm
Reported-by: xse <xse@riseup.net>
References: https://lists.sr.ht/~emersion/public-inbox/patches/32126
2022-06-08 15:14:06 +02:00
Umar Getagazov
576b9d51eb components/app: switch to server buffer on close only if active
If the buffer that's being closed is not the active one, there's no
point in switching the user away to another buffer.
2022-06-08 15:05:26 +02:00
Simon Ser
6b04cb1417 Add support for bot mode
References: https://ircv3.net/specs/extensions/bot-mode
2022-06-08 15:04:27 +02:00
Simon Ser
8507500d74 components/scroll-manager: don't crash when Buffer is empty 2022-04-22 12:32:54 +02:00
Simon Ser
aaef4e1629 store: use lower-case for buffer keys 2022-04-22 12:04:11 +02:00
Simon Ser
cdd2da90a9 Update webpage title when switching buffer 2022-04-22 11:49:23 +02:00
Simon Ser
4a981997f0 Handle CHATHISTORY messages when reaching end of batch
Closes: https://todo.sr.ht/~emersion/gamja/115
2022-04-22 11:25:41 +02:00
Simon Ser
f45b51d981 commands: fix TypeError in kickban
The ban variable was undefined.
2022-04-14 10:53:35 +02:00
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: c428e504fe9c ("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: ab3d4dd66183 ("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: 86853eb2e552 ("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
42 changed files with 8909 additions and 1749 deletions

27
.build.yml Normal file
View File

@ -0,0 +1,27 @@
image: alpine/latest
packages:
- npm
- rsync
sources:
- https://codeberg.org/emersion/gamja.git
secrets:
- 7a146c8e-aeb4-46e7-99bf-05af7486bbe9 # deploy SSH key
artifacts:
- gamja/gamja.tar.gz
tasks:
- setup: |
cd gamja
npm install --include=dev
- build: |
cd gamja
npm run build
tar -czf gamja.tar.gz -C dist .
- lint: |
cd gamja
npm run -- lint --max-warnings 0
- 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-gamja@sheeta.emersion.fr:/srv/http/gamja

3
.gitignore vendored
View File

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

106
README.md
View File

@ -2,7 +2,7 @@
A simple IRC web client.
![screenshot](https://l.sr.ht/7Npm.png)
<img src="https://fs.emersion.fr/protected/img/gamja/main.png" alt="Screenshot" width="800">
## Usage
@ -10,98 +10,35 @@ Requires an IRC WebSocket server.
First install dependencies:
npm install --production
npm install --omit=dev
### [soju]
Add a WebSocket listener to soju, e.g. `listen wss://127.0.0.1:8080`.
Configure your reverse proxy to serve gamja files and proxy `/socket` to soju.
### [webircgateway]
Setup webircgateway to serve gamja files:
```ini
[fileserving]
enabled = true
webroot = /path/to/gamja
```
Then connect to webircgateway and append `?server=/webirc/websocket/` to the
URL.
### nginx
If you use nginx as a reverse HTTP proxy, make sure to bump the default read
timeout to a value higher than the IRC server PING interval. Example:
```
location /socket {
proxy_pass http://127.0.0.1:8080;
proxy_read_timeout 600s;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
If you are unable to configure the proxy timeout accordingly, you can set the
`server.ping` option in `config.json` to an interval, in seconds, between which
gamja will send opportunistic pings.
Then [configure an HTTP server] to serve the gamja files.
### 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.
## Query parameters
### Production build
gamja settings can be overridden using URL query parameters:
Optionally, [Parcel] can be used to build a minified version of gamja.
- `server`: path or URL to the WebSocket server
- `nick`: nickname
- `channels`: comma-separated list of channels to join (`#` needs to be escaped)
npm install --include=dev
npm run build
Alternatively, the channels can be set with the URL fragment (ie, by just
appending the channel name to the gamja URL).
## Configuration
## Configuration file
gamja default settings can be set using a `config.json` file at the root:
```js
{
// IRC server settings.
"server": {
// WebSocket URL or path to connect to (string).
"url": "wss://irc.example.org",
// Channel(s) to auto-join (string or array of strings).
"autojoin": "#gamja",
// Controls how the password UI is presented to the user. Set to
// "mandatory" to require a password, "optional" to accept one but not
// require it, and "disabled" to never ask for a password. Defaults to
// "optional".
"auth": "optional",
// Interval in seconds to send PING commands (number). Set to 0 to
// disable. Enabling PINGs can have an impact on client power usage and
// should only be enabled if necessary.
"ping": 60
}
}
```
gamja can be configured via a [configuration file] and via [URL parameters].
## Contributing
Send patches on the [mailing list], report bugs on the [issue tracker]. Discuss
in #soju on Libera Chat.
Send patches on [Codeberg], report bugs on the [issue tracker]. Discuss
in [#soju on Libera Chat].
## License
@ -109,8 +46,11 @@ AGPLv3, see LICENSE.
Copyright (C) 2020 The gamja Contributors
[gamja]: https://sr.ht/~emersion/gamja/
[soju]: https://soju.im
[webircgateway]: https://github.com/kiwiirc/webircgateway
[mailing list]: https://lists.sr.ht/~emersion/public-inbox
[gamja]: https://codeberg.org/emersion/gamja
[Codeberg]: https://codeberg.org/emersion/gamja
[issue tracker]: https://todo.sr.ht/~emersion/gamja
[Parcel]: https://parceljs.org
[configure an HTTP server]: doc/setup.md
[configuration file]: doc/config-file.md
[URL parameters]: doc/url-params.md
[#soju on Libera Chat]: ircs://irc.libera.chat/#soju

View File

@ -25,14 +25,14 @@ function getActiveChannel(app) {
return activeBuffer.name;
}
function setUserHostMode(app, args, mode) {
async function setUserHostMode(app, args, mode) {
let nick = args[0];
if (!nick) {
throw new Error("Missing nick");
}
let activeChannel = getActiveChannel(app);
let client = getActiveClient(app);
client.whois(nick).then((whois) => {
let whois = await client.whois(nick);
const info = whois[irc.RPL_WHOISUSER].params;
const user = info[2];
const host = info[3];
@ -40,7 +40,6 @@ function setUserHostMode(app, args, mode) {
command: "MODE",
params: [activeChannel, mode, `*!${user}@${host}`],
});
});
}
function markServerBufferUnread(app) {
@ -54,19 +53,25 @@ function markServerBufferUnread(app) {
}
const join = {
usage: "<name>",
name: "join",
usage: "<name> [password]",
description: "Join a channel",
execute: (app, args) => {
let channel = args[0];
if (!channel) {
throw new Error("Missing channel name");
}
if (args.length > 1) {
app.open(channel, null, args[1]);
} else {
app.open(channel);
}
},
};
const kick = {
usage: "<nick>",
name: "kick",
usage: "<nick> [comment]",
description: "Remove a user from the channel",
execute: (app, args) => {
let nick = args[0];
@ -79,6 +84,23 @@ const kick = {
},
};
const ban = {
name: "ban",
usage: "[nick]",
description: "Ban a user from the channel, or display the current ban list",
execute: (app, args) => {
if (args.length === 0) {
let activeChannel = getActiveChannel(app);
getActiveClient(app).send({
command: "MODE",
params: [activeChannel, "+b"],
});
} else {
return setUserHostMode(app, args, "+b");
}
},
};
function givemode(app, args, mode) {
// TODO: Handle several users at once
let nick = args[0];
@ -92,23 +114,22 @@ function givemode(app, args, mode) {
});
}
export default {
"ban": {
usage: "[nick]",
description: "Ban a user from the channel, or display the current ban list",
const commands = [
{
name: "away",
usage: "[message]",
description: "Set away message",
execute: (app, args) => {
if (args.length == 0) {
let activeChannel = getActiveChannel(app);
getActiveClient(app).send({
command: "MODE",
params: [activeChannel, "+b"],
});
} else {
return setUserHostMode(app, args, "+b");
const params = [];
if (args.length) {
params.push(args.join(" "));
}
getActiveClient(app).send({ command: "AWAY", params });
},
},
"buffer": {
ban,
{
name: "buffer",
usage: "<name>",
description: "Switch to a buffer",
execute: (app, args) => {
@ -122,39 +143,45 @@ export default {
throw new Error("Unknown buffer");
},
},
"close": {
{
name: "close",
description: "Close the current buffer",
execute: (app, args) => {
let activeBuffer = app.state.buffers.get(app.state.activeBuffer);
if (!activeBuffer || activeBuffer.type == BufferType.SERVER) {
if (!activeBuffer || activeBuffer.type === BufferType.SERVER) {
throw new Error("Not in a user or channel buffer");
}
app.close(activeBuffer.id);
},
},
"deop": {
{
name: "deop",
usage: "<nick>",
description: "Remove operator status for a user on this channel",
execute: (app, args) => givemode(app, args, "-o"),
},
"devoice": {
{
name: "devoice",
usage: "<nick>",
description: "Remove voiced status for a user on this channel",
execute: (app, args) => givemode(app, args, "-v"),
},
"disconnect": {
{
name: "disconnect",
description: "Disconnect from the server",
execute: (app, args) => {
app.disconnect();
},
},
"help": {
{
name: "help",
description: "Show help menu",
execute: (app, args) => {
app.openHelp();
},
},
"invite": {
{
name: "invite",
usage: "<nick>",
description: "Invite a user to the channel",
execute: (app, args) => {
@ -163,15 +190,17 @@ export default {
throw new Error("Missing nick");
}
let activeChannel = getActiveChannel(app);
getActiveClient(app).send({ command: "INVITE", params: [
nick, activeChannel,
]});
getActiveClient(app).send({
command: "INVITE",
params: [nick, activeChannel],
});
},
},
"j": join,
"join": join,
"kick": kick,
"kickban": {
{ ...join, name: "j" },
join,
kick,
{
name: "kickban",
usage: "<target>",
description: "Ban a user and removes them from the channel",
execute: (app, args) => {
@ -179,7 +208,8 @@ export default {
ban.execute(app, args);
},
},
"lusers": {
{
name: "lusers",
usage: "[<mask> [<target>]]",
description: "Request user statistics about the network",
execute: (app, args) => {
@ -187,7 +217,8 @@ export default {
markServerBufferUnread(app);
},
},
"me": {
{
name: "me",
usage: "<action>",
description: "Send an action message to the current buffer",
execute: (app, args) => {
@ -197,7 +228,8 @@ export default {
app.privmsg(target, text);
},
},
"mode": {
{
name: "mode",
usage: "[target] [modes] [mode args...]",
description: "Query or change a channel or user mode",
execute: (app, args) => {
@ -209,7 +241,8 @@ export default {
getActiveClient(app).send({ command: "MODE", params: args });
},
},
"motd": {
{
name: "motd",
usage: "[server]",
description: "Get the Message Of The Day",
execute: (app, args) => {
@ -217,7 +250,8 @@ export default {
markServerBufferUnread(app);
},
},
"msg": {
{
name: "msg",
usage: "<target> <message>",
description: "Send a message to a nickname or a channel",
execute: (app, args) => {
@ -226,7 +260,8 @@ export default {
getActiveClient(app).send({ command: "PRIVMSG", params: [target, text] });
},
},
"nick": {
{
name: "nick",
usage: "<nick>",
description: "Change current nickname",
execute: (app, args) => {
@ -234,7 +269,8 @@ export default {
getActiveClient(app).send({ command: "NICK", params: [newNick] });
},
},
"notice": {
{
name: "notice",
usage: "<target> <message>",
description: "Send a notice to a nickname or a channel",
execute: (app, args) => {
@ -243,12 +279,14 @@ export default {
getActiveClient(app).send({ command: "NOTICE", params: [target, text] });
},
},
"op": {
{
name: "op",
usage: "<nick>",
description: "Give a user operator status on this channel",
execute: (app, args) => givemode(app, args, "+o"),
},
"part": {
{
name: "part",
usage: "[reason]",
description: "Leave a channel",
execute: (app, args) => {
@ -261,7 +299,8 @@ export default {
getActiveClient(app).send({ command: "PART", params });
},
},
"query": {
{
name: "query",
usage: "<nick> [message]",
description: "Open a buffer to send messages to a nickname",
execute: (app, args) => {
@ -277,11 +316,12 @@ export default {
}
},
},
"quiet": {
{
name: "quiet",
usage: "[nick]",
description: "Quiet a user in the channel, or display the current quiet list",
execute: (app, args) => {
if (args.length == 0) {
if (args.length === 0) {
getActiveClient(app).send({
command: "MODE",
params: [getActiveChannel(app), "+q"],
@ -291,13 +331,15 @@ export default {
}
},
},
"quit": {
{
name: "quit",
description: "Quit",
execute: (app, args) => {
app.close({ name: SERVER_BUFFER });
},
},
"quote": {
{
name: "quote",
usage: "<command>",
description: "Send a raw IRC command to the server",
execute: (app, args) => {
@ -310,25 +352,29 @@ export default {
getActiveClient(app).send(msg);
},
},
"reconnect": {
{
name: "reconnect",
description: "Reconnect to the server",
execute: (app, args) => {
app.reconnect();
},
},
"setname": {
{
name: "setname",
usage: "<realname>",
description: "Change current realname",
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": {
{
name: "stats",
usage: "<query> [server]",
description: "Request server statistics",
execute: (app, args) => {
@ -344,7 +390,8 @@ export default {
markServerBufferUnread(app);
},
},
"topic": {
{
name: "topic",
usage: "<topic>",
description: "Change the topic of the current channel",
execute: (app, args) => {
@ -356,39 +403,39 @@ export default {
getActiveClient(app).send({ command: "TOPIC", params });
},
},
"unban": {
{
name: "unban",
usage: "<nick>",
description: "Remove a user from the ban list",
execute: (app, args) => {
return setUserHostMode(app, args, "-b");
},
},
"unquiet": {
{
name: "unquiet",
usage: "<nick>",
description: "Remove a user from the quiet list",
execute: (app, args) => {
return setUserHostMode(app, args, "-q");
},
},
"unvoice": {
usage: "<nick>",
description: "Remove a user from the voiced list",
execute: (app, args) => givemode(app, args, "-v"),
},
"voice": {
{
name: "voice",
usage: "<nick>",
description: "Give a user voiced status on this channel",
execute: (app, args) => givemode(app, args, "+v"),
},
"who": {
usage: "[<mask> [o]]",
{
name: "who",
usage: "<mask>",
description: "Retrieve a list of users",
execute: (app, args) => {
getActiveClient(app).send({ command: "WHO", params: args });
markServerBufferUnread(app);
},
},
"whois": {
{
name: "whois",
usage: "<nick>",
description: "Retrieve information about a user",
execute: (app, args) => {
@ -400,7 +447,8 @@ export default {
markServerBufferUnread(app);
},
},
"whowas": {
{
name: "whowas",
usage: "<nick> [count]",
description: "Retrieve information about an offline user",
execute: (app, args) => {
@ -411,4 +459,15 @@ export default {
markServerBufferUnread(app);
},
},
};
{
name: "list",
usage: "[filter]",
description: "Retrieve a list of channels from a network",
execute: (app, args) => {
getActiveClient(app).send({ command: "LIST", params: args });
markServerBufferUnread(app);
},
},
];
export default new Map(commands.map((cmd) => [cmd.name, cmd]));

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.handleInput = this.handleInput.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
if (props.username) {
this.state.username = props.username;
}
}
handleInput(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 onInput=${this.handleInput} 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

@ -1,4 +1,4 @@
import { html, Component } from "../lib/index.js";
import { html } from "../lib/index.js";
import linkify from "../lib/linkify.js";
import { strip as stripANSI } from "../lib/ansi.js";
import { BufferType, ServerStatus, getServerName } from "../state.js";
@ -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) {
@ -56,6 +44,9 @@ export default function BufferHeader(props) {
switch (props.bouncerNetwork.state) {
case "disconnected":
description = "Bouncer disconnected from network";
if (props.bouncerNetwork.error) {
description += ": " + props.bouncerNetwork.error;
}
break;
case "connecting":
description = "Bouncer connecting to network...";
@ -74,56 +65,89 @@ export default function BufferHeader(props) {
break;
}
if (props.isBouncer) {
if (props.server.isupport.get("BOUNCER_NETID")) {
actions = html`
let joinButton = html`
<button
key="join"
onClick=${handleJoinClick}
onClick=${props.onJoin}
>Join channel</button>
`;
let reconnectButton = html`
<button
key="reconect"
onClick=${props.onReconnect}
>Reconnect</button>
`;
let settingsButton = html`
<button
key="settings"
onClick="${props.onOpenSettings}"
>Settings</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=${handleManageNetworkClick}
onClick=${props.onManageNetwork}
>Manage network</button>
`;
} else {
actions = html`
<button
key="add"
onClick=${handleAddNetworkClick}
>Add network</button>
<button
key="disconnect"
class="danger"
onClick=${handleCloseClick}
>Disconnect</button>
`;
`);
}
} else {
actions = html`
if (fullyConnected) {
actions.push(html`
<button
key="join"
onClick=${handleJoinClick}
>Join channel</button>
<button
key="disconnect"
class="danger"
onClick=${handleCloseClick}
>Disconnect</button>
`;
key="add"
onClick=${props.onAddNetwork}
>Add network</button>
`);
} else if (props.server.status === ServerStatus.DISCONNECTED) {
actions.push(reconnectButton);
}
actions.push(settingsButton);
}
} else {
if (fullyConnected) {
actions.push(joinButton);
} else if (props.server.status === ServerStatus.DISCONNECTED) {
actions.push(reconnectButton);
}
actions.push(settingsButton);
}
break;
case BufferType.CHANNEL:
if (props.buffer.topic) {
description = linkify(stripANSI(props.buffer.topic), props.onChannelClick);
}
actions = html`
if (props.buffer.joined) {
actions.push(html`
<button
key="part"
class="danger"
onClick=${handleCloseClick}
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 +168,37 @@ 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>`);
}
if (props.user.bot) {
let desc = "This user is an automated bot.";
details.push(html`<abbr title=${desc}>bot</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,15 +207,15 @@ export default function BufferHeader(props) {
<button
key="close"
class="danger"
onClick=${handleCloseClick}
onClick=${props.onClose}
>Close</button>
`;
break;
}
let name = props.buffer.name;
if (props.buffer.type == BufferType.SERVER) {
name = getServerName(props.server, props.bouncerNetwork, props.isBouncer);
if (props.buffer.type === BufferType.SERVER) {
name = getServerName(props.server, props.bouncerNetwork);
}
return html`

View File

@ -1,42 +1,70 @@
import * as irc from "../lib/irc.js";
import { html, Component } from "../lib/index.js";
import { BufferType, Unread, getBufferURL, getServerName } from "../state.js";
import { strip as stripANSI } from "../lib/ansi.js";
import { html } from "../lib/index.js";
import { BufferType, Unread, ServerStatus, getBufferURL, getServerName } from "../state.js";
function BufferItem(props) {
function handleClick(event) {
event.preventDefault();
props.onClick();
}
let name = props.buffer.name;
if (props.buffer.type == BufferType.SERVER) {
name = getServerName(props.server, props.bouncerNetwork, props.isBouncer);
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);
}
let title;
let classes = ["type-" + props.buffer.type];
if (props.active) {
classes.push("active");
}
if (props.buffer.unread != Unread.NONE) {
if (props.buffer.unread !== Unread.NONE) {
classes.push("unread-" + props.buffer.unread);
}
switch (props.buffer.type) {
case BufferType.SERVER:
let isError = props.server.status === ServerStatus.DISCONNECTED;
if (props.bouncerNetwork && props.bouncerNetwork.error) {
isError = true;
}
if (isError) {
classes.push("error");
}
break;
case BufferType.NICK:
let user = props.server.users.get(name);
if (user && irc.isMeaningfulRealname(user.realname, name)) {
title = stripANSI(user.realname);
}
break;
}
return html`
<li class="${classes.join(" ")}">
<a href=${getBufferURL(props.buffer)} onClick=${handleClick}>${name}</a>
<a
href=${getBufferURL(props.buffer)}
title=${title}
onClick=${handleClick}
onMouseDown=${handleMouseDown}
>${name}</a>
</li>
`;
}
export default function BufferList(props) {
let items = Array.from(props.buffers.values()).map((buf) => {
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,10 +72,10 @@ export default function BufferList(props) {
key=${buf.id}
buffer=${buf}
server=${server}
isBouncer=${props.isBouncer}
bouncerNetwork=${bouncerNetwork}
onClick=${() => props.onBufferClick(buf)}
active=${props.activeBuffer == buf.id}
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, BufferEventsDisplayMode, getMessageURL, isMessageBeforeReceipt, SettingsContext } from "../state.js";
import * as store from "../store.js";
import Membership from "./membership.js";
function djb2(s) {
@ -20,21 +21,38 @@ function Nick(props) {
props.onClick();
}
let title;
if (props.user && irc.isMeaningfulRealname(props.user.realname, props.nick)) {
title = stripANSI(props.user.realname);
}
let colorIndex = djb2(props.nick) % 16 + 1;
return html`
<a href=${getNickURL(props.nick)} class="nick nick-${colorIndex}" onClick=${handleClick}>${props.nick}</a>
<a
href=${irc.formatURL({ entity: props.nick })}
title=${title}
class="nick nick-${colorIndex}"
onClick=${handleClick}
>${props.nick}</a>
`;
}
function Timestamp({ date, url }) {
function _Timestamp({ date, url, showSeconds }) {
if (!date) {
return html`<spam class="timestamp">--:--:--</span>`;
let timestamp = "--:--";
if (showSeconds) {
timestamp += ":--";
}
return html`<span class="timestamp">${timestamp}</span>`;
}
let hh = date.getHours().toString().padStart(2, "0");
let mm = date.getMinutes().toString().padStart(2, "0");
let timestamp = `${hh}:${mm}`;
if (showSeconds) {
let ss = date.getSeconds().toString().padStart(2, "0");
let timestamp = `${hh}:${mm}:${ss}`;
timestamp += ":" + ss;
}
return html`
<a
href=${url}
@ -47,6 +65,16 @@ function Timestamp({ date, url }) {
`;
}
function Timestamp(props) {
return html`
<${SettingsContext.Consumer}>
${(settings) => html`
<${_Timestamp} ...${props} showSeconds=${settings.secondsInTimestamps}/>
`}
</>
`;
}
/**
* Check whether a message can be folded.
*
@ -66,7 +94,7 @@ function canFoldMessage(msg) {
class LogLine extends Component {
shouldComponentUpdate(nextProps) {
return this.props.message !== nextProps.message;
return this.props.message !== nextProps.message || this.props.redacted !== nextProps.redacted;
}
render() {
@ -76,18 +104,20 @@ 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)}/>
<${Nick}
nick=${nick}
user=${server.users.get(nick)}
onClick=${() => onNickClick(nick)}
/>
`;
}
function createChannel(channel) {
function onClick(event) {
event.preventDefault();
onChannelClick(channel);
}
return html`
<a href=${getChannelURL(channel)} onClick=${onClick}>
<a href=${irc.formatURL({ entity: channel })} onClick=${onChannelClick}>
${channel}
</a>
`;
@ -95,16 +125,16 @@ 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);
if (ctcp) {
if (ctcp.command == "ACTION") {
if (ctcp.command === "ACTION") {
lineClass = "me-tell";
content = html`* ${createNick(msg.prefix.name)} ${linkify(stripANSI(ctcp.param), onChannelClick)}`;
} else {
@ -113,16 +143,21 @@ class LogLine extends Component {
`;
}
} else {
lineClass = "talk";
let prefix = "<", suffix = ">";
if (msg.command == "NOTICE") {
if (msg.command === "NOTICE") {
lineClass += " notice";
prefix = suffix = "-";
}
content = html`${prefix}${createNick(msg.prefix.name)}${suffix} ${linkify(stripANSI(text), onChannelClick)}`;
if (this.props.redacted) {
content = html`<i>This message has been deleted.</i>`;
} else {
content = html`${linkify(stripANSI(text), onChannelClick)}`;
lineClass += " talk";
}
content = html`<span class="nick-caret">${prefix}</span>${createNick(msg.prefix.name)}<span class="nick-caret">${suffix}</span> ${content}`;
}
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) {
@ -130,6 +165,10 @@ class LogLine extends Component {
}
}
if (msg.tags["+draft/channel-context"]) {
content = html`<em>(only visible to you)</em> ${content}`;
}
if (msg.isHighlight) {
lineClass += " highlight";
}
@ -161,15 +200,95 @@ class LogLine extends Component {
`;
break;
case "MODE":
target = msg.params[0];
let modeStr = msg.params[1];
let user = html`${createNick(msg.prefix.name)}`;
// TODO: use irc.forEachChannelModeUpdate()
if (buf.type === BufferType.CHANNEL && modeStr.length === 2 && server.cm(buf.name) === server.cm(target)) {
let plusMinus = modeStr[0];
let mode = modeStr[1];
let arg = msg.params[2];
let verb;
switch (mode) {
case "b":
verb = plusMinus === "+" ? "added" : "removed";
content = html`${user} has ${verb} a ban on ${arg}`;
break;
case "e":
verb = plusMinus === "+" ? "added" : "removed";
content = html`${user} has ${verb} a ban exemption on ${arg}`;
break;
case "l":
if (plusMinus === "+") {
content = html`${user} has set the channel user limit to ${arg}`;
} else {
content = html`${user} has unset the channel user limit`;
}
break;
case "i":
verb = plusMinus === "+" ? "marked": "unmarked";
content = html`${user} has ${verb} as invite-only`;
break;
case "m":
verb = plusMinus === "+" ? "marked": "unmarked";
content = html`${user} has ${verb} as moderated`;
break;
case "s":
verb = plusMinus === "+" ? "marked": "unmarked";
content = html`${user} has ${verb} as secret`;
break;
case "t":
verb = plusMinus === "+" ? "locked": "unlocked";
content = html`${user} has ${verb} the channel topic`;
break;
case "n":
verb = plusMinus === "+" ? "allowed": "denied";
content = html`${user} has ${verb} external messages to this channel`;
break;
}
if (content) {
break;
}
// Channel membership modes
let membership;
for (let prefix in irc.STD_MEMBERSHIP_MODES) {
if (irc.STD_MEMBERSHIP_MODES[prefix] === mode) {
membership = irc.STD_MEMBERSHIP_NAMES[prefix];
break;
}
}
if (membership && arg) {
let verb = plusMinus === "+" ? "granted" : "revoked";
let preposition = plusMinus === "+" ? "to" : "from";
content = html`
* ${createNick(msg.prefix.name)} sets mode ${msg.params.slice(1).join(" ")}
${user} has ${verb} ${membership} privileges ${preposition} ${createNick(arg)}
`;
break;
}
}
content = html`
${user} sets mode ${msg.params.slice(1).join(" ")}
`;
if (server.cm(buf.name) !== server.cm(target)) {
content = html`${content} on ${target}`;
}
break;
case "TOPIC":
let topic = msg.params[1];
if (topic) {
content = html`
${createNick(msg.prefix.name)} changed the topic to: ${linkify(stripANSI(topic), onChannelClick)}
`;
} else {
content = html`
${createNick(msg.prefix.name)} cleared the topic
`;
}
break;
case "INVITE":
invitee = msg.params[0];
@ -186,6 +305,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 +317,66 @@ 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;
// MONITOR messages are only displayed in user buffers
case irc.RPL_MONONLINE:
content = html`${createNick(buf.name)} is online`;
break;
case irc.RPL_MONOFFLINE:
content = html`${createNick(buf.name)} is offline`;
break;
default:
if (irc.isError(msg.command) && msg.command != irc.ERR_NOMOTD) {
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`
@ -241,11 +419,16 @@ class FoldGroup extends Component {
render() {
let msgs = this.props.messages;
let buf = this.props.buffer;
let server = this.props.server;
let onNickClick = this.props.onNickClick;
function createNick(nick) {
return html`
<${Nick} nick=${nick} onClick=${() => onNickClick(nick)}/>
<${Nick}
nick=${nick}
user=${server.users.get(nick)}
onClick=${() => onNickClick(nick)}
/>
`;
}
@ -266,7 +449,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 +471,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 +567,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 +654,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}
@ -417,23 +673,41 @@ function sameDate(d1, d2) {
export default class Buffer extends Component {
shouldComponentUpdate(nextProps) {
return this.props.buffer !== nextProps.buffer;
return this.props.buffer !== nextProps.buffer ||
this.props.settings !== nextProps.settings;
}
render() {
let buf = this.props.buffer;
let server = this.props.server;
if (!buf) {
return null;
}
let server = this.props.server;
let settings = this.props.settings;
let serverName = server.name;
let children = [];
if (buf.type == BufferType.SERVER) {
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}
@ -441,13 +715,46 @@ export default class Buffer extends Component {
message=${msg}
buffer=${buf}
server=${server}
redacted=${buf.redacted.has(msg.tags.msgid)}
onChannelClick=${onChannelClick}
onNickClick=${onNickClick}
onVerifyClick=${onVerifyClick}
/>
`;
}
function createFoldGroup(msgs) {
// Filter out PART → JOIN pairs
// Merge NICK change chains
let nickChanges = new Map();
let mergedMsgs = [];
for (let msg of msgs) {
let keep = true;
switch (msg.command) {
case "PART":
case "QUIT":
nickChanges.delete(msg.prefix.name);
break;
case "NICK":
let prev = nickChanges.get(msg.prefix.name);
if (!prev) {
// Future NICK messages may mutate this one
msg = { ...msg };
nickChanges.set(msg.params[0], msg);
break;
}
prev.params = msg.params;
nickChanges.delete(msg.prefix.name);
nickChanges.set(msg.params[0], prev);
keep = false;
break;
}
if (keep) {
mergedMsgs.push(msg);
}
}
msgs = mergedMsgs;
// Filter out PART → JOIN pairs, as well as no-op NICKs from previous step
let partIndexes = new Map();
let keep = [];
msgs.forEach((msg, i) => {
@ -458,6 +765,8 @@ export default class Buffer extends Component {
keep[partIndexes.get(msg.prefix.name)] = false;
partIndexes.delete(msg.prefix.name);
keep.push(false);
} else if (msg.command === "NICK" && msg.prefix.name === msg.params[0]) {
keep.push(false);
} else {
keep.push(true);
}
@ -483,10 +792,23 @@ export default class Buffer extends Component {
let hasUnreadSeparator = false;
let prevDate = new Date();
let foldMessages = [];
let lastMonitor = null;
buf.messages.forEach((msg) => {
let sep = [];
if (!hasUnreadSeparator && buf.type != BufferType.SERVER && buf.prevReadReceipt && msg.tags.time > buf.prevReadReceipt.time) {
if (settings.bufferEvents === BufferEventsDisplayMode.HIDE && canFoldMessage(msg)) {
return;
}
if (msg.command === irc.RPL_MONONLINE || msg.command === irc.RPL_MONOFFLINE) {
let skip = !lastMonitor || msg.command === lastMonitor;
lastMonitor = msg.command;
if (skip) {
return;
}
}
if (!hasUnreadSeparator && buf.type !== BufferType.SERVER && !isMessageBeforeReceipt(msg, buf.prevReadReceipt)) {
sep.push(html`<${UnreadSeparator} key="unread"/>`);
hasUnreadSeparator = true;
}
@ -499,12 +821,12 @@ export default class Buffer extends Component {
if (sep.length > 0) {
children.push(createFoldGroup(foldMessages));
children.push(sep);
children.push(...sep);
foldMessages = [];
}
// TODO: consider checking the time difference too
if (canFoldMessage(msg)) {
if (settings.bufferEvents === BufferEventsDisplayMode.FOLD && canFoldMessage(msg)) {
foldMessages.push(msg);
return;
}

View File

@ -1,5 +1,16 @@
import { html, Component, createRef } from "../lib/index.js";
function encodeContentDisposition(filename) {
// Encode filename according to RFC 5987 if necessary. Note,
// encodeURIComponent will percent-encode a superset of attr-char.
let encodedFilename = encodeURIComponent(filename);
if (encodedFilename === filename) {
return "attachment; filename=\"" + filename + "\"";
} else {
return "attachment; filename*=UTF-8''" + encodedFilename;
}
}
export default class Composer extends Component {
state = {
text: "",
@ -13,6 +24,9 @@ export default class Composer extends Component {
this.handleInput = this.handleInput.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleInputKeyDown = this.handleInputKeyDown.bind(this);
this.handleInputPaste = this.handleInputPaste.bind(this);
this.handleDragOver = this.handleDragOver.bind(this);
this.handleDrop = this.handleDrop.bind(this);
this.handleWindowKeyDown = this.handleWindowKeyDown.bind(this);
this.handleWindowPaste = this.handleWindowPaste.bind(this);
}
@ -116,11 +130,119 @@ export default class Composer extends Component {
this.setState({ text: autocomplete.text });
}
canUploadFiles() {
let client = this.props.client;
return client && client.isupport.filehost() && !this.props.readOnly;
}
async uploadFile(file) {
let client = this.props.client;
let endpoint = client.isupport.filehost();
let auth;
if (client.params.saslPlain) {
let params = client.params.saslPlain;
auth = "Basic " + btoa(params.username + ":" + params.password);
} else if (client.params.saslOauthBearer) {
auth = "Bearer " + client.params.saslOauthBearer.token;
}
let headers = {
"Content-Length": file.size,
"Content-Disposition": encodeContentDisposition(file.name),
};
if (file.type) {
headers["Content-Type"] = file.type;
}
if (auth) {
headers["Authorization"] = auth;
}
// TODO: show a loading UI while uploading
// TODO: show a cancel button
let resp = await fetch(endpoint, {
method: "POST",
body: file,
headers,
credentials: "include",
});
if (!resp.ok) {
throw new Error(`HTTP request failed (${resp.status})`);
}
let loc = resp.headers.get("Location");
if (!loc) {
throw new Error("filehost response missing Location header field");
}
return new URL(loc, endpoint);
}
async uploadFileList(fileList) {
let promises = [];
for (let file of fileList) {
promises.push(this.uploadFile(file));
}
let urls = await Promise.all(promises);
this.setState((state) => {
if (state.text) {
return { text: state.text + " " + urls.join(" ") };
} else {
return { text: urls.join(" ") };
}
});
}
async handleInputPaste(event) {
if (event.clipboardData.files.length === 0 || !this.canUploadFiles()) {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
await this.uploadFileList(event.clipboardData.files);
}
handleDragOver(event) {
if (event.dataTransfer.items.length === 0 || !this.canUploadFiles()) {
return;
}
for (let item of event.dataTransfer.items) {
if (item.kind !== "file") {
return;
}
}
event.preventDefault();
}
async handleDrop(event) {
if (event.dataTransfer.files.length === 0 || !this.canUploadFiles()) {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
await this.uploadFileList(event.dataTransfer.files);
}
handleWindowKeyDown(event) {
// If an <input> or <button> is focused, ignore.
if (document.activeElement !== document.body && document.activeElement.tagName !== "SECTION") {
if (document.activeElement && document.activeElement !== document.body) {
switch (document.activeElement.tagName.toLowerCase()) {
case "section":
case "a":
break;
default:
return;
}
}
// If a modifier is pressed, reserve for key bindings.
if (event.altKey || event.ctrlKey || event.metaKey) {
@ -143,7 +265,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 +289,12 @@ export default class Composer extends Component {
return;
}
let text = event.clipboardData.getData('text');
if (event.clipboardData.files.length > 0) {
this.handleInputPaste(event);
return;
}
let text = event.clipboardData.getData("text");
event.preventDefault();
event.stopImmediatePropagation();
@ -201,6 +328,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,9 +346,13 @@ 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}
onPaste=${this.handleInputPaste}
onDragOver=${this.handleDragOver}
onDrop=${this.handleDrop}
maxlength=${this.props.maxLen}
/>
</form>
`;

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,14 +10,14 @@ export default class ConnectForm extends Component {
rememberMe: false,
username: "",
realname: "",
autojoin: "",
autojoin: true,
};
nickInput = createRef();
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
this.handleInput = this.handleInput.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
if (props.params) {
@ -27,14 +28,13 @@ export default class ConnectForm extends Component {
rememberMe: props.params.autoconnect || false,
username: props.params.username || "",
realname: props.params.realname || "",
autojoin: (props.params.autojoin || []).join(","),
};
}
}
handleChange(event) {
handleInput(event) {
let target = event.target;
let value = target.type == "checkbox" ? target.checked : target.value;
let value = target.type === "checkbox" ? target.checked : target.value;
this.setState({ [target.name]: value });
}
@ -61,15 +61,15 @@ export default class ConnectForm extends Component {
username: params.username || params.nick,
password: this.state.password,
};
} else if (this.props.auth === "external") {
params.saslExternal = true;
} else if (this.props.auth === "oauth2") {
params.saslOauthBearer = this.props.params.saslOauthBearer;
}
this.state.autojoin.split(",").forEach(function(ch) {
ch = ch.trim();
if (!ch) {
return;
if (this.state.autojoin) {
params.autojoin = this.props.params.autojoin || [];
}
params.autojoin.push(ch);
});
this.props.onSubmit(params);
}
@ -107,12 +107,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" && this.props.auth !== "oauth2") {
auth = html`
<label>
Password:<br/>
@ -129,25 +129,25 @@ export default class ConnectForm extends Component {
`;
}
let autojoin = html`
let autojoin = null;
let channels = this.props.params.autojoin || [];
if (channels.length > 0) {
let s = channels.length > 1 ? "s" : "";
autojoin = html`
<label>
Auto-join channels:<br/>
<input
type="text"
type="checkbox"
name="autojoin"
value=${this.state.autojoin}
disabled=${disabled}
placeholder="Comma-separated list of channels"
checked=${this.state.autojoin}
/>
Auto-join channel${s} <strong>${channels.join(", ")}</strong>
</label>
<br/>
<br/><br/>
`;
// Show autojoin field in advanced options, except if it's pre-filled
let isAutojoinAdvanced = (this.props.params.autojoin || []).length === 0;
}
return html`
<form onChange=${this.handleChange} onSubmit=${this.handleSubmit}>
<form onInput=${this.handleInput} onSubmit=${this.handleSubmit}>
<h2>Connect to IRC</h2>
<label>
@ -159,13 +159,14 @@ export default class ConnectForm extends Component {
disabled=${disabled}
ref=${this.nickInput}
required
autofocus
/>
</label>
<br/><br/>
${auth}
${!isAutojoinAdvanced ? [autojoin, html`<br/>`] : null}
${autojoin}
<label>
<input
@ -212,7 +213,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 +221,6 @@ export default class ConnectForm extends Component {
/>
</label>
<br/><br/>
${isAutojoinAdvanced ? autojoin : null}
</details>
<br/>

View File

@ -21,13 +21,13 @@ export default class Dialog extends Component {
}
handleBackdropClick(event) {
if (event.target.className == "dialog") {
if (event.target.className === "dialog") {
this.dismiss();
}
}
handleKeyDown(event) {
if (event.key == "Escape") {
if (event.key === "Escape") {
this.dismiss();
}
}

View File

@ -1,4 +1,4 @@
import { html, Component } from "../lib/index.js";
import { html } from "../lib/index.js";
import { keybindings } from "../keybindings.js";
import commands from "../commands.js";
@ -6,7 +6,7 @@ function KeyBindingsHelp() {
let l = keybindings.map((binding) => {
let keys = [];
if (binding.ctrlKey) {
keys.psuh("Ctrl");
keys.push("Ctrl");
}
if (binding.altKey) {
keys.push("Alt");
@ -26,27 +26,32 @@ function KeyBindingsHelp() {
`;
});
return html`
<dl>
<dt><kbd>/</kbd></dt>
<dd>Start writing a command</dd>
l.push(html`
<dt><kbd>Tab</kbd></dt>
<dd>Automatically complete nickname or channel</dd>
`);
${l}
</dl>
`;
if (!window.matchMedia("(pointer: none)").matches) {
l.push(html`
<dt><strong>Middle mouse click</strong></dt>
<dd>Close buffer</dd>
`);
}
return html`<dl>${l}</dl>`;
}
function CommandsHelp() {
let l = Object.keys(commands).map((name) => {
let cmd = commands[name];
let l = [...commands.keys()].map((name) => {
let cmd = commands.get(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

@ -8,13 +8,17 @@ export default class JoinForm extends Component {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
this.handleInput = this.handleInput.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
if (props.channel) {
this.state.channel = props.channel;
}
}
handleChange(event) {
handleInput(event) {
let target = event.target;
let value = target.type == "checkbox" ? target.checked : target.value;
let value = target.type === "checkbox" ? target.checked : target.value;
this.setState({ [target.name]: value });
}
@ -30,7 +34,7 @@ export default class JoinForm extends Component {
render() {
return html`
<form onChange=${this.handleChange} onSubmit=${this.handleSubmit}>
<form onInput=${this.handleInput} onSubmit=${this.handleSubmit}>
<label>
Channel:<br/>
<input type="text" name="channel" value=${this.state.channel} autofocus required/>

View File

@ -1,5 +1,4 @@
import { html, Component } from "../lib/index.js";
import { getNickURL } from "../state.js";
import { strip as stripANSI } from "../lib/ansi.js";
import Membership from "./membership.js";
import * as irc from "../lib/irc.js";
@ -13,7 +12,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) {
@ -22,27 +22,9 @@ class MemberItem extends Component {
}
render() {
// XXX: If we were feeling creative we could generate unique colors for
// each item in ISUPPORT CHANMODES. But I am not feeling creative.
const membmap = {
"~": "owner",
"&": "admin",
"@": "op",
"%": "halfop",
"+": "voice",
};
const membclass = membmap[this.props.membership[0]] || "";
let membership = "";
if (this.props.membership) {
membership = html`
<span class="membership ${membclass}" title=${membclass}>
${this.props.membership}
</span>
`;
};
let title = null;
let title;
let user = this.props.user;
let classes = ["nick"];
if (user) {
let mask = "";
if (user.username && user.hostname) {
@ -61,13 +43,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"
href=${irc.formatURL({ entity: this.props.nick, enttype: "user" })}
class=${classes.join(" ")}
title=${title}
onClick=${this.handleClick}
>
@ -94,12 +81,13 @@ function sortMembers(a, b) {
return i - j;
}
return nickA < nickB ? -1 : 1;
return nickA.localeCompare(nickB);
}
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

@ -1,21 +1,14 @@
import { html, Component } from "../lib/index.js";
// XXX: If we were feeling creative we could generate unique colors for
// each item in ISUPPORT CHANMODES. But I am not feeling creative.
const names = {
"~": "owner",
"&": "admin",
"@": "op",
"%": "halfop",
"+": "voice",
};
import { html } from "../lib/index.js";
import * as irc from "../lib/irc.js";
export default function Membership(props) {
if (!this.props.value) {
return null;
}
const name = names[this.props.value[0]] || "";
// XXX: If we were feeling creative we could generate unique colors for
// each item in ISUPPORT CHANMODES. But I am not feeling creative.
const name = irc.STD_MEMBERSHIP_NAMES[this.props.value[0]] || "";
return html`
<span class="membership ${name}" title=${name}>
${this.props.value}

View File

@ -14,7 +14,7 @@ export default class NetworkForm extends Component {
prevParams = null;
state = {
...defaultParams,
isNew: true,
autojoin: true,
};
constructor(props) {
@ -22,11 +22,9 @@ export default class NetworkForm extends Component {
this.prevParams = { ...defaultParams };
this.handleChange = this.handleChange.bind(this);
this.handleInput = this.handleInput.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) {
@ -37,9 +35,9 @@ export default class NetworkForm extends Component {
}
}
handleChange(event) {
handleInput(event) {
let target = event.target;
let value = target.type == "checkbox" ? target.checked : target.value;
let value = target.type === "checkbox" ? target.checked : target.value;
this.setState({ [target.name]: value });
}
@ -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,14 +69,31 @@ 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}>
<form onInput=${this.handleInput} onSubmit=${this.handleSubmit}>
<label>
Hostname:<br/>
<input type="text" name="host" value=${this.state.host} autofocus required/>
</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.handleInput = this.handleInput.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleInput(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 onInput=${this.handleInput} 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>
`;
}
}

View File

@ -1,4 +1,4 @@
import { html, Component } from "../lib/index.js";
import { Component } from "../lib/index.js";
let store = new Map();
@ -11,10 +11,10 @@ export default class ScrollManager extends Component {
isAtBottom() {
let target = this.props.target.current;
return target.scrollTop >= target.scrollHeight - target.offsetHeight;
return Math.abs(target.scrollHeight - target.clientHeight - target.scrollTop) <= 10;
}
saveScrollPosition() {
saveScrollPosition(scrollKey) {
let target = this.props.target.current;
let sticky = target.querySelectorAll(this.props.stickTo);
@ -29,11 +29,14 @@ export default class ScrollManager extends Component {
}
}
store.set(this.props.scrollKey, stickToKey);
store.set(scrollKey, stickToKey);
}
restoreScrollPosition() {
let target = this.props.target.current;
if (!target.firstChild) {
return;
}
let stickToKey = store.get(this.props.scrollKey);
if (!stickToKey) {
@ -45,13 +48,13 @@ export default class ScrollManager extends Component {
}
}
if (target.scrollTop == 0) {
if (target.scrollTop === 0) {
this.props.onScrollTop();
}
}
handleScroll() {
if (this.props.target.current.scrollTop == 0) {
if (this.props.target.current.scrollTop === 0) {
this.props.onScrollTop();
}
}
@ -61,9 +64,9 @@ export default class ScrollManager extends Component {
this.props.target.current.addEventListener("scroll", this.handleScroll);
}
componentWillReceiveProps(nextProps) {
if (this.props.scrollKey !== nextProps.scrollKey || this.props.children !== nextProps.children) {
this.saveScrollPosition();
getSnapshotBeforeUpdate(prevProps) {
if (this.props.scrollKey !== prevProps.scrollKey || this.props.children !== prevProps.children) {
this.saveScrollPosition(prevProps.scrollKey);
}
}
@ -76,7 +79,7 @@ export default class ScrollManager extends Component {
componentWillUnmount() {
this.props.target.current.removeEventListener("scroll", this.handleScroll);
this.saveScrollPosition();
this.saveScrollPosition(this.props.scrollKey);
}
render() {

112
components/settings-form.js Normal file
View File

@ -0,0 +1,112 @@
import { html, Component } from "../lib/index.js";
export default class SettingsForm extends Component {
state = {};
constructor(props) {
super(props);
this.state.secondsInTimestamps = props.settings.secondsInTimestamps;
this.state.bufferEvents = props.settings.bufferEvents;
this.handleInput = this.handleInput.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleInput(event) {
let target = event.target;
let value = target.type === "checkbox" ? target.checked : target.value;
this.setState({ [target.name]: value }, () => {
this.props.onChange(this.state);
});
}
handleSubmit(event) {
event.preventDefault();
this.props.onClose();
}
registerProtocol() {
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);
}
}
render() {
let protocolHandler = null;
if (this.props.showProtocolHandler) {
protocolHandler = html`
<div class="protocol-handler">
<div class="left">
Set gamja as your default IRC client for this browser.
IRC links will be automatically opened here.
</div>
<div class="right">
<button type="button" onClick=${() => this.registerProtocol()}>
Enable
</button>
</div>
</div>
<br/><br/>
`;
}
return html`
<form onInput=${this.handleInput} onSubmit=${this.handleSubmit}>
<label>
<input
type="checkbox"
name="secondsInTimestamps"
checked=${this.state.secondsInTimestamps}
/>
Show seconds in time indicator
</label>
<br/><br/>
<label>
<input
type="radio"
name="bufferEvents"
value="fold"
checked=${this.state.bufferEvents === "fold"}
/>
Show and fold chat events
</label>
<br/>
<label>
<input
type="radio"
name="bufferEvents"
value="expand"
checked=${this.state.bufferEvents === "expand"}
/>
Show and expand chat events
</label>
<br/>
<label>
<input
type="radio"
name="bufferEvents"
value="hide"
checked=${this.state.bufferEvents === "hide"}
/>
Hide chat events
</label>
<br/><br/>
${protocolHandler}
<button type="button" class="danger" onClick=${() => this.props.onDisconnect()}>
Disconnect
</button>
<button>
Close
</button>
</form>
`;
}
}

171
components/switcher-form.js Normal file
View File

@ -0,0 +1,171 @@
import { html, Component } from "../lib/index.js";
import { BufferType, getBufferURL, getServerName } from "../state.js";
import * as irc from "../lib/irc.js";
class SwitcherItem extends Component {
constructor(props) {
super(props);
this.handleClick = this.handleClick.bind(this);
}
handleClick(event) {
event.preventDefault();
this.props.onClick();
}
render() {
let class_ = this.props.selected ? "selected" : "";
return html`
<li>
<a
href=${getBufferURL(this.props.buffer)}
class=${class_}
onClick=${this.handleClick}
>
<span class="server">
${getServerName(this.props.server, this.props.bouncerNetwork)}
</span>
${this.props.buffer.name}
</a>
</li>
`;
}
}
function matchString(s, query) {
return s.toLowerCase().includes(query) ? 1 : 0;
}
function matchBuffer(buf, server, query) {
let score = 2 * matchString(buf.name, query);
switch (buf.type) {
case BufferType.CHANNEL:
score += matchString(buf.topic || "", query);
break;
case BufferType.NICK:
let user = server.users.get(buf.name);
if (user && user.realname && irc.isMeaningfulRealname(user.realname, buf.name)) {
score += matchString(user.realname, query);
}
break;
}
return score;
}
export default class SwitcherForm extends Component {
state = {
query: "",
selected: 0,
};
constructor(props) {
super(props);
this.handleInput = this.handleInput.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleKeyUp = this.handleKeyUp.bind(this);
}
getSuggestions() {
let query = this.state.query.toLowerCase();
let l = [];
let scores = new Map();
for (let buf of this.props.buffers.values()) {
if (buf.type === BufferType.SERVER) {
continue;
}
let score = 0;
if (query !== "") {
let server = this.props.servers.get(buf.server);
score = matchBuffer(buf, server, query);
if (!score) {
continue;
}
}
scores.set(buf.id, score);
l.push(buf);
}
l.sort((a, b) => {
return scores.get(b.id) - scores.get(a.id);
});
return l.slice(0, 20);
}
handleInput(event) {
let target = event.target;
this.setState({ [target.name]: target.value });
}
handleSubmit(event) {
event.preventDefault();
this.props.onSubmit(this.getSuggestions()[this.state.selected]);
}
handleKeyUp(event) {
switch (event.key) {
case "ArrowUp":
event.stopPropagation();
this.move(-1);
break;
case "ArrowDown":
event.stopPropagation();
this.move(1);
break;
}
}
move(delta) {
let numSuggestions = this.getSuggestions().length;
this.setState((state) => {
return {
selected: (state.selected + delta + numSuggestions) % numSuggestions,
};
});
}
render() {
let items = this.getSuggestions().map((buf, i) => {
let server = this.props.servers.get(buf.server);
let bouncerNetwork = null;
if (server.bouncerNetID) {
bouncerNetwork = this.props.bouncerNetworks.get(server.bouncerNetID);
}
return html`
<${SwitcherItem}
buffer=${buf}
server=${server}
bouncerNetwork=${bouncerNetwork}
selected=${this.state.selected === i}
onClick=${() => this.props.onSubmit(buf)}
/>
`;
});
return html`
<form
onInput=${this.handleInput}
onSubmit=${this.handleSubmit}
onKeyUp=${this.handleKeyUp}
>
<input
type="search"
name="query"
value=${this.state.query}
placeholder="Filter"
autocomplete="off"
autofocus
/>
<ul class="switcher-list">
${items}
</ul>
</form>
`;
}
}

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.handleInput = this.handleInput.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleInput(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 onInput=${this.handleInput} onSubmit=${this.handleSubmit}>
<p>Your account <strong>${this.props.account}</strong> has been created, but a verification code is required to complete the registration.</p>
<p>${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>
`;
}
}

79
dev-server.js Normal file
View File

@ -0,0 +1,79 @@
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", (err) => {
console.log(err);
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);

50
doc/config-file.md Normal file
View File

@ -0,0 +1,50 @@
# Configuration file
gamja can be configured using a `config.json` file at the root. Example:
```json
{
"server": {
"url": "wss://irc.example.org",
"autojoin": "#gamja"
},
"oauth2": {
"url": "https://auth.example.org",
"client_id": "asdf"
}
}
```
Errors while parsing the configuration file are logged in the
[browser's web console].
## IRC server
The `server` object configures the IRC server.
- `url` (string): WebSocket URL or path to connect to. Defaults to `/socket`.
- `autojoin` (string or array of strings): Channel(s) to automatically join
after connecting.
- `auth` (string): configure how the password UI is presented to the user. Set
to `mandatory` to require a password, `optional` to accept one but not
require it, `disabled` to never ask for a password, `external` to use SASL
EXTERNAL, `oauth2` to use SASL OAUTHBEARER. Defaults to `optional`.
- `nick` (string): default nickname. If it contains a `*` character, it will be
replaced with a random string.
- `autoconnect` (boolean): don't display the login UI, immediately connect to
the server
- `ping` (number): interval in seconds to send PING commands. Set to 0 to
disable, this is the default. Enabling PINGs can have an impact on client
power usage and should only be enabled if necessary.
## OAuth 2.0
The `oauth2` object configures OAuth 2.0 authentication.
- `url` (string): OAuth 2.0 server URL. The server must support OAuth 2.0
Authorization Server Metadata (RFC 8414) or OpenID Connect Discovery.
- `client_id` (string): OAuth 2.0 client ID.
- `client_secret` (string): OAuth 2.0 client secret.
- `scope` (string): OAuth 2.0 scope.
[browser's web console]: https://firefox-source-docs.mozilla.org/devtools-user/web_console/index.html

68
doc/setup.md Normal file
View File

@ -0,0 +1,68 @@
# Setting up gamja
An HTTP server must be configured to serve the gamja static files. Usually,
the same HTTP server is used as a reverse proxy for the IRC WebSocket.
## [soju]
Add a WebSocket listener to soju, e.g. `listen ws+insecure://127.0.0.1:8080`.
Then configure your reverse proxy to serve gamja files and proxy `/socket` to
soju.
## [webircgateway]
Setup webircgateway to serve gamja files:
```ini
[fileserving]
enabled = true
webroot = /path/to/gamja
```
Then configure gamja to connect to `/webirc/websocket/` (either by setting
`server.url` in the [configuration file], or by appending
`?server=/webirc/websocket/` to the URL).
## nginx
If you use nginx as a reverse HTTP proxy, make sure to bump the default read
timeout to a value higher than the IRC server PING interval. Example:
```
location / {
root /path/to/gamja;
}
location /socket {
proxy_pass http://127.0.0.1:8080;
proxy_read_timeout 600s;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
If you are unable to configure the proxy timeout accordingly, or if your IRC
server doesn't send PINGs, you can set the `server.ping` option in
`config.json` (see below).
## [kimchi]
Setup kimchi to serve gamja files and proxy the WebSocket connection:
```
site irc.example.org {
file_server /path/to/gamja
}
site irc.example.org/socket {
reverse_proxy http://127.0.0.1:8080
}
```
[soju]: https://soju.im
[webircgateway]: https://github.com/kiwiirc/webircgateway
[kimchi]: https://sr.ht/~emersion/kimchi/
[configuration file]: config-file.md

15
doc/url-params.md Normal file
View File

@ -0,0 +1,15 @@
# URL parameters
gamja settings can be overridden using URL query parameters:
- `server`: path or URL to the WebSocket server
- `nick`: nickname (if the character `*` appears in the string, it will be
replaced with a randomly generated value)
- `channels`: comma-separated list of channels to join (`#` needs to be escaped)
- `open`: [IRC URL] to open
- `debug`: enable debug logs if set to `1`, disable debug logs if set to `0`
Alternatively, the channels can be set with the URL fragment (ie, by just
appending the channel name to the gamja URL).
[IRC URL]: https://datatracker.ietf.org/doc/html/draft-butcher-irc-url-04

56
eslint.config.js Normal file
View File

@ -0,0 +1,56 @@
import globals from "globals";
import js from "@eslint/js";
import stylisticJs from "@stylistic/eslint-plugin-js";
export default [
{
ignores: ["dist/"],
},
js.configs.recommended,
{
languageOptions: {
globals: {
...globals.browser,
"process": "readonly",
},
},
plugins: { "@stylistic/js": stylisticJs },
rules: {
"no-case-declarations": "off",
"no-unused-vars": ["error", {
args: "none",
caughtErrorsIgnorePattern: "^_",
destructuredArrayIgnorePattern: "^_",
}],
"no-var": "error",
"no-eval": "error",
"no-implied-eval": "error",
"eqeqeq": "error",
"no-invalid-this": "error",
"no-extend-native": "error",
"prefer-arrow-callback": "error",
"no-implicit-globals": "error",
"no-throw-literal": "error",
"no-implicit-coercion": "warn",
"object-shorthand": "warn",
"curly": "warn",
"camelcase": "warn",
"@stylistic/js/indent": ["warn", "tab"],
"@stylistic/js/quotes": ["warn", "double"],
"@stylistic/js/semi": "warn",
"@stylistic/js/brace-style": ["warn", "1tbs"],
"@stylistic/js/comma-dangle": ["warn", "always-multiline"],
"@stylistic/js/comma-spacing": "warn",
"@stylistic/js/arrow-parens": "warn",
"@stylistic/js/arrow-spacing": "warn",
"@stylistic/js/block-spacing": "warn",
"@stylistic/js/object-curly-spacing": ["warn", "always"],
"@stylistic/js/object-curly-newline": ["warn", {
multiline: true,
consistent: true,
}],
"@stylistic/js/array-bracket-spacing": ["warn", "never"],
"@stylistic/js/array-bracket-newline": ["warn", "consistent"],
},
},
];

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, receiptFromMessage } from "./state.js";
function getSiblingBuffer(buffers, bufID, delta) {
let bufList = Array.from(buffers.values());
@ -19,25 +19,29 @@ 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 };
}, () => {
app.updateDocumentTitle();
});
},
},
@ -92,6 +96,14 @@ export const keybindings = [
}
},
},
{
key: "k",
ctrlKey: true,
description: "Switch to a buffer",
execute: (app) => {
app.openDialog("switch");
},
},
];
export function setup(app) {
@ -109,9 +121,9 @@ export function setup(app) {
return;
}
candidates = candidates.filter((binding) => {
return !!binding.altKey == event.altKey && !!binding.ctrlKey == event.ctrlKey;
return Boolean(binding.altKey) === event.altKey && Boolean(binding.ctrlKey) === event.ctrlKey;
});
if (candidates.length != 1) {
if (candidates.length !== 1) {
return;
}
event.preventDefault();

View File

@ -10,10 +10,26 @@ const COLOR_HEX = "\x04";
const REVERSE_COLOR = "\x16";
const RESET = "\x0F";
const HEX_COLOR_LENGTH = 6;
function isDigit(ch) {
return ch >= "0" && ch <= "9";
}
function isHexColor(text) {
if (text.length < HEX_COLOR_LENGTH) {
return false;
}
for (let i = 0; i < HEX_COLOR_LENGTH; i++) {
let ch = text[i].toUpperCase();
let ok = (ch >= "0" && ch <= "9") || (ch >= "A" && ch <= "F");
if (!ok) {
return false;
}
}
return true;
}
export function strip(text) {
let out = "";
for (let i = 0; i < text.length; i++) {
@ -35,7 +51,7 @@ export function strip(text) {
if (isDigit(text[i + 1])) {
i++;
}
if (text[i + 1] == "," && isDigit(text[i + 2])) {
if (text[i + 1] === "," && isDigit(text[i + 2])) {
i += 2;
if (isDigit(text[i + 1])) {
i++;
@ -43,7 +59,13 @@ export function strip(text) {
}
break;
case COLOR_HEX:
i += 6;
if (!isHexColor(text.slice(i + 1))) {
break;
}
i += HEX_COLOR_LENGTH;
if (text[i + 1] === "," && isHexColor(text.slice(i + 2))) {
i += 1 + HEX_COLOR_LENGTH;
}
break;
default:
out += ch;

42
lib/base64.js Normal file
View File

@ -0,0 +1,42 @@
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
/* The JS world is still in the stone age. We're in 2022 and we still don't
* have the technology to correctly base64-encode a UTF-8 string. Can't wait
* the next industrial revolution.
*
* For more info, see:
* https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem
*/
export function encode(data) {
if (!window.TextEncoder) {
return btoa(data);
}
let encoder = new TextEncoder();
let bytes = encoder.encode(data);
let trailing = bytes.length % 3;
let out = "";
for (let i = 0; i < bytes.length - trailing; i += 3) {
let u24 = (bytes[i] << 16) + (bytes[i + 1] << 8) + bytes[i + 2];
out += alphabet[(u24 >> 18) & 0x3F];
out += alphabet[(u24 >> 12) & 0x3F];
out += alphabet[(u24 >> 6) & 0x3F];
out += alphabet[u24 & 0x3F];
}
if (trailing === 1) {
let u8 = bytes[bytes.length - 1];
out += alphabet[u8 >> 2];
out += alphabet[(u8 << 4) & 0x3F];
out += "==";
} else if (trailing === 2) {
let u16 = (bytes[bytes.length - 2] << 8) + bytes[bytes.length - 1];
out += alphabet[u16 >> 10];
out += alphabet[(u16 >> 4) & 0x3F];
out += alphabet[(u16 << 2) & 0x3F];
out += "=";
}
return out;
}

File diff suppressed because it is too large Load Diff

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.es.js";
export { linkifyjs };

View File

@ -1,9 +1,14 @@
import * as base64 from "./base64.js";
// RFC 1459
export const RPL_WELCOME = "001";
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 +17,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 +34,28 @@ 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_CHANNEL_URL = "328";
export const RPL_CREATIONTIME = "329";
export const RPL_QUIETLIST = "728";
export const RPL_ENDOFQUIETLIST = "729";
// IRCv3 MONITOR: https://ircv3.net/specs/extensions/monitor
@ -59,9 +74,24 @@ export const ERR_SASLTOOLONG = "905";
export const ERR_SASLABORTED = "906";
export const ERR_SASLALREADY = "907";
export const STD_MEMBERSHIPS = "~&@%+";
export const STD_CHANTYPES = "#&+!";
export const STD_CHANMODES = "beI,k,l,imnst";
export const STD_MEMBERSHIP_NAMES = {
"~": "owner",
"&": "admin",
"@": "operator",
"%": "halfop",
"+": "voice",
};
export const STD_MEMBERSHIP_MODES = {
"~": "q",
"&": "a",
"@": "o",
"%": "h",
"+": "v",
};
const STD_MEMBERSHIPS = "~&@%+";
const STD_CHANTYPES = "#&+!";
const tagEscapeMap = {
";": "\\:",
@ -90,10 +120,10 @@ export function parseTags(s) {
let parts = s.split("=", 2);
let k = parts[0];
let v = null;
if (parts.length == 2) {
if (parts.length === 2) {
v = unescapeTag(parts[1]);
if (v.endsWith("\\")) {
v = v.slice(0, v.length - 1)
v = v.slice(0, v.length - 1);
}
}
tags[k] = v;
@ -115,38 +145,32 @@ export function formatTags(tags) {
}
export function parsePrefix(s) {
let prefix = {
name: null,
user: null,
host: null,
};
let host = null;
let i = s.indexOf("@");
if (i < 0) {
prefix.name = s;
return prefix;
}
prefix.host = s.slice(i + 1);
if (i > 0) {
host = s.slice(i + 1);
s = s.slice(0, i);
i = s.indexOf("!");
if (i < 0) {
prefix.name = s;
return prefix;
}
prefix.name = s.slice(0, i);
prefix.user = s.slice(i + 1);
return prefix;
let user = null;
i = s.indexOf("!");
if (i > 0) {
user = s.slice(i + 1);
s = s.slice(0, i);
}
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) {
@ -217,7 +241,7 @@ export function formatMessage(msg) {
s += msg.command;
if (msg.params && msg.params.length > 0) {
for (let i = 0; i < msg.params.length - 1; i++) {
s += " " + msg.params[i]
s += " " + msg.params[i];
}
let last = String(msg.params[msg.params.length - 1]);
@ -247,11 +271,12 @@ export function parseTargetPrefix(s, allowedPrefixes = STD_MEMBERSHIPS) {
const alphaNum = (() => {
try {
return new RegExp(/^\p{L}$/, "u");
} catch (e) {
return new RegExp(/^[\p{L}0-9]$/, "u");
} catch (_e) {
return new RegExp(/^[a-zA-Z0-9]$/, "u");
}
})();
const space = new RegExp(/^\s$/);
function isWordBoundary(ch) {
switch (ch) {
@ -259,21 +284,44 @@ function isWordBoundary(ch) {
case "_":
case "|":
return false;
case "\u00A0":
return true;
default:
return !alphaNum.test(ch);
}
}
function isURIPrefix(text) {
for (let i = text.length - 1; i >= 0; i--) {
if (space.test(text[i])) {
text = text.slice(i);
break;
}
}
let i = text.indexOf("://");
if (i <= 0) {
return false;
}
// See RFC 3986 section 3
let ch = text[i - 1];
switch (ch) {
case "+":
case "-":
case ".":
return true;
default:
return alphaNum.test(ch);
}
}
export function isHighlight(msg, nick, cm) {
if (msg.command != "PRIVMSG" && msg.command != "NOTICE") {
if (msg.command !== "PRIVMSG" && msg.command !== "NOTICE") {
return false;
}
nick = cm(nick);
if (msg.prefix && cm(msg.prefix.name) == nick) {
if (msg.prefix && cm(msg.prefix.name) === nick) {
return false; // Our own messages aren't highlights
}
@ -289,10 +337,10 @@ 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)) {
if (isWordBoundary(left) && isWordBoundary(right) && !isURIPrefix(text.slice(0, i))) {
return true;
}
@ -301,7 +349,7 @@ export function isHighlight(msg, nick, cm) {
}
export function isServerBroadcast(msg) {
if (msg.command != "PRIVMSG" && msg.command != "NOTICE") {
if (msg.command !== "PRIVMSG" && msg.command !== "NOTICE") {
return false;
}
return msg.params[0].startsWith("$");
@ -339,7 +387,7 @@ export function formatDate(date) {
}
export function parseCTCP(msg) {
if (msg.command != "PRIVMSG" && msg.command != "NOTICE") {
if (msg.command !== "PRIVMSG" && msg.command !== "NOTICE") {
return null;
}
@ -363,12 +411,21 @@ export function parseCTCP(msg) {
return ctcp;
}
export function parseISUPPORT(tokens, params) {
let changed = [];
function unescapeISUPPORTValue(s) {
return s.replace(/\\x[0-9A-Z]{2}/gi, (esc) => {
let hex = esc.slice(2);
return String.fromCharCode(parseInt(hex, 16));
});
}
export class Isupport {
raw = new Map();
parse(tokens) {
tokens.forEach((tok) => {
if (tok.startsWith("-")) {
let k = tok.slice(1);
params.delete(k.toUpperCase());
this.raw.delete(k.toUpperCase());
return;
}
@ -376,15 +433,124 @@ export function parseISUPPORT(tokens, params) {
let k = tok, v = "";
if (i >= 0) {
k = tok.slice(0, i);
v = tok.slice(i + 1);
v = unescapeISUPPORTValue(tok.slice(i + 1));
}
k = k.toUpperCase();
params.set(k, v);
changed.push(k);
this.raw.set(k, v);
});
return changed;
}
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;
}
bot() {
return this.raw.get("BOT");
}
userLen() {
if (!this.raw.has("USERLEN")) {
return 20;
}
return parseInt(this.raw.get("USERLEN"), 10);
}
hostLen() {
if (!this.raw.has("HOSTLEN")) {
return 63;
}
return parseInt(this.raw.get("HOSTLEN"), 10);
}
lineLen() {
if (!this.raw.has("LINELEN")) {
return 512;
}
return parseInt(this.raw.get("LINELEN"), 10);
}
filehost() {
return this.raw.get("SOJU.IM/FILEHOST");
}
}
export function getMaxPrivmsgLen(isupport, nick, target) {
let user = "_".repeat(isupport.userLen());
let host = "_".repeat(isupport.hostLen());
let prefix = { name: nick, user, host };
let msg = { prefix, command: "PRIVMSG", params: [target, ""] };
let raw = formatMessage(msg) + "\r\n";
return isupport.lineLen() - raw.length;
}
export const CaseMapping = {
@ -406,13 +572,13 @@ export const CaseMapping = {
let ch = str[i];
if ("A" <= ch && ch <= "Z") {
ch = ch.toLowerCase();
} else if (ch == "{") {
} else if (ch === "{") {
ch = "[";
} else if (ch == "}") {
} else if (ch === "}") {
ch = "]";
} else if (ch == "\\") {
} else if (ch === "\\") {
ch = "|";
} else if (ch == "~") {
} else if (ch === "~") {
ch = "^";
}
out += ch;
@ -426,11 +592,11 @@ export const CaseMapping = {
let ch = str[i];
if ("A" <= ch && ch <= "Z") {
ch = ch.toLowerCase();
} else if (ch == "{") {
} else if (ch === "{") {
ch = "[";
} else if (ch == "}") {
} else if (ch === "}") {
ch = "]";
} else if (ch == "\\") {
} else if (ch === "\\") {
ch = "|";
}
out += ch;
@ -593,11 +759,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 +805,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 +814,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 +822,151 @@ 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 function formatURL({ host, enttype, entity } = {}) {
host = host || "";
entity = entity || "";
let s = "irc://" + host + "/" + encodeURIComponent(entity);
if (enttype) {
s += ",is" + enttype;
}
return s;
}
export class CapRegistry {
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(" ")] };
}
}
const maxSASLLength = 400;
export function generateAuthenticateMessages(payload) {
let encoded = base64.encode(payload);
// <= instead of < because we need to send a final empty response if the
// last chunk is exactly 400 bytes long
let msgs = [];
for (let i = 0; i <= encoded.length; i += maxSASLLength) {
let chunk = encoded.substring(i, i + maxSASLLength);
msgs.push({ command: "AUTHENTICATE", params: [chunk || "+"] });
}
return msgs;
}

View File

@ -1,65 +1,65 @@
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");
linkifyjs.registerCustomProtocol("geo", true);
children.push(text.substring(last, start));
children.push(transformChannel(channel));
const IRCChannelToken = linkifyjs.createTokenClass("ircChannel", {
isLink: true,
toHref() {
return "irc:///" + this.v;
},
});
last = end;
}
children.push(text.substring(last));
linkifyjs.registerPlugin("ircChannel", ({ scanner, parser }) => {
const { POUND, UNDERSCORE, DOT, HYPHEN } = scanner.tokens;
const { alphanumeric } = scanner.tokens.groups;
return children;
}
const Prefix = parser.start.tt(POUND);
const Channel = new linkifyjs.State(IRCChannelToken);
const Divider = Channel.tt(DOT);
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>`;
}
Prefix.ta(alphanumeric, Channel);
Prefix.tt(POUND, Channel);
Prefix.tt(UNDERSCORE, Channel);
Prefix.tt(DOT, Divider);
Prefix.tt(HYPHEN, Channel);
Channel.ta(alphanumeric, Channel);
Channel.tt(POUND, Channel);
Channel.tt(UNDERSCORE, Channel);
Channel.tt(HYPHEN, Channel);
Divider.ta(alphanumeric, Channel);
});
let links = anchorme.list(text);
export default function linkify(text, onClick) {
let links = linkifyjs.find(text);
let children = [];
let last = 0;
links.forEach((match) => {
const prefix = text.substring(last, match.start)
children.push(...linkifyChannel(prefix, transformChannel));
let proto = match.protocol || "https://";
if (match.isEmail) {
proto = "mailto:";
if (!match.isLink) {
return;
}
let url = match.string;
if (!url.startsWith(proto)) {
url = proto + url;
}
const prefix = text.substring(last, match.start);
children.push(prefix);
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));
const suffix = text.substring(last);
children.push(suffix);
return children;
}

109
lib/oauth2.js Normal file
View File

@ -0,0 +1,109 @@
function formatQueryString(params) {
let l = [];
for (let k in params) {
l.push(encodeURIComponent(k) + "=" + encodeURIComponent(params[k]));
}
return l.join("&");
}
export async function fetchServerMetadata(url) {
// TODO: handle path in config.oauth2.url
let resp;
try {
resp = await fetch(url + "/.well-known/oauth-authorization-server");
if (!resp.ok) {
throw new Error(`HTTP error: ${resp.status} ${resp.statusText}`);
}
} catch (err) {
console.warn("OAuth 2.0 server doesn't support Authorization Server Metadata (retrying with OpenID Connect Discovery): ", err);
resp = await fetch(url + "/.well-known/openid-configuration");
if (!resp.ok) {
throw new Error(`HTTP error: ${resp.status} ${resp.statusText}`);
}
}
let data = await resp.json();
if (!data.issuer) {
throw new Error("Missing issuer in response");
}
if (!data.authorization_endpoint) {
throw new Error("Missing authorization_endpoint in response");
}
if (!data.token_endpoint) {
throw new Error("Missing authorization_endpoint in response");
}
if (!data.response_types_supported.includes("code")) {
throw new Error("Server doesn't support authorization code response type");
}
return data;
}
export function redirectAuthorize({ serverMetadata, clientId, redirectUri, scope }) {
// TODO: move fragment to query string in redirect_uri
// TODO: use the state param to prevent cross-site request
// forgery
let params = {
"response_type": "code",
"client_id": clientId,
"redirect_uri": redirectUri,
};
if (scope) {
params.scope = scope;
}
window.location.assign(serverMetadata.authorization_endpoint + "?" + formatQueryString(params));
}
function buildPostHeaders(clientId, clientSecret) {
let headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
};
if (clientSecret) {
headers["Authorization"] = "Basic " + btoa(encodeURIComponent(clientId) + ":" + encodeURIComponent(clientSecret));
}
return headers;
}
export async function exchangeCode({ serverMetadata, redirectUri, code, clientId, clientSecret }) {
let data = {
"grant_type": "authorization_code",
code,
"redirect_uri": redirectUri,
};
if (!clientSecret) {
data["client_id"] = clientId;
}
let resp = await fetch(serverMetadata.token_endpoint, {
method: "POST",
headers: buildPostHeaders(clientId, clientSecret),
body: formatQueryString(data),
});
if (!resp.ok) {
throw new Error(`HTTP error: ${resp.status} ${resp.statusText}`);
}
data = await resp.json();
if (data.error) {
throw new Error("Authentication failed: " + (data.error_description || data.error));
}
return data;
}
export async function introspectToken({ serverMetadata, token, clientId, clientSecret }) {
let resp = await fetch(serverMetadata.introspection_endpoint, {
method: "POST",
headers: buildPostHeaders(clientId, clientSecret),
body: formatQueryString({ token }),
});
if (!resp.ok) {
throw new Error(`HTTP error: ${resp.status} ${resp.statusText}`);
}
let data = await resp.json();
if (!data.active) {
throw new Error("Expired token");
}
return data;
}

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);

4823
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +1,37 @@
{
"name": "gamja",
"type": "module",
"dependencies": {
"anchorme": "^2.1.2",
"htm": "^3.0.4",
"preact": "^10.5.9"
"linkifyjs": "^4.1.3",
"preact": "^10.17.1"
},
"devDependencies": {
"http-server": "^13.0.2"
"@eslint/js": "^9.11.1",
"@parcel/packager-raw-url": "^2.0.0",
"@parcel/transformer-webmanifest": "^2.0.0",
"@stylistic/eslint-plugin-js": "^4.2.0",
"eslint": "^9.11.1",
"globals": "^16.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",
"lint": "eslint"
},
"private": true
"private": true,
"targets": {
"default": {
"source": "index.html",
"context": "browser",
"publicUrl": "."
}
},
"engines": {
"node": ">=14.13.0"
}
}

324
state.js
View File

@ -1,5 +1,6 @@
import * as irc from "./lib/irc.js";
import Client from "./lib/client.js";
import { createContext } from "./lib/index.js";
export const SERVER_BUFFER = "*";
@ -34,22 +35,22 @@ export const ReceiptType = {
READ: "read",
};
export function getNickURL(nick) {
return "irc:///" + encodeURIComponent(nick) + ",isuser";
}
export const BufferEventsDisplayMode = {
FOLD: "fold",
EXPAND: "expand",
HIDE: "hide",
};
export function getChannelURL(channel) {
return "irc:///" + encodeURIComponent(channel);
}
export const SettingsContext = createContext("settings");
export function getBufferURL(buf) {
switch (buf.type) {
case BufferType.SERVER:
return "irc:///";
return irc.formatURL();
case BufferType.CHANNEL:
return getChannelURL(buf.name);
return irc.formatURL({ entity: buf.name });
case BufferType.NICK:
return getNickURL(buf.name);
return irc.formatURL({ entity: buf.name, enttype: "user" });
}
throw new Error("Unknown buffer type: " + buf.type);
}
@ -63,21 +64,63 @@ 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;
}
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) {
let updated;
@ -93,26 +136,63 @@ function updateState(state, updater) {
}
function isServerBuffer(buf) {
return buf.type == BufferType.SERVER;
return buf.type === BufferType.SERVER;
}
function isChannelBuffer(buf) {
return buf.type === BufferType.CHANNEL;
}
function trimStartCharacter(s, c) {
let i = 0;
for (; i < s.length; ++i) {
if (s[i] !== c) {
break;
}
}
return s.substring(i);
}
function getBouncerNetworkNameFromBuffer(state, buffer) {
let server = state.servers.get(buffer.server);
let network = state.bouncerNetworks.get(server.bouncerNetID);
if (!network) {
return null;
}
return getServerName(server, network);
}
/* Returns 1 if a should appear after b, -1 if a should appear before b, or
* 0 otherwise. */
function compareBuffers(a, b) {
if (a.server != b.server) {
function compareBuffers(state, a, b) {
if (a.server !== b.server) {
let aServerName = getBouncerNetworkNameFromBuffer(state, a);
let bServerName = getBouncerNetworkNameFromBuffer(state, b);
if (aServerName && bServerName && aServerName !== bServerName) {
return aServerName.localeCompare(bServerName);
}
return a.server > b.server ? 1 : -1;
}
if (isServerBuffer(a) != isServerBuffer(b)) {
if (isServerBuffer(a) !== isServerBuffer(b)) {
return isServerBuffer(b) ? 1 : -1;
}
if (a.name != b.name) {
return a.name > b.name ? 1 : -1;
if (isChannelBuffer(a) && isChannelBuffer(b)) {
const strippedA = trimStartCharacter(a.name, a.name[0]);
const strippedB = trimStartCharacter(b.name, b.name[0]);
const cmp = strippedA.localeCompare(strippedB);
if (cmp !== 0) {
return cmp;
}
return 0;
// if they are the same when stripped, fallthough to default logic
}
return a.name.localeCompare(b.name);
}
function updateMembership(membership, letter, add, client) {
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];
@ -135,9 +215,9 @@ function updateMembership(membership, letter, add, client) {
/* Insert a message in an immutable list of sorted messages. */
function insertMessage(list, msg) {
if (list.length == 0) {
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 +246,11 @@ export const State = {
servers: new Map(),
buffers: new Map(),
activeBuffer: null,
bouncerNetworks: new Map(),
settings: {
secondsInTimestamps: true,
bufferEvents: BufferEventsDisplayMode.FOLD,
},
};
},
updateServer(state, id, updater) {
@ -225,7 +310,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 +331,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 }];
},
@ -262,7 +355,7 @@ export const State = {
let id = lastBufferID;
let type;
if (name == SERVER_BUFFER) {
if (name === SERVER_BUFFER) {
type = BufferType.SERVER;
} else if (client.isChannel(name)) {
type = BufferType.CHANNEL;
@ -277,16 +370,32 @@ export const State = {
type,
server: serverID,
serverInfo: null, // if server
joined: false, // if channel
topic: null, // if channel
hasInitialWho: false, // if channel
members: new irc.CaseMapMap(null, client.cm), // if channel
messages: [],
redacted: new Set(),
unread: Unread.NONE,
prevReadReceipt: null,
});
bufferList = bufferList.sort(compareBuffers);
bufferList = bufferList.sort((a, b) => compareBuffers(state, a, b));
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 +420,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,9 +430,9 @@ 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) {
if (buf.server !== serverID) {
return;
}
let members = new irc.CaseMapMap(buf.members, client.cm);
@ -333,11 +442,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,58 +477,64 @@ 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);
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:
who = client.parseWhoReply(msg);
case irc.RPL_ENDOFWHO:
target = msg.params[1];
if (msg.list.length === 0 && !client.isChannel(target) && target.indexOf("*") < 0) {
// Not a channel nor a mask, likely a nick
return updateUser(target, (user) => {
return { offline: true };
});
} else {
return updateServer((server) => {
let users = new irc.CaseMapMap(server.users);
for (let reply of msg.list) {
let who = client.parseWhoReply(reply);
if (who.flags !== undefined) {
who.away = who.flags.indexOf("G") >= 0; // H for here, G for gone
who.operator = who.flags.indexOf("*") >= 0;
let botFlag = client.isupport.bot();
if (botFlag) {
who.bot = who.flags.indexOf(botFlag) >= 0;
}
delete who.flags;
}
who.offline = false;
return updateUser(who.nick, who);
case irc.RPL_ENDOFWHO:
target = msg.params[1];
if (!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;
users.set(who.nick, who);
}
return { offline: true };
return { users };
});
}
break;
case "JOIN":
channel = msg.params[0];
if (client.isMyNick(msg.prefix.name)) {
let [id, update] = State.createBuffer(state, channel, serverID, client);
let [_id, update] = State.createBuffer(state, channel, serverID, client);
state = { ...state, ...update };
}
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 +562,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 +574,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 +631,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":
@ -472,7 +649,7 @@ export const State = {
return updateUser(msg.prefix.name, { account });
case "AWAY":
let awayMessage = msg.params[0];
return updateUser(msg.prefix.name, { away: !!awayMessage });
return updateUser(msg.prefix.name, { away: Boolean(awayMessage) });
case "TOPIC":
channel = msg.params[0];
topic = msg.params[1];
@ -484,7 +661,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];
}));
@ -503,22 +680,21 @@ export const State = {
return { members };
});
case irc.RPL_MONONLINE:
targets = msg.params[1].split(",");
for (let target of targets) {
let prefix = irc.parsePrefix(target);
let update = updateUser(prefix.name, { offline: false });
state = { ...state, ...update };
case "REDACT":
target = msg.params[0];
if (client.isMyNick(target)) {
target = msg.prefix.name;
}
return state;
return updateBuffer(target, (buf) => {
return { redacted: new Set(buf.redacted).add(msg.params[1]) };
});
case irc.RPL_MONONLINE:
case irc.RPL_MONOFFLINE:
targets = msg.params[1].split(",");
for (let target of targets) {
let prefix = irc.parsePrefix(target);
let update = updateUser(prefix.name, { offline: true });
let update = updateUser(prefix.name, { offline: msg.command === irc.RPL_MONOFFLINE });
state = { ...state, ...update };
}

View File

@ -1,3 +1,5 @@
import { ReceiptType, Unread } from "./state.js";
const PREFIX = "gamja_";
class Item {
@ -23,18 +25,19 @@ class Item {
}
export const autoconnect = new Item("autoconnect");
export const naggedProtocolHandler = new Item("naggedProtocolHandler");
export const settings = new Item("settings");
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,14 +46,22 @@ 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) {
// TODO: use case-mapping here somehow
return JSON.stringify({
name: buf.name,
name: buf.name.toLowerCase(),
server: {
url: buf.server.url,
nick: buf.server.nick,
bouncerNetwork: buf.server.bouncerNetwork,
},
});
@ -71,22 +82,53 @@ 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 || Unread.NONE;
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;
}
}
let closed = prev.closed || false;
if (buf.closed !== undefined && buf.closed !== prev.closed) {
closed = buf.closed;
updated = true;
}
if (!updated) {
return false;
}
this.m.set(this.key(buf), {
name: buf.name,
unread: buf.unread,
unread,
receipts,
closed,
server: {
url: buf.server.url,
nick: buf.server.nick,
bouncerNetwork: buf.server.bouncerNetwork,
},
});
this.save();
return true;
}
delete(buf) {
@ -95,19 +137,25 @@ export class Buffer {
}
list(server) {
// Some gamja versions would store the same buffer multiple times
let names = new Set();
let buffers = [];
for (const buf of this.m.values()) {
if (buf.server.url !== server.url || buf.server.nick !== server.nick || buf.server.bouncerNetwork !== server.bouncerNetwork) {
if (buf.server.bouncerNetwork !== server.bouncerNetwork) {
continue;
}
if (names.has(buf.name)) {
continue;
}
buffers.push(buf);
names.add(buf.name);
}
return buffers;
}
clear(server) {
if (server) {
for (const buf of this.m.values()) {
for (const buf of this.list(server)) {
this.m.delete(this.key(buf));
}
} else {

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;
@ -147,9 +154,8 @@ button.danger:hover {
padding: 2px 10px;
box-sizing: border-box;
}
#buffer-list li.active a {
color: white;
background-color: var(--gray);
#buffer-list li.error a {
color: red;
}
#buffer-list li.unread-message a {
color: #b37400;
@ -157,6 +163,10 @@ button.danger:hover {
#buffer-list li.unread-highlight a {
color: #22009b;
}
#buffer-list li.active a {
color: white;
background-color: var(--gray);
}
#buffer-list li:not(.type-server) a {
padding-left: 20px;
}
@ -179,7 +189,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 +205,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 +292,9 @@ button.danger:hover {
padding: 2px 10px;
box-sizing: border-box;
}
#member-list li a.away {
color: var(--gray);
}
.membership.owner {
color: red;
@ -286,7 +302,7 @@ button.danger:hover {
.membership.admin {
color: blue;
}
.membership.op {
.membership.operator {
color: var(--green);
}
.membership.halfop {
@ -336,18 +352,27 @@ form input[type="text"],
form input[type="username"],
form input[type="password"],
form input[type="url"],
form input[type="email"] {
form input[type="email"],
form input[type="search"] {
box-sizing: border-box;
width: 100%;
font-family: inherit;
font-size: inherit;
}
a {
color: var(--green);
}
#buffer-list li a, a.timestamp, a.nick {
color: var(--gray);
text-decoration: none;
}
#buffer-list li a,
a.nick {
color: var(--main-color);
}
a.timestamp {
color: var(--gray);
}
#buffer-list li a:hover, #buffer-list li a:active,
a.timestamp:hover, a.timestamp:active,
a.nick:hover, a.nick:active {
@ -369,6 +394,7 @@ details summary[role="button"] {
}
#buffer .logline {
white-space: pre-wrap;
overflow: auto;
}
#buffer .talk, #buffer .motd {
color: var(--main-color);
@ -520,6 +546,14 @@ details summary[role="button"] {
overflow: auto; /* hack to clear floating elements */
}
.dialog .protocol-handler {
display: flex;
flex-direction: row;
}
.dialog .protocol-handler .left {
flex-grow: 1;
}
kbd {
background-color: #f0f0f0;
border: 1px solid #bfbfbf;
@ -535,21 +569,44 @@ kbd {
border-radius: 3px;
}
ul.switcher-list {
list-style-type: none;
margin: 0;
padding: 0;
margin-top: 10px;
}
ul.switcher-list li a {
display: inline-block;
width: 100%;
padding: 5px 10px;
margin: 4px 0;
box-sizing: border-box;
text-decoration: none;
color: inherit;
}
ul.switcher-list li a.selected {
background-color: rgba(0, 0, 0, 0.1);
}
ul.switcher-list .server {
float: right;
opacity: 0.8;
}
@media (prefers-color-scheme: dark) {
html {
scrollbar-color: var(--gray) transparent;
}
#buffer-list li.active a {
color: var(--sidebar-background);
background-color: white;
}
#buffer-list li.unread-message a {
color: var(--green);
}
#buffer-list li.unread-highlight a {
color: #0062cc;
}
#buffer-list li.active a {
color: var(--sidebar-background);
background-color: white;
}
#buffer-header .status-gone {
color: #fb885b;
@ -562,7 +619,8 @@ kbd {
form input[type="username"],
form input[type="password"],
form input[type="url"],
form input[type="email"] {
form input[type="email"],
form input[type="search"] {
color: #ffffff;
background: var(--sidebar-background);
border: 1px solid #495057;
@ -572,16 +630,12 @@ kbd {
form input[type="username"]:focus,
form input[type="password"]:focus,
form input[type="url"]:focus,
form input[type="email"]:focus {
form input[type="email"]:focus,
form input[type="search"]:focus {
outline: 0;
border-color: #3897ff;
}
#buffer-list li a,
a.nick {
color: var(--main-color);
}
#buffer {
background: var(--main-background);
}
@ -651,6 +705,10 @@ kbd {
border: 1px solid var(--outline-color);
box-shadow: inset 0 -1px 0 var(--outline-color);
}
ul.switcher-list li a.selected {
background-color: rgba(255, 255, 255, 0.1);
}
}
@media (max-width: 640px) {