57 Commits

Author SHA1 Message Date
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
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
26 changed files with 2838 additions and 2222 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

@@ -97,16 +97,18 @@ gamja default settings can be set using a `config.json` file at the root:
{
// IRC server settings.
"server": {
// WebSocket URL or path to connect to (string).
// WebSocket URL or path to connect to (string). Defaults to "/socket".
"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".
// require it, "disabled" to never ask for a password, "external" to
// use SASL EXTERNAL, "oauth2" to use SASL OAUTHBEARER. Defaults to
// "optional".
"auth": "optional",
// Default nickname (string).
// Default nickname (string). If it contains a "*" character, it will
// be replaced with a random string.
"nick": "asdf",
// Don't display the login UI, immediately connect to the server
// (boolean).
@@ -115,6 +117,19 @@ gamja default settings can be set using a `config.json` file at the root:
// disable. Enabling PINGs can have an impact on client power usage and
// should only be enabled if necessary.
"ping": 60
},
// OAuth 2.0 settings.
"oauth2": {
// OAuth 2.0 server URL (string). The server must support OAuth 2.0
// Authorization Server Metadata (RFC 8414) or OpenID Connect
// Discovery.
"url": "https://auth.example.org",
// OAuth 2.0 client ID (string).
"client_id": "asdf",
// OAuth 2.0 client secret (string).
"client_secret": "ghjk",
// OAuth 2.0 scope (string).
"scope": "profile"
}
}
```

View File

@@ -83,6 +83,22 @@ const kick = {
},
};
const 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];
@@ -108,21 +124,7 @@ export default {
getActiveClient(app).send({command: "AWAY", params});
},
},
"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");
}
},
},
"ban": ban,
"buffer": {
usage: "<name>",
description: "Switch to a buffer",

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";
@@ -11,12 +12,13 @@ import NetworkForm from "./network-form.js";
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 Composer from "./composer.js";
import ScrollManager from "./scroll-manager.js";
import Dialog from "./dialog.js";
import { html, Component, createRef } from "../lib/index.js";
import { strip as stripANSI } from "../lib/ansi.js";
import { SERVER_BUFFER, BufferType, ReceiptType, ServerStatus, Unread, State, getServerName, receiptFromMessage, isReceiptBefore, isMessageBeforeReceipt } from "../state.js";
import { SERVER_BUFFER, BufferType, ReceiptType, ServerStatus, Unread, BufferEventsDisplayMode, State, getServerName, receiptFromMessage, isReceiptBefore, isMessageBeforeReceipt, SettingsContext } from "../state.js";
import commands from "../commands.js";
import { setup as setupKeybindings } from "../keybindings.js";
import * as store from "../store.js";
@@ -196,6 +198,7 @@ export default class App extends Component {
*/
autoOpenURL = null;
messageNotifications = new Set();
baseTitle = null;
constructor(props) {
super(props);
@@ -220,6 +223,14 @@ export default class App extends Component {
this.handleRegisterSubmit = this.handleRegisterSubmit.bind(this);
this.handleVerifyClick = this.handleVerifyClick.bind(this);
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.state.settings = {
...this.state.settings,
...store.settings.load(),
};
this.bufferStore = new store.Buffer();
@@ -240,32 +251,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();
@@ -311,6 +327,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.
@@ -321,7 +379,7 @@ export default class App extends Component {
}
}
this.setState({ connectParams: connectParams });
this.setState({ loading: false, connectParams: connectParams });
if (connectParams.autoconnect) {
this.setState({ connectForm: false });
@@ -329,6 +387,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);
@@ -405,17 +517,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) {
@@ -470,22 +579,46 @@ export default class App extends Component {
if (buf.type === BufferType.NICK && !server.users.has(buf.name)) {
this.whoUserBuffer(buf.name, buf.server);
}
if (buf.type === BufferType.CHANNEL && !buf.hasInitialWho) {
this.whoChannelBuffer(buf.name, buf.server);
}
if (buf.type !== BufferType.SERVER) {
document.title = buf.name + ' · ' + this.baseTitle;
} else {
document.title = this.baseTitle;
}
});
}
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);
@@ -493,8 +626,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;
}
@@ -610,6 +741,13 @@ export default class App extends Component {
}
connect(params) {
// Merge our previous connection params so that config options such as
// the ping interval are applied
params = {
...this.state.connectParams,
...params,
};
let serverID = null;
this.setState((state) => {
let update;
@@ -618,7 +756,10 @@ export default class App extends Component {
});
this.setState({ connectParams: params });
let client = new Client(fillConnectParams(params));
let client = new Client({
...fillConnectParams(params),
eventPlayback: this.state.settings.bufferEvents !== BufferEventsDisplayMode.HIDE,
});
client.debug = this.debug;
this.clients.set(serverID, client);
@@ -720,11 +861,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;
}
@@ -827,6 +984,7 @@ export default class App extends Component {
case irc.RPL_MONONLINE:
case irc.RPL_MONOFFLINE:
case irc.RPL_SASLSUCCESS:
case irc.RPL_CHANNEL_URL:
case "AWAY":
case "SETNAME":
case "CHGHOST":
@@ -840,7 +998,7 @@ export default class App extends Component {
case "CHATHISTORY":
case "ACK":
case "BOUNCER":
case "READ":
case "MARKREAD":
// Ignore these
return [];
default:
@@ -851,15 +1009,12 @@ export default class App extends Component {
handleMessage(serverID, msg) {
let client = this.clients.get(serverID);
let destBuffers = this.routeMessage(serverID, msg);
if (irc.findBatchByType(msg, "chathistory")) {
destBuffers.forEach((bufName) => {
this.addMessage(serverID, bufName, msg);
});
return;
return; // Handled by the caller
}
let destBuffers = this.routeMessage(serverID, msg);
this.setState((state) => State.handleMessage(state, msg, serverID, client));
let target, channel;
@@ -993,10 +1148,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=", "") };
@@ -1050,7 +1205,7 @@ export default class App extends Component {
}
destBuffers.forEach((bufName) => {
this.addMessage(serverID, bufName, msg);
this.handleChatMessage(serverID, bufName, msg);
});
}
@@ -1090,7 +1245,20 @@ export default class App extends Component {
from = receiptFromMessage(lastMsg);
}
client.fetchHistoryBetween(target.name, from, to, CHATHISTORY_MAX_SIZE).catch((err) => {
// 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);
}
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.handleChatMessage(serverID, bufName, msg);
}
}
}).catch((err) => {
console.error("Failed to fetch backlog for '" + target.name + "': ", err);
this.showError("Failed to fetch backlog for '" + target.name + "'");
});
@@ -1194,14 +1362,21 @@ 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);
}
}
whoChannelBuffer(target, serverID) {
let client = this.clients.get(serverID);
client.who(target, {
fields: ["flags", "hostname", "nick", "realname", "username", "account"],
}).then(() => {
this.setBufferState({ name: target, server: serverID }, { hasInitialWho: true });
});
}
open(target, serverID, password) {
if (!serverID) {
serverID = State.getActiveServerID(this.state);
@@ -1252,6 +1427,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);
@@ -1277,7 +1453,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;
@@ -1287,7 +1463,9 @@ export default class App extends Component {
}
// fallthrough
case BufferType.NICK:
this.switchBuffer({ name: SERVER_BUFFER });
if (this.state.activeBuffer === buf.id) {
this.switchBuffer({ name: SERVER_BUFFER });
}
this.setState((state) => {
let buffers = new Map(state.buffers);
buffers.delete(buf.id);
@@ -1301,6 +1479,10 @@ export default class App extends Component {
}
}
disconnectAll() {
this.close(this.state.buffers.keys().next().value);
}
executeCommand(s) {
let parts = s.split(" ");
let name = parts[0].toLowerCase().slice(1);
@@ -1334,7 +1516,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);
}
}
@@ -1496,6 +1678,32 @@ export default class App extends Component {
client.fetchHistoryBefore(buf.name, before, limit).then((result) => {
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);
}
});
}
@@ -1658,13 +1866,44 @@ export default class App extends Component {
this.dismissDialog();
}
handleOpenSettingsClick() {
let showProtocolHandler = false;
for (let [id, client] of this.clients) {
if (client.caps.enabled.has("soju.im/bouncer-networks")) {
showProtocolHandler = true;
break;
}
}
this.openDialog("settings", { showProtocolHandler });
}
handleSettingsChange(settings) {
store.settings.put(settings);
this.setState({ settings });
}
handleSettingsDisconnect() {
this.dismissDialog();
this.disconnectAll();
}
componentDidMount() {
this.baseTitle = document.title;
setupKeybindings(this);
}
componentWillUnmount() {
document.title = this.baseTitle;
}
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;
@@ -1714,6 +1953,7 @@ export default class App extends Component {
onReconnect=${() => this.reconnect()}
onAddNetwork=${this.handleAddNetworkClick}
onManageNetwork=${() => this.handleManageNetworkClick(activeBuffer.server)}
onOpenSettings=${this.handleOpenSettingsClick}
/>
</section>
`;
@@ -1822,6 +2062,19 @@ export default class App extends Component {
</>
`;
break;
case "settings":
dialog = html`
<${Dialog} title="Settings" onDismiss=${this.dismissDialog}>
<${SettingsForm}
settings=${this.state.settings}
showProtocolHandler=${dialogData.showProtocolHandler}
onChange=${this.handleSettingsChange}
onDisconnect=${this.handleSettingsDisconnect}
onClose=${this.dismissDialog}
/>
</>
`;
break;
}
let error = null;
@@ -1841,11 +2094,15 @@ 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);
}
return html`
let app = html`
<section
id="buffer-list"
class=${this.state.openPanels.bufferList ? "expand" : ""}
@@ -1878,6 +2135,7 @@ export default class App extends Component {
buffer=${activeBuffer}
server=${activeServer}
bouncerNetwork=${activeBouncerNetwork}
settings=${this.state.settings}
onChannelClick=${this.handleChannelClick}
onNickClick=${this.handleNickClick}
onAuthClick=${() => this.handleAuthClick(activeBuffer.server)}
@@ -1893,9 +2151,16 @@ export default class App extends Component {
onSubmit=${this.handleComposerSubmit}
autocomplete=${this.autocomplete}
commandOnly=${commandOnly}
maxLen=${privmsgMaxLen}
/>
${dialog}
${error}
`;
return html`
<${SettingsContext.Provider} value=${this.state.settings}>
${app}
</>
`;
}
}

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

