Compare commits
29 Commits
v1.0.0-bet
...
v1.0.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
21
README.md
21
README.md
@@ -103,10 +103,12 @@ gamja default settings can be set using a `config.json` file at the root:
|
|||||||
"autojoin": "#gamja",
|
"autojoin": "#gamja",
|
||||||
// Controls how the password UI is presented to the user. Set to
|
// Controls how the password UI is presented to the user. Set to
|
||||||
// "mandatory" to require a password, "optional" to accept one but not
|
// "mandatory" to require a password, "optional" to accept one but not
|
||||||
// require it, "disabled" to never ask for a password, or "external" to
|
// require it, "disabled" to never ask for a password, "external" to
|
||||||
// use SASL EXTERNAL. Defaults to "optional".
|
// use SASL EXTERNAL, "oauth2" to use SASL OAUTHBEARER. Defaults to
|
||||||
|
// "optional".
|
||||||
"auth": "optional",
|
"auth": "optional",
|
||||||
// Default nickname (string).
|
// Default nickname (string). If it contains a "*" character, it will
|
||||||
|
// be replaced with a random string.
|
||||||
"nick": "asdf",
|
"nick": "asdf",
|
||||||
// Don't display the login UI, immediately connect to the server
|
// Don't display the login UI, immediately connect to the server
|
||||||
// (boolean).
|
// (boolean).
|
||||||
@@ -115,6 +117,19 @@ gamja default settings can be set using a `config.json` file at the root:
|
|||||||
// disable. Enabling PINGs can have an impact on client power usage and
|
// disable. Enabling PINGs can have an impact on client power usage and
|
||||||
// should only be enabled if necessary.
|
// should only be enabled if necessary.
|
||||||
"ping": 60
|
"ping": 60
|
||||||
|
},
|
||||||
|
// OAuth 2.0 settings.
|
||||||
|
"oauth2": {
|
||||||
|
// OAuth 2.0 server URL (string). The server must support OAuth 2.0
|
||||||
|
// Authorization Server Metadata (RFC 8414) or OpenID Connect
|
||||||
|
// Discovery.
|
||||||
|
"url": "https://auth.example.org",
|
||||||
|
// OAuth 2.0 client ID (string).
|
||||||
|
"client_id": "asdf",
|
||||||
|
// OAuth 2.0 client secret (string).
|
||||||
|
"client_secret": "ghjk",
|
||||||
|
// OAuth 2.0 scope (string).
|
||||||
|
"scope": "profile"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as irc from "../lib/irc.js";
|
import * as irc from "../lib/irc.js";
|
||||||
import Client from "../lib/client.js";
|
import Client from "../lib/client.js";
|
||||||
|
import * as oauth2 from "../lib/oauth2.js";
|
||||||
import Buffer from "./buffer.js";
|
import Buffer from "./buffer.js";
|
||||||
import BufferList from "./buffer-list.js";
|
import BufferList from "./buffer-list.js";
|
||||||
import BufferHeader from "./buffer-header.js";
|
import BufferHeader from "./buffer-header.js";
|
||||||
@@ -224,6 +225,7 @@ export default class App extends Component {
|
|||||||
this.handleVerifySubmit = this.handleVerifySubmit.bind(this);
|
this.handleVerifySubmit = this.handleVerifySubmit.bind(this);
|
||||||
this.handleOpenSettingsClick = this.handleOpenSettingsClick.bind(this);
|
this.handleOpenSettingsClick = this.handleOpenSettingsClick.bind(this);
|
||||||
this.handleSettingsChange = this.handleSettingsChange.bind(this);
|
this.handleSettingsChange = this.handleSettingsChange.bind(this);
|
||||||
|
this.handleSettingsDisconnect = this.handleSettingsDisconnect.bind(this);
|
||||||
|
|
||||||
this.state.settings = {
|
this.state.settings = {
|
||||||
...this.state.settings,
|
...this.state.settings,
|
||||||
@@ -249,32 +251,37 @@ export default class App extends Component {
|
|||||||
* - Default server URL constructed from the current URL location (this is
|
* - Default server URL constructed from the current URL location (this is
|
||||||
* done in fillConnectParams)
|
* done in fillConnectParams)
|
||||||
*/
|
*/
|
||||||
handleConfig(config) {
|
async handleConfig(config) {
|
||||||
this.setState({ loading: false });
|
|
||||||
|
|
||||||
let connectParams = { ...this.state.connectParams };
|
let connectParams = { ...this.state.connectParams };
|
||||||
|
|
||||||
if (config.server) {
|
if (typeof config.server.url === "string") {
|
||||||
if (typeof config.server.url === "string") {
|
connectParams.url = config.server.url;
|
||||||
connectParams.url = config.server.url;
|
}
|
||||||
}
|
if (Array.isArray(config.server.autojoin)) {
|
||||||
if (Array.isArray(config.server.autojoin)) {
|
connectParams.autojoin = config.server.autojoin;
|
||||||
connectParams.autojoin = config.server.autojoin;
|
} else if (typeof config.server.autojoin === "string") {
|
||||||
} else if (typeof config.server.autojoin === "string") {
|
connectParams.autojoin = [config.server.autojoin];
|
||||||
connectParams.autojoin = [config.server.autojoin];
|
}
|
||||||
}
|
if (typeof config.server.nick === "string") {
|
||||||
if (typeof config.server.nick === "string") {
|
connectParams.nick = config.server.nick;
|
||||||
connectParams.nick = config.server.nick;
|
}
|
||||||
}
|
if (typeof config.server.autoconnect === "boolean") {
|
||||||
if (typeof config.server.autoconnect === "boolean") {
|
connectParams.autoconnect = config.server.autoconnect;
|
||||||
connectParams.autoconnect = config.server.autoconnect;
|
}
|
||||||
}
|
if (config.server.auth === "external") {
|
||||||
if (config.server.auth === "external") {
|
connectParams.saslExternal = true;
|
||||||
connectParams.saslExternal = true;
|
}
|
||||||
}
|
if (typeof config.server.ping === "number") {
|
||||||
if (typeof config.server.ping === "number") {
|
connectParams.ping = config.server.ping;
|
||||||
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();
|
let autoconnect = store.autoconnect.load();
|
||||||
@@ -320,6 +327,48 @@ export default class App extends Component {
|
|||||||
|
|
||||||
this.config = config;
|
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 (autojoin.length > 0) {
|
||||||
if (connectParams.autoconnect) {
|
if (connectParams.autoconnect) {
|
||||||
// Ask the user whether they want to join that new channel.
|
// Ask the user whether they want to join that new channel.
|
||||||
@@ -330,7 +379,7 @@ export default class App extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState({ connectParams: connectParams });
|
this.setState({ loading: false, connectParams: connectParams });
|
||||||
|
|
||||||
if (connectParams.autoconnect) {
|
if (connectParams.autoconnect) {
|
||||||
this.setState({ connectForm: false });
|
this.setState({ connectForm: false });
|
||||||
@@ -338,6 +387,59 @@ 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");
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
showError(err) {
|
||||||
console.error("App error: ", err);
|
console.error("App error: ", err);
|
||||||
|
|
||||||
@@ -477,6 +579,10 @@ export default class App extends Component {
|
|||||||
this.whoUserBuffer(buf.name, buf.server);
|
this.whoUserBuffer(buf.name, buf.server);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (buf.type === BufferType.CHANNEL && !buf.hasInitialWho) {
|
||||||
|
this.whoChannelBuffer(buf.name, buf.server);
|
||||||
|
}
|
||||||
|
|
||||||
if (buf.type !== BufferType.SERVER) {
|
if (buf.type !== BufferType.SERVER) {
|
||||||
document.title = buf.name + ' · ' + this.baseTitle;
|
document.title = buf.name + ' · ' + this.baseTitle;
|
||||||
} else {
|
} else {
|
||||||
@@ -485,19 +591,29 @@ export default class App extends Component {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
addMessage(serverID, bufName, msg) {
|
prepareChatMessage(serverID, msg) {
|
||||||
let client = this.clients.get(serverID);
|
|
||||||
|
|
||||||
// Treat server-wide broadcasts as highlights. They're sent by server
|
// Treat server-wide broadcasts as highlights. They're sent by server
|
||||||
// operators and can contain important information.
|
// 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);
|
||||||
if (!msg.tags) {
|
msg.isHighlight = irc.isHighlight(msg, client.nick, client.cm) || irc.isServerBroadcast(msg);
|
||||||
msg.tags = {};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!msg.tags.time) {
|
if (!msg.tags.time) {
|
||||||
msg.tags.time = irc.formatDate(new Date());
|
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 stored = this.bufferStore.get({ name: bufName, server: client.params });
|
||||||
let deliveryReceipt = getReceipt(stored, ReceiptType.DELIVERED);
|
let deliveryReceipt = getReceipt(stored, ReceiptType.DELIVERED);
|
||||||
@@ -505,8 +621,6 @@ export default class App extends Component {
|
|||||||
let isDelivered = isMessageBeforeReceipt(msg, deliveryReceipt);
|
let isDelivered = isMessageBeforeReceipt(msg, deliveryReceipt);
|
||||||
let isRead = isMessageBeforeReceipt(msg, readReceipt);
|
let isRead = isMessageBeforeReceipt(msg, readReceipt);
|
||||||
|
|
||||||
// TODO: messages coming from infinite scroll shouldn't trigger notifications
|
|
||||||
|
|
||||||
if (client.isMyNick(msg.prefix.name)) {
|
if (client.isMyNick(msg.prefix.name)) {
|
||||||
isRead = true;
|
isRead = true;
|
||||||
}
|
}
|
||||||
@@ -750,8 +864,19 @@ export default class App extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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;
|
target = SERVER_BUFFER;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -854,6 +979,7 @@ export default class App extends Component {
|
|||||||
case irc.RPL_MONONLINE:
|
case irc.RPL_MONONLINE:
|
||||||
case irc.RPL_MONOFFLINE:
|
case irc.RPL_MONOFFLINE:
|
||||||
case irc.RPL_SASLSUCCESS:
|
case irc.RPL_SASLSUCCESS:
|
||||||
|
case irc.RPL_CHANNEL_URL:
|
||||||
case "AWAY":
|
case "AWAY":
|
||||||
case "SETNAME":
|
case "SETNAME":
|
||||||
case "CHGHOST":
|
case "CHGHOST":
|
||||||
@@ -868,7 +994,6 @@ export default class App extends Component {
|
|||||||
case "ACK":
|
case "ACK":
|
||||||
case "BOUNCER":
|
case "BOUNCER":
|
||||||
case "MARKREAD":
|
case "MARKREAD":
|
||||||
case "READ":
|
|
||||||
// Ignore these
|
// Ignore these
|
||||||
return [];
|
return [];
|
||||||
default:
|
default:
|
||||||
@@ -1019,10 +1144,9 @@ export default class App extends Component {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "MARKREAD":
|
case "MARKREAD":
|
||||||
case "READ":
|
|
||||||
target = msg.params[0];
|
target = msg.params[0];
|
||||||
let bound = msg.params[1];
|
let bound = msg.params[1];
|
||||||
if (!client.isMyNick(msg.prefix.name) || bound === "*" || !bound.startsWith("timestamp=")) {
|
if (bound === "*" || !bound.startsWith("timestamp=")) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let readReceipt = { time: bound.replace("timestamp=", "") };
|
let readReceipt = { time: bound.replace("timestamp=", "") };
|
||||||
@@ -1076,7 +1200,7 @@ export default class App extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
destBuffers.forEach((bufName) => {
|
destBuffers.forEach((bufName) => {
|
||||||
this.addMessage(serverID, bufName, msg);
|
this.handleChatMessage(serverID, bufName, msg);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1116,11 +1240,17 @@ export default class App extends Component {
|
|||||||
from = receiptFromMessage(lastMsg);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
client.fetchHistoryBetween(target.name, from, to, CHATHISTORY_MAX_SIZE).then((result) => {
|
client.fetchHistoryBetween(target.name, from, to, CHATHISTORY_MAX_SIZE).then((result) => {
|
||||||
for (let msg of result.messages) {
|
for (let msg of result.messages) {
|
||||||
let destBuffers = this.routeMessage(serverID, msg);
|
let destBuffers = this.routeMessage(serverID, msg);
|
||||||
for (let bufName of destBuffers) {
|
for (let bufName of destBuffers) {
|
||||||
this.addMessage(serverID, target.name, msg);
|
this.handleChatMessage(serverID, bufName, msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
@@ -1232,6 +1362,16 @@ export default class App extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
whoChannelBuffer(target, serverID) {
|
||||||
|
let client = this.clients.get(serverID);
|
||||||
|
|
||||||
|
client.who(target, {
|
||||||
|
fields: ["flags", "hostname", "nick", "realname", "username", "account"],
|
||||||
|
}).then(() => {
|
||||||
|
this.setBufferState({ name: target, server: serverID }, { hasInitialWho: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
open(target, serverID, password) {
|
open(target, serverID, password) {
|
||||||
if (!serverID) {
|
if (!serverID) {
|
||||||
serverID = State.getActiveServerID(this.state);
|
serverID = State.getActiveServerID(this.state);
|
||||||
@@ -1282,6 +1422,7 @@ export default class App extends Component {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let disconnectAll = client && !client.params.bouncerNetwork && client.caps.enabled.has("soju.im/bouncer-networks");
|
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);
|
this.disconnect(buf.server);
|
||||||
|
|
||||||
@@ -1307,7 +1448,7 @@ export default class App extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TODO: only clear autoconnect if this server is stored there
|
// TODO: only clear autoconnect if this server is stored there
|
||||||
if (buf.server == 1) {
|
if (isFirstServer) {
|
||||||
store.autoconnect.put(null);
|
store.autoconnect.put(null);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -1370,7 +1511,7 @@ export default class App extends Component {
|
|||||||
|
|
||||||
if (!client.caps.enabled.has("echo-message")) {
|
if (!client.caps.enabled.has("echo-message")) {
|
||||||
msg.prefix = { name: client.nick };
|
msg.prefix = { name: client.nick };
|
||||||
this.addMessage(serverID, target, msg);
|
this.handleChatMessage(serverID, target, msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1532,8 +1673,31 @@ export default class App extends Component {
|
|||||||
|
|
||||||
client.fetchHistoryBefore(buf.name, before, limit).then((result) => {
|
client.fetchHistoryBefore(buf.name, before, limit).then((result) => {
|
||||||
this.endOfHistory.set(buf.id, !result.more);
|
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) {
|
for (let msg of result.messages) {
|
||||||
this.addMessage(buf.server, buf.name, msg);
|
this.addChatMessage(buf.server, buf.name, msg);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1714,6 +1878,11 @@ export default class App extends Component {
|
|||||||
this.setState({ settings });
|
this.setState({ settings });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleSettingsDisconnect() {
|
||||||
|
this.dismissDialog();
|
||||||
|
this.disconnectAll();
|
||||||
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this.baseTitle = document.title;
|
this.baseTitle = document.title;
|
||||||
setupKeybindings(this);
|
setupKeybindings(this);
|
||||||
@@ -1725,7 +1894,11 @@ export default class App extends Component {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
if (this.state.loading) {
|
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;
|
let activeBuffer = null, activeServer = null, activeBouncerNetwork = null;
|
||||||
@@ -1891,8 +2064,8 @@ export default class App extends Component {
|
|||||||
settings=${this.state.settings}
|
settings=${this.state.settings}
|
||||||
showProtocolHandler=${dialogData.showProtocolHandler}
|
showProtocolHandler=${dialogData.showProtocolHandler}
|
||||||
onChange=${this.handleSettingsChange}
|
onChange=${this.handleSettingsChange}
|
||||||
onDisconnect=${() => this.disconnectAll()}
|
onDisconnect=${this.handleSettingsDisconnect}
|
||||||
onClose=${() => this.dismissDialog()}
|
onClose=${this.dismissDialog}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
`;
|
`;
|
||||||
@@ -1916,8 +2089,12 @@ export default class App extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let commandOnly = false;
|
let commandOnly = false;
|
||||||
|
let privmsgMaxLen;
|
||||||
if (activeBuffer && activeBuffer.type === BufferType.SERVER) {
|
if (activeBuffer && activeBuffer.type === BufferType.SERVER) {
|
||||||
commandOnly = true;
|
commandOnly = true;
|
||||||
|
} else if (activeBuffer) {
|
||||||
|
let client = this.clients.get(activeBuffer.server);
|
||||||
|
privmsgMaxLen = irc.getMaxPrivmsgLen(client.isupport, client.nick, activeBuffer.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
let app = html`
|
let app = html`
|
||||||
@@ -1969,6 +2146,7 @@ export default class App extends Component {
|
|||||||
onSubmit=${this.handleComposerSubmit}
|
onSubmit=${this.handleComposerSubmit}
|
||||||
autocomplete=${this.autocomplete}
|
autocomplete=${this.autocomplete}
|
||||||
commandOnly=${commandOnly}
|
commandOnly=${commandOnly}
|
||||||
|
maxLen=${privmsgMaxLen}
|
||||||
/>
|
/>
|
||||||
${dialog}
|
${dialog}
|
||||||
${error}
|
${error}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export default class NetworkForm extends Component {
|
|||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
this.handleChange = this.handleChange.bind(this);
|
this.handleInput = this.handleInput.bind(this);
|
||||||
this.handleSubmit = this.handleSubmit.bind(this);
|
this.handleSubmit = this.handleSubmit.bind(this);
|
||||||
|
|
||||||
if (props.username) {
|
if (props.username) {
|
||||||
@@ -17,7 +17,7 @@ export default class NetworkForm extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleChange(event) {
|
handleInput(event) {
|
||||||
let target = event.target;
|
let target = event.target;
|
||||||
let value = target.type == "checkbox" ? target.checked : target.value;
|
let value = target.type == "checkbox" ? target.checked : target.value;
|
||||||
this.setState({ [target.name]: value });
|
this.setState({ [target.name]: value });
|
||||||
@@ -31,7 +31,7 @@ export default class NetworkForm extends Component {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
return html`
|
return html`
|
||||||
<form onChange=${this.handleChange} onSubmit=${this.handleSubmit}>
|
<form onInput=${this.handleInput} onSubmit=${this.handleSubmit}>
|
||||||
<label>
|
<label>
|
||||||
Username:<br/>
|
Username:<br/>
|
||||||
<input type="username" name="username" value=${this.state.username} required/>
|
<input type="username" name="username" value=${this.state.username} required/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as irc from "../lib/irc.js";
|
import * as irc from "../lib/irc.js";
|
||||||
import { html, Component } from "../lib/index.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 BufferItem(props) {
|
||||||
function handleClick(event) {
|
function handleClick(event) {
|
||||||
@@ -26,6 +26,15 @@ function BufferItem(props) {
|
|||||||
if (props.buffer.unread != Unread.NONE) {
|
if (props.buffer.unread != Unread.NONE) {
|
||||||
classes.push("unread-" + props.buffer.unread);
|
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`
|
return html`
|
||||||
<li class="${classes.join(" ")}">
|
<li class="${classes.join(" ")}">
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ export default class Composer extends Component {
|
|||||||
placeholder=${placeholder}
|
placeholder=${placeholder}
|
||||||
enterkeyhint="send"
|
enterkeyhint="send"
|
||||||
onKeyDown=${this.handleInputKeyDown}
|
onKeyDown=${this.handleInputKeyDown}
|
||||||
|
maxlength=${this.props.maxLen}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default class ConnectForm extends Component {
|
|||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
this.handleChange = this.handleChange.bind(this);
|
this.handleInput = this.handleInput.bind(this);
|
||||||
this.handleSubmit = this.handleSubmit.bind(this);
|
this.handleSubmit = this.handleSubmit.bind(this);
|
||||||
|
|
||||||
if (props.params) {
|
if (props.params) {
|
||||||
@@ -32,7 +32,7 @@ export default class ConnectForm extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleChange(event) {
|
handleInput(event) {
|
||||||
let target = event.target;
|
let target = event.target;
|
||||||
let value = target.type == "checkbox" ? target.checked : target.value;
|
let value = target.type == "checkbox" ? target.checked : target.value;
|
||||||
this.setState({ [target.name]: value });
|
this.setState({ [target.name]: value });
|
||||||
@@ -63,6 +63,8 @@ export default class ConnectForm extends Component {
|
|||||||
};
|
};
|
||||||
} else if (this.props.auth === "external") {
|
} else if (this.props.auth === "external") {
|
||||||
params.saslExternal = true;
|
params.saslExternal = true;
|
||||||
|
} else if (this.props.auth === "oauth2") {
|
||||||
|
params.saslOauthBearer = this.props.params.saslOauthBearer;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.state.autojoin) {
|
if (this.state.autojoin) {
|
||||||
@@ -110,7 +112,7 @@ export default class ConnectForm extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let auth = null;
|
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`
|
auth = html`
|
||||||
<label>
|
<label>
|
||||||
Password:<br/>
|
Password:<br/>
|
||||||
@@ -145,7 +147,7 @@ export default class ConnectForm extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<form onChange=${this.handleChange} onSubmit=${this.handleSubmit}>
|
<form onInput=${this.handleInput} onSubmit=${this.handleSubmit}>
|
||||||
<h2>Connect to IRC</h2>
|
<h2>Connect to IRC</h2>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
@@ -157,6 +159,7 @@ export default class ConnectForm extends Component {
|
|||||||
disabled=${disabled}
|
disabled=${disabled}
|
||||||
ref=${this.nickInput}
|
ref=${this.nickInput}
|
||||||
required
|
required
|
||||||
|
autofocus
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<br/><br/>
|
<br/><br/>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export default class JoinForm extends Component {
|
|||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
this.handleChange = this.handleChange.bind(this);
|
this.handleInput = this.handleInput.bind(this);
|
||||||
this.handleSubmit = this.handleSubmit.bind(this);
|
this.handleSubmit = this.handleSubmit.bind(this);
|
||||||
|
|
||||||
if (props.channel) {
|
if (props.channel) {
|
||||||
@@ -16,7 +16,7 @@ export default class JoinForm extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleChange(event) {
|
handleInput(event) {
|
||||||
let target = event.target;
|
let target = event.target;
|
||||||
let value = target.type == "checkbox" ? target.checked : target.value;
|
let value = target.type == "checkbox" ? target.checked : target.value;
|
||||||
this.setState({ [target.name]: value });
|
this.setState({ [target.name]: value });
|
||||||
@@ -34,7 +34,7 @@ export default class JoinForm extends Component {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
return html`
|
return html`
|
||||||
<form onChange=${this.handleChange} onSubmit=${this.handleSubmit}>
|
<form onInput=${this.handleInput} onSubmit=${this.handleSubmit}>
|
||||||
<label>
|
<label>
|
||||||
Channel:<br/>
|
Channel:<br/>
|
||||||
<input type="text" name="channel" value=${this.state.channel} autofocus required/>
|
<input type="text" name="channel" value=${this.state.channel} autofocus required/>
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ function sortMembers(a, b) {
|
|||||||
return i - j;
|
return i - j;
|
||||||
}
|
}
|
||||||
|
|
||||||
return nickA < nickB ? -1 : 1;
|
return nickA.localeCompare(nickB);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class MemberList extends Component {
|
export default class MemberList extends Component {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export default class NetworkForm extends Component {
|
|||||||
|
|
||||||
this.prevParams = { ...defaultParams };
|
this.prevParams = { ...defaultParams };
|
||||||
|
|
||||||
this.handleChange = this.handleChange.bind(this);
|
this.handleInput = this.handleInput.bind(this);
|
||||||
this.handleSubmit = this.handleSubmit.bind(this);
|
this.handleSubmit = this.handleSubmit.bind(this);
|
||||||
|
|
||||||
if (props.params) {
|
if (props.params) {
|
||||||
@@ -35,7 +35,7 @@ export default class NetworkForm extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleChange(event) {
|
handleInput(event) {
|
||||||
let target = event.target;
|
let target = event.target;
|
||||||
let value = target.type == "checkbox" ? target.checked : target.value;
|
let value = target.type == "checkbox" ? target.checked : target.value;
|
||||||
this.setState({ [target.name]: value });
|
this.setState({ [target.name]: value });
|
||||||
@@ -85,7 +85,7 @@ export default class NetworkForm extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<form onChange=${this.handleChange} onSubmit=${this.handleSubmit}>
|
<form onInput=${this.handleInput} onSubmit=${this.handleSubmit}>
|
||||||
<label>
|
<label>
|
||||||
Hostname:<br/>
|
Hostname:<br/>
|
||||||
<input type="text" name="host" value=${this.state.host} autofocus required/>
|
<input type="text" name="host" value=${this.state.host} autofocus required/>
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ export default class RegisterForm extends Component {
|
|||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
this.handleChange = this.handleChange.bind(this);
|
this.handleInput = this.handleInput.bind(this);
|
||||||
this.handleSubmit = this.handleSubmit.bind(this);
|
this.handleSubmit = this.handleSubmit.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleChange(event) {
|
handleInput(event) {
|
||||||
let target = event.target;
|
let target = event.target;
|
||||||
let value = target.type == "checkbox" ? target.checked : target.value;
|
let value = target.type == "checkbox" ? target.checked : target.value;
|
||||||
this.setState({ [target.name]: value });
|
this.setState({ [target.name]: value });
|
||||||
@@ -27,7 +27,7 @@ export default class RegisterForm extends Component {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
return html`
|
return html`
|
||||||
<form onChange=${this.handleChange} onSubmit=${this.handleSubmit}>
|
<form onInput=${this.handleInput} onSubmit=${this.handleSubmit}>
|
||||||
<label>
|
<label>
|
||||||
E-mail:<br/>
|
E-mail:<br/>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ export default class SettingsForm extends Component {
|
|||||||
this.state.secondsInTimestamps = props.settings.secondsInTimestamps;
|
this.state.secondsInTimestamps = props.settings.secondsInTimestamps;
|
||||||
this.state.bufferEvents = props.settings.bufferEvents;
|
this.state.bufferEvents = props.settings.bufferEvents;
|
||||||
|
|
||||||
this.handleChange = this.handleChange.bind(this);
|
this.handleInput = this.handleInput.bind(this);
|
||||||
this.handleSubmit = this.handleSubmit.bind(this);
|
this.handleSubmit = this.handleSubmit.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleChange(event) {
|
handleInput(event) {
|
||||||
let target = event.target;
|
let target = event.target;
|
||||||
let value = target.type == "checkbox" ? target.checked : target.value;
|
let value = target.type == "checkbox" ? target.checked : target.value;
|
||||||
this.setState({ [target.name]: value }, () => {
|
this.setState({ [target.name]: value }, () => {
|
||||||
@@ -56,7 +56,7 @@ export default class SettingsForm extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<form onChange=${this.handleChange} onSubmit=${this.handleSubmit}>
|
<form onInput=${this.handleInput} onSubmit=${this.handleSubmit}>
|
||||||
<label>
|
<label>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ export default class RegisterForm extends Component {
|
|||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
this.handleChange = this.handleChange.bind(this);
|
this.handleInput = this.handleInput.bind(this);
|
||||||
this.handleSubmit = this.handleSubmit.bind(this);
|
this.handleSubmit = this.handleSubmit.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleChange(event) {
|
handleInput(event) {
|
||||||
let target = event.target;
|
let target = event.target;
|
||||||
let value = target.type == "checkbox" ? target.checked : target.value;
|
let value = target.type == "checkbox" ? target.checked : target.value;
|
||||||
this.setState({ [target.name]: value });
|
this.setState({ [target.name]: value });
|
||||||
@@ -27,7 +27,7 @@ export default class RegisterForm extends Component {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
return html`
|
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>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>
|
<p>${linkify(this.props.message)}</p>
|
||||||
|
|||||||
24
lib/ansi.js
24
lib/ansi.js
@@ -10,10 +10,26 @@ const COLOR_HEX = "\x04";
|
|||||||
const REVERSE_COLOR = "\x16";
|
const REVERSE_COLOR = "\x16";
|
||||||
const RESET = "\x0F";
|
const RESET = "\x0F";
|
||||||
|
|
||||||
|
const HEX_COLOR_LENGTH = 6;
|
||||||
|
|
||||||
function isDigit(ch) {
|
function isDigit(ch) {
|
||||||
return ch >= "0" && ch <= "9";
|
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) {
|
export function strip(text) {
|
||||||
let out = "";
|
let out = "";
|
||||||
for (let i = 0; i < text.length; i++) {
|
for (let i = 0; i < text.length; i++) {
|
||||||
@@ -43,7 +59,13 @@ export function strip(text) {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case COLOR_HEX:
|
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;
|
break;
|
||||||
default:
|
default:
|
||||||
out += ch;
|
out += ch;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const permanentCaps = [
|
|||||||
"chghost",
|
"chghost",
|
||||||
"echo-message",
|
"echo-message",
|
||||||
"extended-join",
|
"extended-join",
|
||||||
|
"extended-monitor",
|
||||||
"invite-notify",
|
"invite-notify",
|
||||||
"labeled-response",
|
"labeled-response",
|
||||||
"message-tags",
|
"message-tags",
|
||||||
@@ -21,9 +22,9 @@ const permanentCaps = [
|
|||||||
"draft/account-registration",
|
"draft/account-registration",
|
||||||
"draft/chathistory",
|
"draft/chathistory",
|
||||||
"draft/extended-monitor",
|
"draft/extended-monitor",
|
||||||
|
"draft/read-marker",
|
||||||
|
|
||||||
"soju.im/bouncer-networks",
|
"soju.im/bouncer-networks",
|
||||||
"soju.im/read",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const RECONNECT_MIN_DELAY_MSEC = 10 * 1000; // 10s
|
const RECONNECT_MIN_DELAY_MSEC = 10 * 1000; // 10s
|
||||||
@@ -122,6 +123,7 @@ export default class Client extends EventTarget {
|
|||||||
pass: null,
|
pass: null,
|
||||||
saslPlain: null,
|
saslPlain: null,
|
||||||
saslExternal: false,
|
saslExternal: false,
|
||||||
|
saslOauthBearer: null,
|
||||||
bouncerNetwork: null,
|
bouncerNetwork: null,
|
||||||
ping: 0,
|
ping: 0,
|
||||||
eventPlayback: true,
|
eventPlayback: true,
|
||||||
@@ -293,6 +295,9 @@ export default class Client extends EventTarget {
|
|||||||
if (!msg.prefix) {
|
if (!msg.prefix) {
|
||||||
msg.prefix = this.serverPrefix;
|
msg.prefix = this.serverPrefix;
|
||||||
}
|
}
|
||||||
|
if (!msg.tags) {
|
||||||
|
msg.tags = {};
|
||||||
|
}
|
||||||
|
|
||||||
let msgBatch = null;
|
let msgBatch = null;
|
||||||
if (msg.tags["batch"]) {
|
if (msg.tags["batch"]) {
|
||||||
@@ -467,6 +472,10 @@ export default class Client extends EventTarget {
|
|||||||
case "EXTERNAL":
|
case "EXTERNAL":
|
||||||
initialResp = { command: "AUTHENTICATE", params: [base64.encode("")] };
|
initialResp = { command: "AUTHENTICATE", params: [base64.encode("")] };
|
||||||
break;
|
break;
|
||||||
|
case "OAUTHBEARER":
|
||||||
|
let raw = "n,,\x01auth=Bearer " + params.token + "\x01\x01";
|
||||||
|
initialResp = { command: "AUTHENTICATE", params: [base64.encode(raw)] };
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown authentication mechanism '${mechanism}'`);
|
throw new Error(`Unknown authentication mechanism '${mechanism}'`);
|
||||||
}
|
}
|
||||||
@@ -658,6 +667,8 @@ export default class Client extends EventTarget {
|
|||||||
promise = this.authenticate("PLAIN", this.params.saslPlain);
|
promise = this.authenticate("PLAIN", this.params.saslPlain);
|
||||||
} else if (this.params.saslExternal) {
|
} else if (this.params.saslExternal) {
|
||||||
promise = this.authenticate("EXTERNAL");
|
promise = this.authenticate("EXTERNAL");
|
||||||
|
} else if (this.params.saslOauthBearer) {
|
||||||
|
promise = this.authenticate("OAUTHBEARER", this.params.saslOauthBearer);
|
||||||
}
|
}
|
||||||
(promise || Promise.resolve()).catch((err) => {
|
(promise || Promise.resolve()).catch((err) => {
|
||||||
this.dispatchError(err);
|
this.dispatchError(err);
|
||||||
@@ -720,6 +731,11 @@ export default class Client extends EventTarget {
|
|||||||
return chanTypes.indexOf(name[0]) >= 0;
|
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) {
|
setPingInterval(sec) {
|
||||||
clearInterval(this.pingIntervalID);
|
clearInterval(this.pingIntervalID);
|
||||||
this.pingIntervalID = null;
|
this.pingIntervalID = null;
|
||||||
@@ -903,7 +919,7 @@ export default class Client extends EventTarget {
|
|||||||
if (limit <= 0) {
|
if (limit <= 0) {
|
||||||
throw new Error("Cannot fetch all chat history: too many messages");
|
throw new Error("Cannot fetch all chat history: too many messages");
|
||||||
}
|
}
|
||||||
if (messages.length == max) {
|
if (messages.length >= max) {
|
||||||
// There are still more messages to fetch
|
// There are still more messages to fetch
|
||||||
after.time = messages[messages.length - 1].tags.time;
|
after.time = messages[messages.length - 1].tags.time;
|
||||||
return this.fetchHistoryBetween(target, after, before, limit);
|
return this.fetchHistoryBetween(target, after, before, limit);
|
||||||
@@ -1017,29 +1033,19 @@ export default class Client extends EventTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
supportsReadMarker() {
|
supportsReadMarker() {
|
||||||
return this.caps.enabled.has("draft/read-marker") || this.caps.enabled.has("soju.im/read");
|
return this.caps.enabled.has("draft/read-marker");
|
||||||
}
|
|
||||||
|
|
||||||
_markReadCmd() {
|
|
||||||
if (this.caps.enabled.has("draft/read-marker")) {
|
|
||||||
return "MARKREAD";
|
|
||||||
} else if (this.caps.enabled.has("soju.im/read")) {
|
|
||||||
return "READ";
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchReadMarker(target) {
|
fetchReadMarker(target) {
|
||||||
this.send({
|
this.send({
|
||||||
command: this._markReadCmd(),
|
command: "MARKREAD",
|
||||||
params: [target],
|
params: [target],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setReadMarker(target, t) {
|
setReadMarker(target, t) {
|
||||||
this.send({
|
this.send({
|
||||||
command: this._markReadCmd(),
|
command: "MARKREAD",
|
||||||
params: [target, "timestamp="+t],
|
params: [target, "timestamp="+t],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
31
lib/irc.js
31
lib/irc.js
@@ -52,6 +52,7 @@ export const ERR_BADCHANNELKEY = "475";
|
|||||||
// RFC 2812
|
// RFC 2812
|
||||||
export const ERR_UNAVAILRESOURCE = "437";
|
export const ERR_UNAVAILRESOURCE = "437";
|
||||||
// Other
|
// Other
|
||||||
|
export const RPL_CHANNEL_URL = "328";
|
||||||
export const RPL_CREATIONTIME = "329";
|
export const RPL_CREATIONTIME = "329";
|
||||||
export const RPL_QUIETLIST = "728";
|
export const RPL_QUIETLIST = "728";
|
||||||
export const RPL_ENDOFQUIETLIST = "729";
|
export const RPL_ENDOFQUIETLIST = "729";
|
||||||
@@ -478,6 +479,36 @@ export class Isupport {
|
|||||||
bot() {
|
bot() {
|
||||||
return this.raw.get("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 = {
|
export const CaseMapping = {
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
2678
package-lock.json
generated
2678
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
3
state.js
3
state.js
@@ -157,7 +157,7 @@ function compareBuffers(a, b) {
|
|||||||
return isServerBuffer(b) ? 1 : -1;
|
return isServerBuffer(b) ? 1 : -1;
|
||||||
}
|
}
|
||||||
if (a.name != b.name) {
|
if (a.name != b.name) {
|
||||||
return a.name > b.name ? 1 : -1;
|
return a.name.localeCompare(b.name);
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -343,6 +343,7 @@ export const State = {
|
|||||||
serverInfo: null, // if server
|
serverInfo: null, // if server
|
||||||
joined: false, // if channel
|
joined: false, // if channel
|
||||||
topic: null, // if channel
|
topic: null, // if channel
|
||||||
|
hasInitialWho: false, // if channel
|
||||||
members: new irc.CaseMapMap(null, client.cm), // if channel
|
members: new irc.CaseMapMap(null, client.cm), // if channel
|
||||||
messages: [],
|
messages: [],
|
||||||
unread: Unread.NONE,
|
unread: Unread.NONE,
|
||||||
|
|||||||
@@ -158,6 +158,9 @@ button.danger:hover {
|
|||||||
color: white;
|
color: white;
|
||||||
background-color: var(--gray);
|
background-color: var(--gray);
|
||||||
}
|
}
|
||||||
|
#buffer-list li.error a {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
#buffer-list li.unread-message a {
|
#buffer-list li.unread-message a {
|
||||||
color: #b37400;
|
color: #b37400;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user