Compare commits
82 Commits
v1.0.0-bet
...
v1.0.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b8c886c91 | ||
|
|
c6a1513a07 | ||
|
|
3771b39979 | ||
|
|
ca4b3575b1 | ||
|
|
68cb6c56c4 | ||
|
|
e0de4d1b36 | ||
|
|
06f7cf9565 | ||
|
|
5e33919cce | ||
|
|
97b5970acb | ||
|
|
ffbbde7f28 | ||
|
|
8f29f0c35d | ||
|
|
3b383308d4 | ||
|
|
3a95fd5ba4 | ||
|
|
44a064274d | ||
|
|
fe016807da | ||
|
|
10d988b891 | ||
|
|
a0ed50a8e2 | ||
|
|
2f627eecad | ||
|
|
2d651ef901 | ||
|
|
535bdb2f52 | ||
|
|
57f64e9cc2 | ||
|
|
57809be989 | ||
|
|
6c26ee2156 | ||
|
|
5db0105dbd | ||
|
|
c8fda8ed53 | ||
|
|
fd63c169ed | ||
|
|
2c3fbdd605 | ||
|
|
2883234ff6 | ||
|
|
4f350ae223 | ||
|
|
f7459704f6 | ||
|
|
c6024e643a | ||
|
|
c547a32282 | ||
|
|
081f5743be | ||
|
|
3f059567c5 | ||
|
|
4b306305bf | ||
|
|
a172c810e9 | ||
|
|
ab3569e104 | ||
|
|
dc5e64aaac | ||
|
|
2d27168529 | ||
|
|
24ba3f5189 | ||
|
|
90a2c91651 | ||
|
|
e815295503 | ||
|
|
bbc94c88c0 | ||
|
|
84ca0a4408 | ||
|
|
84b68308b9 | ||
|
|
4964782c30 | ||
|
|
54e1fc93d9 | ||
|
|
34d3bd6df9 | ||
|
|
a13f74d466 | ||
|
|
a603b79e33 | ||
|
|
096fcbf829 | ||
|
|
a2d2a11d44 | ||
|
|
e6618c8a1f | ||
|
|
aa9aa78d71 | ||
|
|
4780b9c709 | ||
|
|
e7b69cec9a | ||
|
|
cfbd91d257 | ||
|
|
7138e43710 | ||
|
|
89647472ae | ||
|
|
e2dc32c0d3 | ||
|
|
1bcd9d3607 | ||
|
|
e4ebf5eb80 | ||
|
|
1428ec4d49 | ||
|
|
839e46360e | ||
|
|
d0064dd647 | ||
|
|
b9693d53ec | ||
|
|
f6ba40046f | ||
|
|
54453c5f44 | ||
|
|
fa80a56516 | ||
|
|
7cabb6f85b | ||
|
|
505a6fd5ab | ||
|
|
8e30806fec | ||
|
|
f0c398a10c | ||
|
|
baaf576d82 | ||
|
|
e3c2d85a94 | ||
|
|
576b9d51eb | ||
|
|
6b04cb1417 | ||
|
|
8507500d74 | ||
|
|
aaef4e1629 | ||
|
|
cdd2da90a9 | ||
|
|
4a981997f0 | ||
|
|
f45b51d981 |
@@ -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
|
||||
|
||||
96
README.md
96
README.md
@@ -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
|
||||
|
||||
49
commands.js
49
commands.js
@@ -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}`],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -83,6 +82,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 +123,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",
|
||||
|
||||
@@ -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,14 @@ 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 SwitcherForm from "./switcher-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 +199,8 @@ export default class App extends Component {
|
||||
*/
|
||||
autoOpenURL = null;
|
||||
messageNotifications = new Set();
|
||||
baseTitle = null;
|
||||
lastFocusPingDate = null;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
@@ -220,6 +225,16 @@ 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.handleSwitchSubmit = this.handleSwitchSubmit.bind(this);
|
||||
this.handleWindowFocus = this.handleWindowFocus.bind(this);
|
||||
|
||||
this.state.settings = {
|
||||
...this.state.settings,
|
||||
...store.settings.load(),
|
||||
};
|
||||
|
||||
this.bufferStore = new store.Buffer();
|
||||
|
||||
@@ -240,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();
|
||||
@@ -311,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.
|
||||
@@ -321,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 });
|
||||
@@ -329,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);
|
||||
|
||||
@@ -379,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) {
|
||||
@@ -405,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) {
|
||||
@@ -470,22 +582,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 +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;
|
||||
}
|
||||
@@ -573,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);
|
||||
}
|
||||
|
||||
@@ -610,6 +744,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 +759,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 +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;
|
||||
}
|
||||
|
||||
@@ -757,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);
|
||||
@@ -813,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:
|
||||
@@ -824,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":
|
||||
@@ -840,7 +1008,7 @@ export default class App extends Component {
|
||||
case "CHATHISTORY":
|
||||
case "ACK":
|
||||
case "BOUNCER":
|
||||
case "READ":
|
||||
case "MARKREAD":
|
||||
// Ignore these
|
||||
return [];
|
||||
default:
|
||||
@@ -851,15 +1019,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;
|
||||
@@ -874,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;
|
||||
}
|
||||
|
||||
@@ -993,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=", "") };
|
||||
@@ -1012,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") {
|
||||
@@ -1032,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:
|
||||
@@ -1050,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;
|
||||
@@ -1069,32 +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).catch((err) => {
|
||||
console.error("Failed to fetch backlog for '" + target.name + "': ", err);
|
||||
this.showError("Failed to fetch backlog for '" + target.name + "'");
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1194,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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1252,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);
|
||||
|
||||
@@ -1277,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;
|
||||
@@ -1287,7 +1488,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);
|
||||
@@ -1296,11 +1499,19 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
disconnectAll() {
|
||||
this.close(this.state.buffers.keys().next().value);
|
||||
}
|
||||
|
||||
executeCommand(s) {
|
||||
let parts = s.split(" ");
|
||||
let name = parts[0].toLowerCase().slice(1);
|
||||
@@ -1334,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1464,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;
|
||||
@@ -1494,9 +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);
|
||||
});
|
||||
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) {
|
||||
@@ -1614,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;
|
||||
}
|
||||
|
||||
@@ -1629,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() {
|
||||
@@ -1658,13 +1892,68 @@ 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();
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -1714,6 +2003,7 @@ export default class App extends Component {
|
||||
onReconnect=${() => this.reconnect()}
|
||||
onAddNetwork=${this.handleAddNetworkClick}
|
||||
onManageNetwork=${() => this.handleManageNetworkClick(activeBuffer.server)}
|
||||
onOpenSettings=${this.handleOpenSettingsClick}
|
||||
/>
|
||||
</section>
|
||||
`;
|
||||
@@ -1822,6 +2112,30 @@ 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;
|
||||
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;
|
||||
@@ -1841,11 +2155,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 +2196,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 +2212,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}
|
||||
</>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(" ")}">
|
||||
|
||||
@@ -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, getMessageURL, isMessageBeforeReceipt, SettingsContext } from "../state.js";
|
||||
import * as store from "../store.js";
|
||||
import Membership from "./membership.js";
|
||||
|
||||
@@ -23,19 +23,26 @@ 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>
|
||||
`;
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
@@ -86,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>
|
||||
`;
|
||||
@@ -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";
|
||||
}
|
||||
@@ -246,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";
|
||||
@@ -546,7 +574,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 +586,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 = [];
|
||||
@@ -630,9 +660,22 @@ export default class Buffer extends Component {
|
||||
let hasUnreadSeparator = false;
|
||||
let prevDate = new Date();
|
||||
let foldMessages = [];
|
||||
let lastMonitor = null;
|
||||
buf.messages.forEach((msg) => {
|
||||
let sep = [];
|
||||
|
||||
if (settings.bufferEvents === BufferEventsDisplayMode.HIDE && canFoldMessage(msg)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.command === irc.RPL_MONONLINE || msg.command === irc.RPL_MONOFFLINE) {
|
||||
let skip = !lastMonitor || msg.command === lastMonitor;
|
||||
lastMonitor = msg.command;
|
||||
if (skip) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasUnreadSeparator && buf.type != BufferType.SERVER && !isMessageBeforeReceipt(msg, buf.prevReadReceipt)) {
|
||||
sep.push(html`<${UnreadSeparator} key="unread"/>`);
|
||||
hasUnreadSeparator = true;
|
||||
@@ -651,7 +694,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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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/>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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/>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
112
components/settings-form.js
Normal file
@@ -0,0 +1,112 @@
|
||||
import { html, Component } from "../lib/index.js";
|
||||
|
||||
export default class SettingsForm extends Component {
|
||||
state = {};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state.secondsInTimestamps = props.settings.secondsInTimestamps;
|
||||
this.state.bufferEvents = props.settings.bufferEvents;
|
||||
|
||||
this.handleInput = this.handleInput.bind(this);
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
}
|
||||
|
||||
handleInput(event) {
|
||||
let target = event.target;
|
||||
let value = target.type == "checkbox" ? target.checked : target.value;
|
||||
this.setState({ [target.name]: value }, () => {
|
||||
this.props.onChange(this.state);
|
||||
});
|
||||
}
|
||||
|
||||
handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
this.props.onClose();
|
||||
}
|
||||
|
||||
registerProtocol() {
|
||||
let url = window.location.origin + window.location.pathname + "?open=%s";
|
||||
try {
|
||||
navigator.registerProtocolHandler("irc", url);
|
||||
navigator.registerProtocolHandler("ircs", url);
|
||||
} catch (err) {
|
||||
console.error("Failed to register protocol handler: ", err);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
let protocolHandler = null;
|
||||
if (this.props.showProtocolHandler) {
|
||||
protocolHandler = html`
|
||||
<div class="protocol-handler">
|
||||
<div class="left">
|
||||
Set gamja as your default IRC client for this browser.
|
||||
IRC links will be automatically opened here.
|
||||
</div>
|
||||
<div class="right">
|
||||
<button type="button" onClick=${() => this.registerProtocol()}>
|
||||
Enable
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<br/><br/>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<form onInput=${this.handleInput} onSubmit=${this.handleSubmit}>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="secondsInTimestamps"
|
||||
checked=${this.state.secondsInTimestamps}
|
||||
/>
|
||||
Show seconds in time indicator
|
||||
</label>
|
||||
<br/><br/>
|
||||
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="bufferEvents"
|
||||
value="fold"
|
||||
checked=${this.state.bufferEvents === "fold"}
|
||||
/>
|
||||
Show and fold chat events
|
||||
</label>
|
||||
<br/>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="bufferEvents"
|
||||
value="expand"
|
||||
checked=${this.state.bufferEvents === "expand"}
|
||||
/>
|
||||
Show and expand chat events
|
||||
</label>
|
||||
<br/>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="bufferEvents"
|
||||
value="hide"
|
||||
checked=${this.state.bufferEvents === "hide"}
|
||||
/>
|
||||
Hide chat events
|
||||
</label>
|
||||
<br/><br/>
|
||||
|
||||
${protocolHandler}
|
||||
|
||||
<button type="button" class="danger" onClick=${() => this.props.onDisconnect()}>
|
||||
Disconnect
|
||||
</button>
|
||||
<button>
|
||||
Close
|
||||
</button>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
}
|
||||
171
components/switcher-form.js
Normal file
171
components/switcher-form.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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
45
doc/config-file.md
Normal 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
66
doc/setup.md
Normal 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
14
doc/url-params.md
Normal 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
|
||||
@@ -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) {
|
||||
|
||||
24
lib/ansi.js
24
lib/ansi.js
@@ -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
42
lib/base64.js
Normal 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;
|
||||
}
|
||||
124
lib/client.js
124
lib/client.js
@@ -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
|
||||
@@ -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,8 +125,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();
|
||||
@@ -187,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) => {
|
||||
@@ -292,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"]) {
|
||||
@@ -460,11 +468,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 +636,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 +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);
|
||||
@@ -716,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;
|
||||
@@ -882,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 { 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 null;
|
||||
});
|
||||
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) {
|
||||
@@ -1011,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],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
46
lib/irc.js
46
lib/irc.js
@@ -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 = {
|
||||
@@ -804,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
109
lib/oauth2.js
Normal 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;
|
||||
}
|
||||
5217
package-lock.json
generated
5217
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@
|
||||
"targets": {
|
||||
"default": {
|
||||
"source": "index.html",
|
||||
"context": "browser",
|
||||
"publicUrl": "."
|
||||
}
|
||||
},
|
||||
|
||||
72
state.js
72
state.js
@@ -1,5 +1,6 @@
|
||||
import * as irc from "./lib/irc.js";
|
||||
import Client from "./lib/client.js";
|
||||
import { createContext } from "./lib/index.js";
|
||||
|
||||
export const SERVER_BUFFER = "*";
|
||||
|
||||
@@ -34,22 +35,22 @@ export const ReceiptType = {
|
||||
READ: "read",
|
||||
};
|
||||
|
||||
export function getNickURL(nick) {
|
||||
return "irc:///" + encodeURIComponent(nick) + ",isuser";
|
||||
}
|
||||
export const BufferEventsDisplayMode = {
|
||||
FOLD: "fold",
|
||||
EXPAND: "expand",
|
||||
HIDE: "hide",
|
||||
};
|
||||
|
||||
export function getChannelURL(channel) {
|
||||
return "irc:///" + encodeURIComponent(channel);
|
||||
}
|
||||
export const SettingsContext = createContext("settings");
|
||||
|
||||
export function getBufferURL(buf) {
|
||||
switch (buf.type) {
|
||||
case BufferType.SERVER:
|
||||
return "irc:///";
|
||||
return irc.formatURL();
|
||||
case BufferType.CHANNEL:
|
||||
return getChannelURL(buf.name);
|
||||
return irc.formatURL({ entity: buf.name });
|
||||
case BufferType.NICK:
|
||||
return getNickURL(buf.name);
|
||||
return irc.formatURL({ entity: buf.name, enttype: "user" });
|
||||
}
|
||||
throw new Error("Unknown buffer type: " + buf.type);
|
||||
}
|
||||
@@ -148,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;
|
||||
}
|
||||
@@ -209,6 +210,10 @@ export const State = {
|
||||
buffers: new Map(),
|
||||
activeBuffer: null,
|
||||
bouncerNetworks: new Map(),
|
||||
settings: {
|
||||
secondsInTimestamps: true,
|
||||
bufferEvents: BufferEventsDisplayMode.FOLD,
|
||||
},
|
||||
};
|
||||
},
|
||||
updateServer(state, id, updater) {
|
||||
@@ -330,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,
|
||||
@@ -447,19 +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;
|
||||
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) {
|
||||
@@ -467,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":
|
||||
@@ -630,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 };
|
||||
}
|
||||
|
||||
|
||||
29
store.js
29
store.js
@@ -1,4 +1,4 @@
|
||||
import { ReceiptType } from "./state.js";
|
||||
import { ReceiptType, Unread } from "./state.js";
|
||||
|
||||
const PREFIX = "gamja_";
|
||||
|
||||
@@ -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,10 @@ 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,
|
||||
bouncerNetwork: buf.server.bouncerNetwork,
|
||||
},
|
||||
});
|
||||
@@ -85,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;
|
||||
@@ -107,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;
|
||||
}
|
||||
@@ -115,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,
|
||||
},
|
||||
});
|
||||
@@ -132,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 {
|
||||
|
||||
49
style.css
49
style.css
@@ -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 {
|
||||
@@ -533,6 +539,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;
|
||||
@@ -548,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;
|
||||
@@ -575,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;
|
||||
@@ -585,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;
|
||||
}
|
||||
@@ -664,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) {
|
||||
|
||||
Reference in New Issue
Block a user