@@ -44,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,6 +77,12 @@ export default function BufferHeader(props) {
onClick=${props.onReconnect}
>Reconnect</button>
`;
let settingsButton = html`
<button
key="settings"
onClick="${props.onOpenSettings}"
>Settings</button>
`;
if (props.server.isBouncer) {
if (props.server.bouncerNetID) {
@@ -99,13 +108,7 @@ export default function BufferHeader(props) {
} else if (props.server.status === ServerStatus.DISCONNECTED) {
actions.push(reconnectButton);
}
actions.push(html`
<button
key="disconnect"
class="danger"
onClick=${props.onClose}
>Disconnect</button>
`);
actions.push(settingsButton);
}
} else {
if (fullyConnected) {
@@ -113,13 +116,7 @@ export default function BufferHeader(props) {
} else if (props.server.status === ServerStatus.DISCONNECTED) {
actions.push(reconnectButton);
}
actions.push(html`
<button
key="disconnect"
class="danger"
onClick=${props.onClose}
>Disconnect</button>
`);
actions.push(settingsButton);
}
break;
case BufferType.CHANNEL:
@@ -189,6 +186,10 @@ export default function BufferHeader(props) {
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;

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, getNickURL, getChannelURL, getMessageURL, isMessageBeforeReceipt } from "../state.js";
import { BufferType, ServerStatus, BufferEventsDisplayMode, getNickURL, getChannelURL, getMessageURL, isMessageBeforeReceipt, SettingsContext } from "../state.js";
import * as store from "../store.js";
import Membership from "./membership.js";
@@ -27,15 +27,22 @@ function Nick(props) {
`;
}
function Timestamp({ date, url }) {
function _Timestamp({ date, url, showSeconds }) {
if (!date) {
return html`<spam class="timestamp">--:--:--</span>`;
let timestamp = "--:--";
if (showSeconds) {
timestamp += ":--";
}
return html`<spam class="timestamp">${timestamp}</span>`;
}
let hh = date.getHours().toString().padStart(2, "0");
let mm = date.getMinutes().toString().padStart(2, "0");
let ss = date.getSeconds().toString().padStart(2, "0");
let timestamp = `${hh}:${mm}:${ss}`;
let timestamp = `${hh}:${mm}`;
if (showSeconds) {
let ss = date.getSeconds().toString().padStart(2, "0");
timestamp += ":" + ss;
}
return html`
<a
href=${url}
@@ -48,6 +55,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.
*
@@ -129,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";
}
@@ -546,7 +567,8 @@ 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() {
@@ -557,6 +579,7 @@ export default class Buffer extends Component {
let server = this.props.server;
let bouncerNetwork = this.props.bouncerNetwork;
let settings = this.props.settings;
let serverName = server.name;
let children = [];
@@ -633,6 +656,10 @@ export default class Buffer extends Component {
buf.messages.forEach((msg) => {
let sep = [];
if (settings.bufferEvents === BufferEventsDisplayMode.HIDE && canFoldMessage(msg)) {
return;
}
if (!hasUnreadSeparator && buf.type != BufferType.SERVER && !isMessageBeforeReceipt(msg, buf.prevReadReceipt)) {
sep.push(html`<${UnreadSeparator} key="unread"/>`);
hasUnreadSeparator = true;
@@ -651,7 +678,7 @@ export default class Buffer extends Component {
}
// TODO: consider checking the time difference too
if (canFoldMessage(msg)) {
if (settings.bufferEvents === BufferEventsDisplayMode.FOLD && canFoldMessage(msg)) {
foldMessages.push(msg);
return;
}

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

@@ -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

@@ -101,7 +101,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

@@ -34,6 +34,9 @@ export default class ScrollManager extends Component {
restoreScrollPosition() {
let target = this.props.target.current;
if (!target.firstChild) {
return;
}
let stickToKey = store.get(this.props.scrollKey);
if (!stickToKey) {

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

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>

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;

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);
}
var encoder = new TextEncoder();
var bytes = encoder.encode(data);
var trailing = bytes.length % 3;
var out = "";
for (var i = 0; i < bytes.length - trailing; i += 3) {
var 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) {
var u8 = bytes[bytes.length - 1];
out += alphabet[u8 >> 2];
out += alphabet[(u8 << 4) & 0x3F];
out += "==";
} else if (trailing == 2) {
var 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;
}

View File

@@ -1,4 +1,5 @@
import * as irc from "./irc.js";
import * as base64 from "./base64.js";
// Static list of capabilities that are always requested when supported by the
// server
@@ -9,6 +10,7 @@ const permanentCaps = [
"chghost",
"echo-message",
"extended-join",
"extended-monitor",
"invite-notify",
"labeled-response",
"message-tags",
@@ -19,11 +21,10 @@ const permanentCaps = [
"draft/account-registration",
"draft/chathistory",
"draft/event-playback",
"draft/extended-monitor",
"draft/read-marker",
"soju.im/bouncer-networks",
"soju.im/read",
];
const RECONNECT_MIN_DELAY_MSEC = 10 * 1000; // 10s
@@ -122,8 +123,10 @@ export default class Client extends EventTarget {
pass: null,
saslPlain: null,
saslExternal: false,
saslOauthBearer: null,
bouncerNetwork: null,
ping: 0,
eventPlayback: true,
};
debug = false;
batches = new Map();
@@ -292,6 +295,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"]) {
@@ -460,11 +466,15 @@ export default class Client extends EventTarget {
let initialResp = null;
switch (mechanism) {
case "PLAIN":
let respStr = btoa("\0" + params.username + "\0" + params.password);
let respStr = base64.encode("\0" + params.username + "\0" + params.password);
initialResp = { command: "AUTHENTICATE", params: [respStr] };
break;
case "EXTERNAL":
initialResp = { command: "AUTHENTICATE", params: [btoa("")] };
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}'`);
@@ -624,6 +634,9 @@ export default class Client extends EventTarget {
if (!this.params.bouncerNetwork) {
wantCaps.push("soju.im/bouncer-networks-notify");
}
if (this.params.eventPlayback) {
wantCaps.push("draft/event-playback");
}
let msg = this.caps.requestAvailable(wantCaps);
if (msg) {
@@ -654,6 +667,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);
@@ -716,6 +731,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,7 +906,7 @@ export default class Client extends EventTarget {
let max = Math.min(limit, this.isupport.chatHistory());
let params = ["BEFORE", target, "timestamp=" + before, max];
return this.roundtripChatHistory(params).then((messages) => {
return { more: messages.length >= max };
return { messages, more: messages.length >= max };
});
}
@@ -899,12 +919,12 @@ export default class Client extends EventTarget {
if (limit <= 0) {
throw new Error("Cannot fetch all chat history: too many messages");
}
if (messages.length == max) {
if (messages.length >= max) {
// There are still more messages to fetch
after.time = messages[messages.length - 1].tags.time;
return this.fetchHistoryBetween(target, after, before, limit);
}
return null;
return { messages };
});
}
@@ -1011,4 +1031,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";
@@ -474,6 +475,40 @@ export class Isupport {
}
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);
}
}
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 = {

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

4061
package-lock.json generated

File diff suppressed because it is too large Load Diff

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,6 +35,14 @@ export const ReceiptType = {
READ: "read",
};
export const BufferEventsDisplayMode = {
FOLD: "fold",
EXPAND: "expand",
HIDE: "hide",
};
export const SettingsContext = createContext("settings");
export function getNickURL(nick) {
return "irc:///" + encodeURIComponent(nick) + ",isuser";
}
@@ -148,7 +157,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;
}
@@ -209,6 +218,10 @@ export const State = {
buffers: new Map(),
activeBuffer: null,
bouncerNetworks: new Map(),
settings: {
secondsInTimestamps: true,
bufferEvents: BufferEventsDisplayMode.FOLD,
},
};
},
updateServer(state, id, updater) {
@@ -330,6 +343,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,
@@ -454,6 +468,10 @@ export const State = {
if (who.flags !== undefined) {
who.away = who.flags.indexOf("G") >= 0; // H for here, G for gone
who.operator = who.flags.indexOf("*") >= 0;
let botFlag = client.isupport.bot();
if (botFlag) {
who.bot = who.flags.indexOf(botFlag) >= 0;
}
delete who.flags;
}

View File

@@ -26,6 +26,7 @@ class Item {
export const autoconnect = new Item("autoconnect");
export const naggedProtocolHandler = new Item("naggedProtocolHandler");
export const settings = new Item("settings");
function debounce(f, delay) {
let timeout = null;
@@ -57,11 +58,12 @@ export class Buffer {
}
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,
nick: buf.server.nick.toLowerCase(),
bouncerNetwork: buf.server.bouncerNetwork,
},
});

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;
}
@@ -352,6 +355,8 @@ form input[type="url"],
form input[type="email"] {
box-sizing: border-box;
width: 100%;
font-family: inherit;
font-size: inherit;
}
a {
@@ -533,6 +538,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;