67 Commits

Author SHA1 Message Date
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: 1428ec4d49 ("Add support for draft/read-marker")
2022-09-03 14:40:54 +02:00
Simon Ser
aa9aa78d71 Fix ignored MARKREAD messages
The prefix is a remnant of the soju extension. The IRCv3 one
doesn't have it.

Fixes: 1428ec4d49 ("Add support for draft/read-marker")
2022-09-03 14:40:46 +02:00
Simon Ser
4780b9c709 Fetch read marker before backlog for user targets 2022-09-03 14:31:56 +02:00
Simon Ser
e7b69cec9a Limit composer length
Often times IRC servers will truncate messages which are too big.
2022-08-28 19:16:41 +02:00
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
31 changed files with 2878 additions and 3142 deletions

View File

@@ -5,7 +5,7 @@ packages:
sources:
- https://git.sr.ht/~emersion/gamja
secrets:
- 5874ac5a-905e-4596-a117-fed1401c60ce # deploy SSH key
- 77c7956b-003e-44f7-bb5c-2944b2047654 # deploy SSH key
tasks:
- setup: |
cd gamja
@@ -16,4 +16,4 @@ tasks:
[ "$(git rev-parse HEAD)" = "$(git rev-parse origin/master)" ] || complete-build
rsync --rsh="ssh -o StrictHostKeyChecking=no" -rP \
--delete --exclude=config.json \
. deploy@emersion.fr:/srv/http/gamja
. deploy@sheeta.emersion.fr:/srv/http/gamja

View File

@@ -12,52 +12,7 @@ First install dependencies:
npm install --production
Then configure an HTTP server to serve the gamja files. Below are some
server-specific instructions.
### [soju]
Add a WebSocket listener to soju, e.g. `listen wss://127.0.0.1:8080`.
Configure your reverse proxy to serve gamja files and proxy `/socket` to soju.
### [webircgateway]
Setup webircgateway to serve gamja files:
```ini
[fileserving]
enabled = true
webroot = /path/to/gamja
```
Then connect to webircgateway and append `?server=/webirc/websocket/` to the
URL.
### nginx
If you use nginx as a reverse HTTP proxy, make sure to bump the default read
timeout to a value higher than the IRC server PING interval. Example:
```
location / {
root /path/to/gamja;
}
location /socket {
proxy_pass http://127.0.0.1:8080;
proxy_read_timeout 600s;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
If you are unable to configure the proxy timeout accordingly, or if your IRC
server doesn't send PINGs, you can set the `server.ping` option in
`config.json` (see below).
Then [configure an HTTP server] to serve the gamja files.
### Development server
@@ -76,48 +31,9 @@ Optionally, [Parcel] can be used to build a minified version of gamja.
npm install --include=dev
npm run build
## Query parameters
## Configuration
gamja settings can be overridden using URL query parameters:
- `server`: path or URL to the WebSocket server
- `nick`: nickname
- `channels`: comma-separated list of channels to join (`#` needs to be escaped)
- `open`: [IRC URL] to open
- `debug`: if set to 1, debug mode is enabled
Alternatively, the channels can be set with the URL fragment (ie, by just
appending the channel name to the gamja URL).
## Configuration file
gamja default settings can be set using a `config.json` file at the root:
```js
{
// IRC server settings.
"server": {
// WebSocket URL or path to connect to (string).
"url": "wss://irc.example.org",
// Channel(s) to auto-join (string or array of strings).
"autojoin": "#gamja",
// Controls how the password UI is presented to the user. Set to
// "mandatory" to require a password, "optional" to accept one but not
// require it, "disabled" to never ask for a password, or "external" to
// use SASL EXTERNAL. Defaults to "optional".
"auth": "optional",
// Default nickname (string).
"nick": "asdf",
// Don't display the login UI, immediately connect to the server
// (boolean).
"autoconnect": true,
// Interval in seconds to send PING commands (number). Set to 0 to
// disable. Enabling PINGs can have an impact on client power usage and
// should only be enabled if necessary.
"ping": 60
}
}
```
gamja can be configured via a [configuration file] and via [URL parameters].
## Contributing
@@ -131,10 +47,10 @@ 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
[issue tracker]: https://todo.sr.ht/~emersion/gamja
[Parcel]: https://parceljs.org
[IRC URL]: https://datatracker.ietf.org/doc/html/draft-butcher-irc-url-04
[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,21 +25,20 @@ 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) => {
const info = whois[irc.RPL_WHOISUSER].params;
const user = info[2];
const host = info[3];
client.send({
command: "MODE",
params: [activeChannel, mode, `*!${user}@${host}`],
});
let whois = await client.whois(nick);
const info = whois[irc.RPL_WHOISUSER].params;
const user = info[2];
const host = info[3];
client.send({
command: "MODE",
params: [activeChannel, mode, `*!${user}@${host}`],
});
}

View File

@@ -1,5 +1,6 @@
import * as irc from "../lib/irc.js";
import Client from "../lib/client.js";
import * as oauth2 from "../lib/oauth2.js";
import Buffer from "./buffer.js";
import BufferList from "./buffer-list.js";
import BufferHeader from "./buffer-header.js";
@@ -12,6 +13,7 @@ import AuthForm from "./auth-form.js";
import RegisterForm from "./register-form.js";
import VerifyForm from "./verify-form.js";
import SettingsForm from "./settings-form.js";
import SwitcherForm from "./switcher-form.js";
import Composer from "./composer.js";
import ScrollManager from "./scroll-manager.js";
import Dialog from "./dialog.js";
@@ -198,6 +200,7 @@ export default class App extends Component {
autoOpenURL = null;
messageNotifications = new Set();
baseTitle = null;
lastFocusPingDate = null;
constructor(props) {
super(props);
@@ -224,6 +227,9 @@ export default class App extends Component {
this.handleVerifySubmit = this.handleVerifySubmit.bind(this);
this.handleOpenSettingsClick = this.handleOpenSettingsClick.bind(this);
this.handleSettingsChange = this.handleSettingsChange.bind(this);
this.handleSettingsDisconnect = this.handleSettingsDisconnect.bind(this);
this.handleSwitchSubmit = this.handleSwitchSubmit.bind(this);
this.handleWindowFocus = this.handleWindowFocus.bind(this);
this.state.settings = {
...this.state.settings,
@@ -249,32 +255,37 @@ export default class App extends Component {
* - Default server URL constructed from the current URL location (this is
* done in fillConnectParams)
*/
handleConfig(config) {
this.setState({ loading: false });
async handleConfig(config) {
let connectParams = { ...this.state.connectParams };
if (config.server) {
if (typeof config.server.url === "string") {
connectParams.url = config.server.url;
}
if (Array.isArray(config.server.autojoin)) {
connectParams.autojoin = config.server.autojoin;
} else if (typeof config.server.autojoin === "string") {
connectParams.autojoin = [config.server.autojoin];
}
if (typeof config.server.nick === "string") {
connectParams.nick = config.server.nick;
}
if (typeof config.server.autoconnect === "boolean") {
connectParams.autoconnect = config.server.autoconnect;
}
if (config.server.auth === "external") {
connectParams.saslExternal = true;
}
if (typeof config.server.ping === "number") {
connectParams.ping = config.server.ping;
}
if (typeof config.server.url === "string") {
connectParams.url = config.server.url;
}
if (Array.isArray(config.server.autojoin)) {
connectParams.autojoin = config.server.autojoin;
} else if (typeof config.server.autojoin === "string") {
connectParams.autojoin = [config.server.autojoin];
}
if (typeof config.server.nick === "string") {
connectParams.nick = config.server.nick;
}
if (typeof config.server.autoconnect === "boolean") {
connectParams.autoconnect = config.server.autoconnect;
}
if (config.server.auth === "external") {
connectParams.saslExternal = true;
}
if (typeof config.server.ping === "number") {
connectParams.ping = config.server.ping;
}
if (connectParams.autoconnect && config.server.auth === "mandatory") {
console.error("Error in config.json: cannot set server.autoconnect = true and server.auth = \"mandatory\"");
connectParams.autoconnect = false;
}
if (config.server.auth === "oauth2" && (!config.oauth2 || !config.oauth2.url || !config.oauth2.client_id)) {
console.error("Error in config.json: server.auth = \"oauth2\" requires oauth2 settings");
config.server.auth = null;
}
let autoconnect = store.autoconnect.load();
@@ -320,6 +331,48 @@ export default class App extends Component {
this.config = config;
if (!connectParams.nick && connectParams.autoconnect) {
connectParams.nick = "user-*";
}
if (connectParams.nick && connectParams.nick.includes("*")) {
let placeholder = Math.random().toString(36).substr(2, 7);
connectParams.nick = connectParams.nick.replace("*", placeholder);
}
if (config.server.auth === "oauth2" && !connectParams.saslOauthBearer) {
if (queryParams.error) {
console.error("OAuth 2.0 authorization failed: ", queryParams.error);
this.showError("Authentication failed: " + (queryParams.error_description || queryParams.error));
return;
}
if (!queryParams.code) {
this.redirectOauth2Authorize();
return;
}
// Strip code from query params, to prevent page refreshes from
// trying to exchange the code again
let url = new URL(window.location.toString());
url.searchParams.delete("code");
url.searchParams.delete("state");
window.history.replaceState(null, "", url.toString());
let saslOauthBearer;
try {
saslOauthBearer = await this.exchangeOauth2Code(queryParams.code);
} catch (err) {
this.showError(err);
return;
}
connectParams.saslOauthBearer = saslOauthBearer;
if (saslOauthBearer.username && !connectParams.nick) {
connectParams.nick = saslOauthBearer.username;
}
}
if (autojoin.length > 0) {
if (connectParams.autoconnect) {
// Ask the user whether they want to join that new channel.
@@ -330,7 +383,7 @@ export default class App extends Component {
}
}
this.setState({ connectParams: connectParams });
this.setState({ loading: false, connectParams: connectParams });
if (connectParams.autoconnect) {
this.setState({ connectForm: false });
@@ -338,6 +391,60 @@ export default class App extends Component {
}
}
async redirectOauth2Authorize() {
let serverMetadata;
try {
serverMetadata = await oauth2.fetchServerMetadata(this.config.oauth2.url);
} catch (err) {
console.error("Failed to fetch OAuth 2.0 server metadata:", err);
this.showError("Failed to fetch OAuth 2.0 server metadata");
return;
}
oauth2.redirectAuthorize({
serverMetadata,
clientId: this.config.oauth2.client_id,
redirectUri: window.location.toString(),
scope: this.config.oauth2.scope,
});
}
async exchangeOauth2Code(code) {
let serverMetadata = await oauth2.fetchServerMetadata(this.config.oauth2.url);
let redirectUri = new URL(window.location.toString());
redirectUri.searchParams.delete("code");
redirectUri.searchParams.delete("state");
let data = await oauth2.exchangeCode({
serverMetadata,
redirectUri: redirectUri.toString(),
code,
clientId: this.config.oauth2.client_id,
clientSecret: this.config.oauth2.client_secret,
});
// TODO: handle expires_in/refresh_token
let token = data.access_token;
let username = null;
if (serverMetadata.introspection_endpoint) {
try {
let data = await oauth2.introspectToken({
serverMetadata,
token,
clientId: this.config.oauth2.client_id,
clientSecret: this.config.oauth2.client_secret,
});
username = data.username;
} catch (err) {
console.warn("Failed to introspect OAuth 2.0 token:", err);
}
}
return { token, username };
}
showError(err) {
console.error("App error: ", err);
@@ -388,13 +495,12 @@ export default class App extends Component {
if (client.caps.enabled.has("draft/chathistory") && stored) {
this.setBufferState({ server: serverID, name }, { unread: stored.unread });
}
if (!stored) {
this.bufferStore.put({
name,
server: client.params,
unread: Unread.NONE,
});
}
this.bufferStore.put({
name,
server: client.params,
closed: false,
});
}
createBuffer(serverID, name) {
@@ -414,17 +520,14 @@ export default class App extends Component {
}
sendReadReceipt(client, storedBuffer) {
if (!client.caps.enabled.has("soju.im/read")) {
if (!client.supportsReadMarker()) {
return;
}
let readReceipt = storedBuffer.receipts[ReceiptType.READ];
if (storedBuffer.name === "*" || !readReceipt) {
return;
}
client.send({
command: "READ",
params: [storedBuffer.name, "timestamp="+readReceipt.time],
});
client.setReadMarker(storedBuffer.name, readReceipt.time);
}
switchBuffer(id) {
@@ -480,6 +583,10 @@ export default class App extends Component {
this.whoUserBuffer(buf.name, buf.server);
}
if (buf.type === BufferType.CHANNEL && !buf.hasInitialWho) {
this.whoChannelBuffer(buf.name, buf.server);
}
if (buf.type !== BufferType.SERVER) {
document.title = buf.name + ' · ' + this.baseTitle;
} else {
@@ -488,19 +595,33 @@ export default class App extends Component {
});
}
addMessage(serverID, bufName, msg) {
let client = this.clients.get(serverID);
prepareChatMessage(serverID, msg) {
// Treat server-wide broadcasts as highlights. They're sent by server
// operators and can contain important information.
msg.isHighlight = irc.isHighlight(msg, client.nick, client.cm) || irc.isServerBroadcast(msg);
if (msg.isHighlight === undefined) {
let client = this.clients.get(serverID);
msg.isHighlight = irc.isHighlight(msg, client.nick, client.cm) || irc.isServerBroadcast(msg);
}
if (!msg.tags) {
// Can happen for outgoing messages for instance
msg.tags = {};
}
if (!msg.tags.time) {
msg.tags.time = irc.formatDate(new Date());
}
}
addChatMessage(serverID, bufName, msg) {
this.prepareChatMessage(serverID, msg);
let bufID = { server: serverID, name: bufName };
this.setState((state) => State.addMessage(state, msg, bufID));
}
handleChatMessage(serverID, bufName, msg) {
let client = this.clients.get(serverID);
this.prepareChatMessage(serverID, msg);
let stored = this.bufferStore.get({ name: bufName, server: client.params });
let deliveryReceipt = getReceipt(stored, ReceiptType.DELIVERED);
@@ -508,8 +629,6 @@ export default class App extends Component {
let isDelivered = isMessageBeforeReceipt(msg, deliveryReceipt);
let isRead = isMessageBeforeReceipt(msg, readReceipt);
// TODO: messages coming from infinite scroll shouldn't trigger notifications
if (client.isMyNick(msg.prefix.name)) {
isRead = true;
}
@@ -588,7 +707,7 @@ export default class App extends Component {
// Open a new buffer if the message doesn't come from me or is a
// self-message
if ((!client.isMyNick(msg.prefix.name) || client.isMyNick(bufName)) && (msg.command != "PART" && msg.comand != "QUIT")) {
if ((!client.isMyNick(msg.prefix.name) || client.isMyNick(bufName)) && (msg.command != "PART" && msg.comand != "QUIT" && msg.command != irc.RPL_MONONLINE && msg.command != irc.RPL_MONOFFLINE)) {
this.createBuffer(serverID, bufName);
}
@@ -745,11 +864,27 @@ export default class App extends Component {
if (client.cm(msg.prefix.name) === client.cm(client.serverPrefix.name)) {
target = SERVER_BUFFER;
} else {
target = msg.prefix.name;
let context = msg.tags['+draft/channel-context'];
if (context && client.isChannel(context) && State.getBuffer(this.state, { server: serverID, name: context })) {
target = context;
} else {
target = msg.prefix.name;
}
}
}
if (msg.command === "NOTICE" && !State.getBuffer(this.state, { server: serverID, name: target })) {
// Don't open a new buffer if this is just a NOTICE
// Don't open a new buffer if this is just a NOTICE or a garbage
// CTCP message
let openNewBuffer = true;
if (msg.command !== "PRIVMSG") {
openNewBuffer = false;
} else {
let ctcp = irc.parseCTCP(msg);
if (ctcp && ctcp.command !== "ACTION") {
openNewBuffer = false;
}
}
if (!openNewBuffer && !State.getBuffer(this.state, { server: serverID, name: target })) {
target = SERVER_BUFFER;
}
@@ -782,7 +917,7 @@ export default class App extends Component {
if (buf.server != serverID) {
return;
}
if (!buf.members.has(msg.prefix.name) && client.cm(buf.name) !== client.cm(msg.prefix.name)) {
if (!buf.members.has(msg.prefix.name)) {
return;
}
affectedBuffers.push(buf.name);
@@ -838,6 +973,15 @@ export default class App extends Component {
case irc.RPL_INVITING:
channel = msg.params[2];
return [channel];
case irc.RPL_MONONLINE:
case irc.RPL_MONOFFLINE:
let targets = msg.params[1].split(",");
affectedBuffers = [];
for (let target of targets) {
let prefix = irc.parsePrefix(target);
affectedBuffers.push(prefix.name);
}
return affectedBuffers;
case irc.RPL_YOURHOST:
case irc.RPL_MYINFO:
case irc.RPL_ISUPPORT:
@@ -849,9 +993,8 @@ export default class App extends Component {
case irc.RPL_TOPICWHOTIME:
case irc.RPL_NAMREPLY:
case irc.RPL_ENDOFNAMES:
case irc.RPL_MONONLINE:
case irc.RPL_MONOFFLINE:
case irc.RPL_SASLSUCCESS:
case irc.RPL_CHANNEL_URL:
case "AWAY":
case "SETNAME":
case "CHGHOST":
@@ -865,7 +1008,7 @@ export default class App extends Component {
case "CHATHISTORY":
case "ACK":
case "BOUNCER":
case "READ":
case "MARKREAD":
// Ignore these
return [];
default:
@@ -896,7 +1039,7 @@ export default class App extends Component {
// Restore opened channel and user buffers
let join = [];
for (let buf of this.bufferStore.list(client.params)) {
if (buf.name === "*") {
if (buf.name === "*" || buf.closed) {
continue;
}
@@ -1015,10 +1158,10 @@ export default class App extends Component {
this.autoOpenURL = null;
}
break;
case "READ":
case "MARKREAD":
target = msg.params[0];
let bound = msg.params[1];
if (!client.isMyNick(msg.prefix.name) || bound === "*" || !bound.startsWith("timestamp=")) {
if (bound === "*" || !bound.startsWith("timestamp=")) {
break;
}
let readReceipt = { time: bound.replace("timestamp=", "") };
@@ -1034,9 +1177,13 @@ export default class App extends Component {
notif.close();
}
}
let unread;
let closed = true;
this.setBufferState({ server: serverID, name: target }, (buf) => {
closed = false;
// Re-compute unread status
let unread = Unread.NONE;
unread = Unread.NONE;
for (let i = buf.messages.length - 1; i >= 0; i--) {
let msg = buf.messages[i];
if (msg.command !== "PRIVMSG" && msg.command !== "NOTICE") {
@@ -1054,14 +1201,15 @@ export default class App extends Component {
unread = Unread.MESSAGE;
}
return { unread };
}, () => {
this.bufferStore.put({
name: target,
server: client.params,
unread,
closed,
receipts: { [ReceiptType.READ]: readReceipt },
});
return { unread };
});
break;
default:
@@ -1072,11 +1220,11 @@ export default class App extends Component {
}
destBuffers.forEach((bufName) => {
this.addMessage(serverID, bufName, msg);
this.handleChatMessage(serverID, bufName, msg);
});
}
fetchBacklog(serverID) {
async fetchBacklog(serverID) {
let client = this.clients.get(serverID);
if (!client.caps.enabled.has("draft/chathistory")) {
return;
@@ -1091,39 +1239,48 @@ export default class App extends Component {
}
let now = irc.formatDate(new Date());
client.fetchHistoryTargets(now, lastReceipt.time).then((targets) => {
targets.forEach((target) => {
let from = lastReceipt;
let to = { time: now };
let targets = await client.fetchHistoryTargets(now, lastReceipt.time);
targets.forEach(async (target) => {
let from = lastReceipt;
let to = { time: now };
// Maybe we've just received a READ update from the
// server, avoid over-fetching history
let stored = this.bufferStore.get({ name: target.name, server: client.params });
let readReceipt = getReceipt(stored, ReceiptType.READ);
if (isReceiptBefore(from, readReceipt)) {
from = readReceipt;
// Maybe we've just received a READ update from the
// server, avoid over-fetching history
let stored = this.bufferStore.get({ name: target.name, server: client.params });
let readReceipt = getReceipt(stored, ReceiptType.READ);
if (isReceiptBefore(from, readReceipt)) {
from = readReceipt;
}
// If we already have messages stored for the target,
// fetch all messages we've missed
let buf = State.getBuffer(this.state, { server: serverID, name: target.name });
if (buf && buf.messages.length > 0) {
let lastMsg = buf.messages[buf.messages.length - 1];
from = receiptFromMessage(lastMsg);
}
// Query read marker if this is a user (ie, we haven't received
// the read marker as part of a JOIN burst)
if (client.supportsReadMarker() && client.isNick(target.name)) {
client.fetchReadMarker(target.name);
}
let result;
try {
result = await client.fetchHistoryBetween(target.name, from, to, CHATHISTORY_MAX_SIZE);
} catch (err) {
console.error("Failed to fetch backlog for '" + target.name + "': ", err);
this.showError("Failed to fetch backlog for '" + target.name + "'");
return;
}
for (let msg of result.messages) {
let destBuffers = this.routeMessage(serverID, msg);
for (let bufName of destBuffers) {
this.handleChatMessage(serverID, bufName, msg);
}
// If we already have messages stored for the target,
// fetch all messages we've missed
let buf = State.getBuffer(this.state, { server: serverID, name: target.name });
if (buf && buf.messages.length > 0) {
let lastMsg = buf.messages[buf.messages.length - 1];
from = receiptFromMessage(lastMsg);
}
client.fetchHistoryBetween(target.name, from, to, CHATHISTORY_MAX_SIZE).then((result) => {
for (let msg of result.messages) {
let destBuffers = this.routeMessage(serverID, msg);
for (let bufName of destBuffers) {
this.addMessage(serverID, target.name, msg);
}
}
}).catch((err) => {
console.error("Failed to fetch backlog for '" + target.name + "': ", err);
this.showError("Failed to fetch backlog for '" + target.name + "'");
});
});
}
});
}
@@ -1223,11 +1380,25 @@ export default class App extends Component {
});
client.monitor(target);
if (client.caps.enabled.has("soju.im/read")) {
client.send({
command: "READ",
params: [target],
if (client.supportsReadMarker()) {
client.fetchReadMarker(target);
}
}
async whoChannelBuffer(target, serverID) {
let client = this.clients.get(serverID);
// Prevent multiple WHO commands for the same channel in parallel
this.setBufferState({ name: target, server: serverID }, { hasInitialWho: true });
let hasInitialWho = false;
try {
await client.who(target, {
fields: ["flags", "hostname", "nick", "realname", "username", "account"],
});
hasInitialWho = true;
} finally {
this.setBufferState({ name: target, server: serverID }, { hasInitialWho });
}
}
@@ -1281,6 +1452,7 @@ export default class App extends Component {
});
let disconnectAll = client && !client.params.bouncerNetwork && client.caps.enabled.has("soju.im/bouncer-networks");
let isFirstServer = this.state.servers.keys().next().value === buf.server;
this.disconnect(buf.server);
@@ -1306,7 +1478,7 @@ export default class App extends Component {
}
// TODO: only clear autoconnect if this server is stored there
if (buf.server == 1) {
if (isFirstServer) {
store.autoconnect.put(null);
}
break;
@@ -1327,7 +1499,11 @@ export default class App extends Component {
client.unmonitor(buf.name);
this.bufferStore.delete({ name: buf.name, server: client.params });
this.bufferStore.put({
name: buf.name,
server: client.params,
closed: true,
});
break;
}
}
@@ -1369,7 +1545,7 @@ export default class App extends Component {
if (!client.caps.enabled.has("echo-message")) {
msg.prefix = { name: client.nick };
this.addMessage(serverID, target, msg);
this.handleChatMessage(serverID, target, msg);
}
}
@@ -1499,7 +1675,7 @@ export default class App extends Component {
this.openDialog("help");
}
handleBufferScrollTop() {
async handleBufferScrollTop() {
let buf = this.state.buffers.get(this.state.activeBuffer);
if (!buf || buf.type == BufferType.SERVER) {
return;
@@ -1529,12 +1705,34 @@ export default class App extends Component {
limit = 200;
}
client.fetchHistoryBefore(buf.name, before, limit).then((result) => {
this.endOfHistory.set(buf.id, !result.more);
for (let msg of result.messages) {
this.addMessage(buf.server, buf.name, msg);
let result = await client.fetchHistoryBefore(buf.name, before, limit);
this.endOfHistory.set(buf.id, !result.more);
if (result.messages.length > 0) {
let msg = result.messages[result.messages.length - 1];
let receipts = { [ReceiptType.DELIVERED]: receiptFromMessage(msg) };
if (this.state.activeBuffer === buf.id) {
receipts[ReceiptType.READ] = receiptFromMessage(msg);
}
});
let stored = {
name: buf.name,
server: client.params,
receipts,
};
if (this.bufferStore.put(stored)) {
this.sendReadReceipt(client, stored);
}
this.setBufferState(buf, ({ prevReadReceipt }) => {
if (!isMessageBeforeReceipt(msg, prevReadReceipt)) {
prevReadReceipt = receiptFromMessage(msg);
}
return { prevReadReceipt };
});
}
for (let msg of result.messages) {
this.addChatMessage(buf.server, buf.name, msg);
}
}
openDialog(name, data) {
@@ -1652,12 +1850,13 @@ export default class App extends Component {
});
}
handleNetworkSubmit(attrs, autojoin) {
async handleNetworkSubmit(attrs, autojoin) {
let client = this.clients.values().next().value;
this.dismissDialog();
if (this.state.dialogData && this.state.dialogData.id) {
if (Object.keys(attrs).length == 0) {
this.dismissDialog();
return;
}
@@ -1667,22 +1866,19 @@ export default class App extends Component {
});
} else {
attrs = { ...attrs, tls: "1" };
client.createBouncerNetwork(attrs).then((id) => {
if (!autojoin) {
return;
}
let id = await client.createBouncerNetwork(attrs);
if (!autojoin) {
return;
}
// By this point, bouncer-networks-notify should've advertised
// the new network
let serverID = this.serverFromBouncerNetwork(id);
let client = this.clients.get(serverID);
client.params.autojoin = [autojoin];
// By this point, bouncer-networks-notify should've advertised
// the new network
let serverID = this.serverFromBouncerNetwork(id);
let newClient = this.clients.get(serverID);
newClient.params.autojoin = [autojoin];
this.switchToChannel = autojoin;
});
this.switchToChannel = autojoin;
}
this.dismissDialog();
}
handleNetworkRemove() {
@@ -1713,18 +1909,51 @@ export default class App extends Component {
this.setState({ settings });
}
handleSettingsDisconnect() {
this.dismissDialog();
this.disconnectAll();
}
handleSwitchSubmit(buf) {
this.dismissDialog();
if (buf) {
this.switchBuffer(buf);
}
}
handleWindowFocus() {
// When the user focuses gamja, send a PING to make sure we detect any
// network errors ASAP
let now = new Date();
if (this.lastFocusPingDate && now.getTime() - this.lastFocusPingDate.getTime() < 15 * 1000) {
return;
}
this.lastFocusPingDate = now;
for (let client of this.clients.values()) {
client.send({ command: "PING", params: ["gamja"] });
}
}
componentDidMount() {
this.baseTitle = document.title;
setupKeybindings(this);
window.addEventListener("focus", this.handleWindowFocus);
}
componentWillUnmount() {
document.title = this.baseTitle;
window.removeEventListener("focus", this.handleWindowFocus);
}
render() {
if (this.state.loading) {
return html`<section id="connect"></section>`;
let error = null;
if (this.state.error) {
error = html`<form><p class="error-text">${this.state.error}</p></form>`;
}
return html`<section id="connect">${error}</section>`;
}
let activeBuffer = null, activeServer = null, activeBouncerNetwork = null;
@@ -1890,12 +2119,23 @@ export default class App extends Component {
settings=${this.state.settings}
showProtocolHandler=${dialogData.showProtocolHandler}
onChange=${this.handleSettingsChange}
onDisconnect=${() => this.disconnectAll()}
onClose=${() => this.dismissDialog()}
onDisconnect=${this.handleSettingsDisconnect}
onClose=${this.dismissDialog}
/>
</>
`;
break;
case "switch":
dialog = html`
<${Dialog} title="Switch to a channel or user" onDismiss=${this.dismissDialog}>
<${SwitcherForm}
buffers=${this.state.buffers}
servers=${this.state.servers}
bouncerNetworks=${this.state.bouncerNetworks}
onSubmit=${this.handleSwitchSubmit}/>
</>
`;
break;
}
let error = null;
@@ -1915,8 +2155,12 @@ export default class App extends Component {
}
let commandOnly = false;
let privmsgMaxLen;
if (activeBuffer && activeBuffer.type === BufferType.SERVER) {
commandOnly = true;
} else if (activeBuffer) {
let client = this.clients.get(activeBuffer.server);
privmsgMaxLen = irc.getMaxPrivmsgLen(client.isupport, client.nick, activeBuffer.name);
}
let app = html`
@@ -1968,6 +2212,7 @@ export default class App extends Component {
onSubmit=${this.handleComposerSubmit}
autocomplete=${this.autocomplete}
commandOnly=${commandOnly}
maxLen=${privmsgMaxLen}
/>
${dialog}
${error}

View File

@@ -9,7 +9,7 @@ export default class NetworkForm 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.username) {
@@ -17,7 +17,7 @@ export default class NetworkForm extends Component {
}
}
handleChange(event) {
handleInput(event) {
let target = event.target;
let value = target.type == "checkbox" ? target.checked : target.value;
this.setState({ [target.name]: value });
@@ -31,7 +31,7 @@ export default class NetworkForm extends Component {
render() {
return html`
<form onChange=${this.handleChange} onSubmit=${this.handleSubmit}>
<form onInput=${this.handleInput} onSubmit=${this.handleSubmit}>
<label>
Username:<br/>
<input type="username" name="username" value=${this.state.username} required/>

View File

@@ -113,7 +113,6 @@ export default function BufferHeader(props) {
} else {
if (fullyConnected) {
actions.push(joinButton);
actions.push(settingsButton);
} else if (props.server.status === ServerStatus.DISCONNECTED) {
actions.push(reconnectButton);
}

View File

@@ -1,6 +1,6 @@
import * as irc from "../lib/irc.js";
import { html, Component } from "../lib/index.js";
import { BufferType, Unread, getBufferURL, getServerName } from "../state.js";
import { BufferType, Unread, ServerStatus, getBufferURL, getServerName } from "../state.js";
function BufferItem(props) {
function handleClick(event) {
@@ -26,6 +26,15 @@ function BufferItem(props) {
if (props.buffer.unread != Unread.NONE) {
classes.push("unread-" + props.buffer.unread);
}
if (props.buffer.type === BufferType.SERVER) {
let isError = props.server.status === ServerStatus.DISCONNECTED;
if (props.bouncerNetwork && props.bouncerNetwork.error) {
isError = true;
}
if (isError) {
classes.push("error");
}
}
return html`
<li class="${classes.join(" ")}">

View File

@@ -2,7 +2,7 @@ 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, ServerStatus, BufferEventsDisplayMode, getNickURL, getChannelURL, getMessageURL, isMessageBeforeReceipt, SettingsContext } from "../state.js";
import { BufferType, ServerStatus, BufferEventsDisplayMode, getMessageURL, isMessageBeforeReceipt, SettingsContext } from "../state.js";
import * as store from "../store.js";
import Membership from "./membership.js";
@@ -23,7 +23,7 @@ function Nick(props) {
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 })} class="nick nick-${colorIndex}" onClick=${handleClick}>${props.nick}</a>
`;
}
@@ -103,7 +103,7 @@ class LogLine extends Component {
}
function createChannel(channel) {
return html`
<a href=${getChannelURL(channel)} onClick=${onChannelClick}>
<a href=${irc.formatURL({ entity: channel })} onClick=${onChannelClick}>
${channel}
</a>
`;
@@ -146,6 +146,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";
}
@@ -263,6 +267,13 @@ class LogLine extends Component {
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) {
lineClass = "error";
@@ -649,6 +660,7 @@ export default class Buffer extends Component {
let hasUnreadSeparator = false;
let prevDate = new Date();
let foldMessages = [];
let lastMonitor = null;
buf.messages.forEach((msg) => {
let sep = [];
@@ -656,6 +668,14 @@ export default class Buffer extends Component {
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;

View File

@@ -118,8 +118,14 @@ export default class Composer extends Component {
handleWindowKeyDown(event) {
// If an <input> or <button> is focused, ignore.
if (document.activeElement !== document.body && document.activeElement.tagName !== "SECTION") {
return;
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.
@@ -222,6 +228,7 @@ export default class Composer extends Component {
placeholder=${placeholder}
enterkeyhint="send"
onKeyDown=${this.handleInputKeyDown}
maxlength=${this.props.maxLen}
/>
</form>
`;

View File

@@ -17,7 +17,7 @@ export default class ConnectForm 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.params) {
@@ -32,7 +32,7 @@ export default class ConnectForm extends Component {
}
}
handleChange(event) {
handleInput(event) {
let target = event.target;
let value = target.type == "checkbox" ? target.checked : target.value;
this.setState({ [target.name]: value });
@@ -63,6 +63,8 @@ export default class ConnectForm extends Component {
};
} else if (this.props.auth === "external") {
params.saslExternal = true;
} else if (this.props.auth === "oauth2") {
params.saslOauthBearer = this.props.params.saslOauthBearer;
}
if (this.state.autojoin) {
@@ -110,7 +112,7 @@ export default class ConnectForm extends Component {
}
let auth = null;
if (this.props.auth !== "disabled" && this.props.auth !== "external") {
if (this.props.auth !== "disabled" && this.props.auth !== "external" && this.props.auth !== "oauth2") {
auth = html`
<label>
Password:<br/>
@@ -145,7 +147,7 @@ export default class ConnectForm extends Component {
}
return html`
<form onChange=${this.handleChange} onSubmit=${this.handleSubmit}>
<form onInput=${this.handleInput} onSubmit=${this.handleSubmit}>
<h2>Connect to IRC</h2>
<label>
@@ -157,6 +159,7 @@ export default class ConnectForm extends Component {
disabled=${disabled}
ref=${this.nickInput}
required
autofocus
/>
</label>
<br/><br/>

View File

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

View File

@@ -8,7 +8,7 @@ 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) {
@@ -16,7 +16,7 @@ export default class JoinForm extends Component {
}
}
handleChange(event) {
handleInput(event) {
let target = event.target;
let value = target.type == "checkbox" ? target.checked : target.value;
this.setState({ [target.name]: value });
@@ -34,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";
@@ -73,7 +72,7 @@ class MemberItem extends Component {
return html`
<li>
<a
href=${getNickURL(this.props.nick)}
href=${irc.formatURL({ entity: this.props.nick, enttype: "user" })}
class=${classes.join(" ")}
title=${title}
onClick=${this.handleClick}
@@ -101,7 +100,7 @@ function sortMembers(a, b) {
return i - j;
}
return nickA < nickB ? -1 : 1;
return nickA.localeCompare(nickB);
}
export default class MemberList extends Component {

View File

@@ -22,7 +22,7 @@ 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);
if (props.params) {
@@ -35,7 +35,7 @@ export default class NetworkForm extends Component {
}
}
handleChange(event) {
handleInput(event) {
let target = event.target;
let value = target.type == "checkbox" ? target.checked : target.value;
this.setState({ [target.name]: value });
@@ -85,7 +85,7 @@ export default class NetworkForm extends Component {
}
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/>

View File

@@ -9,11 +9,11 @@ export default class RegisterForm extends Component {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
this.handleInput = this.handleInput.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleChange(event) {
handleInput(event) {
let target = event.target;
let value = target.type == "checkbox" ? target.checked : target.value;
this.setState({ [target.name]: value });
@@ -27,7 +27,7 @@ export default class RegisterForm extends Component {
render() {
return html`
<form onChange=${this.handleChange} onSubmit=${this.handleSubmit}>
<form onInput=${this.handleInput} onSubmit=${this.handleSubmit}>
<label>
E-mail:<br/>
<input

View File

@@ -9,11 +9,11 @@ export default class SettingsForm extends Component {
this.state.secondsInTimestamps = props.settings.secondsInTimestamps;
this.state.bufferEvents = props.settings.bufferEvents;
this.handleChange = this.handleChange.bind(this);
this.handleInput = this.handleInput.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleChange(event) {
handleInput(event) {
let target = event.target;
let value = target.type == "checkbox" ? target.checked : target.value;
this.setState({ [target.name]: value }, () => {
@@ -56,7 +56,7 @@ export default class SettingsForm extends Component {
}
return html`
<form onChange=${this.handleChange} onSubmit=${this.handleSubmit}>
<form onInput=${this.handleInput} onSubmit=${this.handleSubmit}>
<label>
<input
type="checkbox"

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>
`;
}
}

View File

@@ -9,11 +9,11 @@ export default class RegisterForm extends Component {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
this.handleInput = this.handleInput.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleChange(event) {
handleInput(event) {
let target = event.target;
let value = target.type == "checkbox" ? target.checked : target.value;
this.setState({ [target.name]: value });
@@ -27,7 +27,7 @@ export default class RegisterForm extends Component {
render() {
return html`
<form onChange=${this.handleChange} onSubmit=${this.handleSubmit}>
<form onInput=${this.handleInput} onSubmit=${this.handleSubmit}>
<p>Your account <strong>${this.props.account}</strong> has been created, but a verification code is required to complete the registration.</p>
<p>${linkify(this.props.message)}</p>

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

@@ -0,0 +1,45 @@
# 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"
}
}
```
## 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.

66
doc/setup.md Normal file
View File

@@ -0,0 +1,66 @@
# 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 wss://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 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

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

@@ -0,0 +1,14 @@
# URL parameters
gamja settings can be overridden using URL query parameters:
- `server`: path or URL to the WebSocket server
- `nick`: nickname
- `channels`: comma-separated list of channels to join (`#` needs to be escaped)
- `open`: [IRC URL] to open
- `debug`: if set to 1, debug mode is enabled
Alternatively, the channels can be set with the URL fragment (ie, by just
appending the channel name to the gamja URL).
[IRC URL]: https://datatracker.ietf.org/doc/html/draft-butcher-irc-url-04

View File

@@ -94,6 +94,14 @@ export const keybindings = [
}
},
},
{
key: "k",
ctrlKey: true,
description: "Switch to a buffer",
execute: (app) => {
app.openDialog("switch");
},
},
];
export function setup(app) {

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++) {
@@ -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;

View File

@@ -10,6 +10,7 @@ const permanentCaps = [
"chghost",
"echo-message",
"extended-join",
"extended-monitor",
"invite-notify",
"labeled-response",
"message-tags",
@@ -21,9 +22,9 @@ const permanentCaps = [
"draft/account-registration",
"draft/chathistory",
"draft/extended-monitor",
"draft/read-marker",
"soju.im/bouncer-networks",
"soju.im/read",
];
const RECONNECT_MIN_DELAY_MSEC = 10 * 1000; // 10s
@@ -48,6 +49,8 @@ const WHOX_FIELDS = {
"realname": "r",
};
const FALLBACK_SERVER_PREFIX = { name: "*" };
let lastLabel = 0;
let lastWhoxToken = 0;
@@ -107,7 +110,7 @@ export default class Client extends EventTarget {
};
status = Client.Status.DISCONNECTED;
serverPrefix = { name: "*" };
serverPrefix = FALLBACK_SERVER_PREFIX;
nick = null;
supportsCap = false;
caps = new irc.CapRegistry();
@@ -122,6 +125,7 @@ export default class Client extends EventTarget {
pass: null,
saslPlain: null,
saslExternal: false,
saslOauthBearer: null,
bouncerNetwork: null,
ping: 0,
eventPlayback: true,
@@ -188,7 +192,7 @@ export default class Client extends EventTarget {
this.ws = null;
this.setStatus(Client.Status.DISCONNECTED);
this.nick = null;
this.serverPrefix = null;
this.serverPrefix = FALLBACK_SERVER_PREFIX;
this.caps = new irc.CapRegistry();
this.batches = new Map();
Object.keys(this.pendingCmds).forEach((k) => {
@@ -293,6 +297,9 @@ export default class Client extends EventTarget {
if (!msg.prefix) {
msg.prefix = this.serverPrefix;
}
if (!msg.tags) {
msg.tags = {};
}
let msgBatch = null;
if (msg.tags["batch"]) {
@@ -465,7 +472,11 @@ export default class Client extends EventTarget {
initialResp = { command: "AUTHENTICATE", params: [respStr] };
break;
case "EXTERNAL":
initialResp = { command: "AUTHENTICATE", params: [base64.encode("")] };
initialResp = { command: "AUTHENTICATE", params: ["+"] };
break;
case "OAUTHBEARER":
let raw = "n,,\x01auth=Bearer " + params.token + "\x01\x01";
initialResp = { command: "AUTHENTICATE", params: [base64.encode(raw)] };
break;
default:
throw new Error(`Unknown authentication mechanism '${mechanism}'`);
@@ -658,6 +669,8 @@ export default class Client extends EventTarget {
promise = this.authenticate("PLAIN", this.params.saslPlain);
} else if (this.params.saslExternal) {
promise = this.authenticate("EXTERNAL");
} else if (this.params.saslOauthBearer) {
promise = this.authenticate("OAUTHBEARER", this.params.saslOauthBearer);
}
(promise || Promise.resolve()).catch((err) => {
this.dispatchError(err);
@@ -720,6 +733,11 @@ export default class Client extends EventTarget {
return chanTypes.indexOf(name[0]) >= 0;
}
isNick(name) {
// A dollar sign is used for server-wide broadcasts
return !this.isServer(name) && !this.isChannel(name) && !name.startsWith('$');
}
setPingInterval(sec) {
clearInterval(this.pingIntervalID);
this.pingIntervalID = null;
@@ -886,62 +904,56 @@ export default class Client extends EventTarget {
}
/* Fetch one page of history before the given date. */
fetchHistoryBefore(target, before, limit) {
async fetchHistoryBefore(target, before, limit) {
let max = Math.min(limit, this.isupport.chatHistory());
let params = ["BEFORE", target, "timestamp=" + before, max];
return this.roundtripChatHistory(params).then((messages) => {
return { messages, more: messages.length >= max };
});
let messages = await this.roundtripChatHistory(params);
return { messages, more: messages.length >= max };
}
/* Fetch history in ascending order. */
fetchHistoryBetween(target, after, before, limit) {
async fetchHistoryBetween(target, after, before, limit) {
let max = Math.min(limit, this.isupport.chatHistory());
let params = ["AFTER", target, "timestamp=" + after.time, max];
return this.roundtripChatHistory(params).then((messages) => {
limit -= messages.length;
if (limit <= 0) {
throw new Error("Cannot fetch all chat history: too many messages");
}
if (messages.length == max) {
// There are still more messages to fetch
after.time = messages[messages.length - 1].tags.time;
return this.fetchHistoryBetween(target, after, before, limit);
}
return { messages };
});
let messages = await this.roundtripChatHistory(params);
limit -= messages.length;
if (limit <= 0) {
throw new Error("Cannot fetch all chat history: too many messages");
}
if (messages.length >= max) {
// There are still more messages to fetch
after.time = messages[messages.length - 1].tags.time;
return await this.fetchHistoryBetween(target, after, before, limit);
}
return { messages };
}
fetchHistoryTargets(t1, t2) {
async fetchHistoryTargets(t1, t2) {
let msg = {
command: "CHATHISTORY",
params: ["TARGETS", "timestamp=" + t1, "timestamp=" + t2, 1000],
};
return this.fetchBatch(msg, "draft/chathistory-targets").then((batch) => {
return batch.messages.map((msg) => {
if (msg.command != "CHATHISTORY" || msg.params[0] != "TARGETS") {
throw new Error("Cannot fetch chat history targets: unexpected message " + msg);
}
return {
name: msg.params[1],
latestMessage: msg.params[2],
};
});
let batch = await this.fetchBatch(msg, "draft/chathistory-targets");
return batch.messages.map((msg) => {
console.assert(msg.command === "CHATHISTORY" && msg.params[0] === "TARGETS");
return {
name: msg.params[1],
latestMessage: msg.params[2],
};
});
}
listBouncerNetworks() {
async listBouncerNetworks() {
let req = { command: "BOUNCER", params: ["LISTNETWORKS"] };
return this.fetchBatch(req, "soju.im/bouncer-networks").then((batch) => {
let networks = new Map();
for (let msg of batch.messages) {
console.assert(msg.command === "BOUNCER" && msg.params[0] === "NETWORK");
let id = msg.params[1];
let params = irc.parseTags(msg.params[2]);
networks.set(id, params);
}
return networks;
});
let batch = await this.fetchBatch(req, "soju.im/bouncer-networks");
let networks = new Map();
for (let msg of batch.messages) {
console.assert(msg.command === "BOUNCER" && msg.params[0] === "NETWORK");
let id = msg.params[1];
let params = irc.parseTags(msg.params[2]);
networks.set(id, params);
}
return networks;
}
monitor(target) {
@@ -1015,4 +1027,22 @@ export default class Client extends EventTarget {
return { message: msg.params[2] };
});
}
supportsReadMarker() {
return this.caps.enabled.has("draft/read-marker");
}
fetchReadMarker(target) {
this.send({
command: "MARKREAD",
params: [target],
});
}
setReadMarker(target, t) {
this.send({
command: "MARKREAD",
params: [target, "timestamp="+t],
});
}
}

View File

@@ -52,6 +52,7 @@ 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";
@@ -478,6 +479,36 @@ export class Isupport {
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);
}
}
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 = {
@@ -808,6 +839,17 @@ export function parseURL(str) {
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();

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

4584
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,7 @@
"targets": {
"default": {
"source": "index.html",
"context": "browser",
"publicUrl": "."
}
},

View File

@@ -43,22 +43,14 @@ export const BufferEventsDisplayMode = {
export const SettingsContext = createContext("settings");
export function getNickURL(nick) {
return "irc:///" + encodeURIComponent(nick) + ",isuser";
}
export function getChannelURL(channel) {
return "irc:///" + encodeURIComponent(channel);
}
export function getBufferURL(buf) {
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);
}
@@ -157,7 +149,7 @@ function compareBuffers(a, b) {
return isServerBuffer(b) ? 1 : -1;
}
if (a.name != b.name) {
return a.name > b.name ? 1 : -1;
return a.name.localeCompare(b.name);
}
return 0;
}
@@ -343,6 +335,7 @@ export const State = {
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: [],
unread: Unread.NONE,
@@ -460,23 +453,6 @@ export const State = {
return { members };
});
break;
case irc.RPL_WHOREPLY:
case irc.RPL_WHOSPCRPL:
who = client.parseWhoReply(msg);
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 (msg.list.length == 0 && !client.isChannel(target) && target.indexOf("*") < 0) {
@@ -484,6 +460,28 @@ export const State = {
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;
users.set(who.nick, who);
}
return { users };
});
}
break;
case "JOIN":
@@ -647,21 +645,12 @@ 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 };
}
return state;
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,4 +1,4 @@
import { ReceiptType } from "./state.js";
import { ReceiptType, Unread } from "./state.js";
const PREFIX = "gamja_";
@@ -62,8 +62,6 @@ export class Buffer {
return JSON.stringify({
name: buf.name.toLowerCase(),
server: {
url: buf.server.url,
nick: buf.server.nick.toLowerCase(),
bouncerNetwork: buf.server.bouncerNetwork,
},
});
@@ -87,7 +85,7 @@ export class Buffer {
let updated = !this.m.has(key);
let prev = this.m.get(key) || {};
let unread = prev.unread;
let unread = prev.unread || Unread.NONE;
if (buf.unread !== undefined && buf.unread !== prev.unread) {
unread = buf.unread;
updated = true;
@@ -109,6 +107,12 @@ export class Buffer {
}
}
let closed = prev.closed || false;
if (buf.closed !== undefined && buf.closed !== prev.closed) {
closed = buf.closed;
updated = true;
}
if (!updated) {
return false;
}
@@ -117,9 +121,8 @@ export class Buffer {
name: buf.name,
unread,
receipts,
closed,
server: {
url: buf.server.url,
nick: buf.server.nick,
bouncerNetwork: buf.server.bouncerNetwork,
},
});
@@ -134,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

@@ -158,6 +158,9 @@ button.danger:hover {
color: white;
background-color: var(--gray);
}
#buffer-list li.error a {
color: red;
}
#buffer-list li.unread-message a {
color: #b37400;
}
@@ -349,9 +352,12 @@ 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 {
@@ -556,6 +562,29 @@ 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;
@@ -583,7 +612,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;
@@ -593,7 +623,8 @@ 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;
}
@@ -672,6 +703,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) {