Compare commits
31 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 |
111
README.md
111
README.md
@@ -12,52 +12,7 @@ First install dependencies:
|
|||||||
|
|
||||||
npm install --production
|
npm install --production
|
||||||
|
|
||||||
Then configure an HTTP server to serve the gamja files. Below are some
|
Then [configure an HTTP server] to serve the gamja files.
|
||||||
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).
|
|
||||||
|
|
||||||
### Development server
|
### Development server
|
||||||
|
|
||||||
@@ -76,63 +31,9 @@ Optionally, [Parcel] can be used to build a minified version of gamja.
|
|||||||
npm install --include=dev
|
npm install --include=dev
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
## Query parameters
|
## Configuration
|
||||||
|
|
||||||
gamja settings can be overridden using URL query parameters:
|
gamja can be configured via a [configuration file] and via [URL 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, "external" to
|
|
||||||
// use SASL EXTERNAL, "oauth2" to use SASL OAUTHBEARER. Defaults to
|
|
||||||
// "optional".
|
|
||||||
"auth": "optional",
|
|
||||||
// Default nickname (string). If it contains a "*" character, it will
|
|
||||||
// be replaced with a random string.
|
|
||||||
"nick": "asdf",
|
|
||||||
// Don't display the login UI, immediately connect to the server
|
|
||||||
// (boolean).
|
|
||||||
"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
|
|
||||||
},
|
|
||||||
// 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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
@@ -146,10 +47,10 @@ AGPLv3, see LICENSE.
|
|||||||
Copyright (C) 2020 The gamja Contributors
|
Copyright (C) 2020 The gamja Contributors
|
||||||
|
|
||||||
[gamja]: https://sr.ht/~emersion/gamja/
|
[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
|
[mailing list]: https://lists.sr.ht/~emersion/public-inbox
|
||||||
[issue tracker]: https://todo.sr.ht/~emersion/gamja
|
[issue tracker]: https://todo.sr.ht/~emersion/gamja
|
||||||
[Parcel]: https://parceljs.org
|
[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
|
[#soju on Libera Chat]: ircs://irc.libera.chat/#soju
|
||||||
|
|||||||
17
commands.js
17
commands.js
@@ -25,21 +25,20 @@ function getActiveChannel(app) {
|
|||||||
return activeBuffer.name;
|
return activeBuffer.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setUserHostMode(app, args, mode) {
|
async function setUserHostMode(app, args, mode) {
|
||||||
let nick = args[0];
|
let nick = args[0];
|
||||||
if (!nick) {
|
if (!nick) {
|
||||||
throw new Error("Missing nick");
|
throw new Error("Missing nick");
|
||||||
}
|
}
|
||||||
let activeChannel = getActiveChannel(app);
|
let activeChannel = getActiveChannel(app);
|
||||||
let client = getActiveClient(app);
|
let client = getActiveClient(app);
|
||||||
client.whois(nick).then((whois) => {
|
let whois = await client.whois(nick);
|
||||||
const info = whois[irc.RPL_WHOISUSER].params;
|
const info = whois[irc.RPL_WHOISUSER].params;
|
||||||
const user = info[2];
|
const user = info[2];
|
||||||
const host = info[3];
|
const host = info[3];
|
||||||
client.send({
|
client.send({
|
||||||
command: "MODE",
|
command: "MODE",
|
||||||
params: [activeChannel, mode, `*!${user}@${host}`],
|
params: [activeChannel, mode, `*!${user}@${host}`],
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import AuthForm from "./auth-form.js";
|
|||||||
import RegisterForm from "./register-form.js";
|
import RegisterForm from "./register-form.js";
|
||||||
import VerifyForm from "./verify-form.js";
|
import VerifyForm from "./verify-form.js";
|
||||||
import SettingsForm from "./settings-form.js";
|
import SettingsForm from "./settings-form.js";
|
||||||
|
import SwitcherForm from "./switcher-form.js";
|
||||||
import Composer from "./composer.js";
|
import Composer from "./composer.js";
|
||||||
import ScrollManager from "./scroll-manager.js";
|
import ScrollManager from "./scroll-manager.js";
|
||||||
import Dialog from "./dialog.js";
|
import Dialog from "./dialog.js";
|
||||||
@@ -199,6 +200,7 @@ export default class App extends Component {
|
|||||||
autoOpenURL = null;
|
autoOpenURL = null;
|
||||||
messageNotifications = new Set();
|
messageNotifications = new Set();
|
||||||
baseTitle = null;
|
baseTitle = null;
|
||||||
|
lastFocusPingDate = null;
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
@@ -226,6 +228,8 @@ export default class App extends Component {
|
|||||||
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.handleSettingsDisconnect = this.handleSettingsDisconnect.bind(this);
|
||||||
|
this.handleSwitchSubmit = this.handleSwitchSubmit.bind(this);
|
||||||
|
this.handleWindowFocus = this.handleWindowFocus.bind(this);
|
||||||
|
|
||||||
this.state.settings = {
|
this.state.settings = {
|
||||||
...this.state.settings,
|
...this.state.settings,
|
||||||
@@ -394,6 +398,7 @@ export default class App extends Component {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch OAuth 2.0 server metadata:", err);
|
console.error("Failed to fetch OAuth 2.0 server metadata:", err);
|
||||||
this.showError("Failed to fetch OAuth 2.0 server metadata");
|
this.showError("Failed to fetch OAuth 2.0 server metadata");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
oauth2.redirectAuthorize({
|
oauth2.redirectAuthorize({
|
||||||
@@ -490,13 +495,12 @@ export default class App extends Component {
|
|||||||
if (client.caps.enabled.has("draft/chathistory") && stored) {
|
if (client.caps.enabled.has("draft/chathistory") && stored) {
|
||||||
this.setBufferState({ server: serverID, name }, { unread: stored.unread });
|
this.setBufferState({ server: serverID, name }, { unread: stored.unread });
|
||||||
}
|
}
|
||||||
if (!stored) {
|
|
||||||
this.bufferStore.put({
|
this.bufferStore.put({
|
||||||
name,
|
name,
|
||||||
server: client.params,
|
server: client.params,
|
||||||
unread: Unread.NONE,
|
closed: false,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createBuffer(serverID, name) {
|
createBuffer(serverID, name) {
|
||||||
@@ -599,6 +603,10 @@ export default class App extends Component {
|
|||||||
msg.isHighlight = irc.isHighlight(msg, client.nick, client.cm) || irc.isServerBroadcast(msg);
|
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) {
|
if (!msg.tags.time) {
|
||||||
msg.tags.time = irc.formatDate(new Date());
|
msg.tags.time = irc.formatDate(new Date());
|
||||||
}
|
}
|
||||||
@@ -699,7 +707,7 @@ export default class App extends Component {
|
|||||||
|
|
||||||
// Open a new buffer if the message doesn't come from me or is a
|
// Open a new buffer if the message doesn't come from me or is a
|
||||||
// self-message
|
// 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);
|
this.createBuffer(serverID, bufName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -909,7 +917,7 @@ export default class App extends Component {
|
|||||||
if (buf.server != serverID) {
|
if (buf.server != serverID) {
|
||||||
return;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
affectedBuffers.push(buf.name);
|
affectedBuffers.push(buf.name);
|
||||||
@@ -965,6 +973,15 @@ export default class App extends Component {
|
|||||||
case irc.RPL_INVITING:
|
case irc.RPL_INVITING:
|
||||||
channel = msg.params[2];
|
channel = msg.params[2];
|
||||||
return [channel];
|
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_YOURHOST:
|
||||||
case irc.RPL_MYINFO:
|
case irc.RPL_MYINFO:
|
||||||
case irc.RPL_ISUPPORT:
|
case irc.RPL_ISUPPORT:
|
||||||
@@ -976,8 +993,6 @@ export default class App extends Component {
|
|||||||
case irc.RPL_TOPICWHOTIME:
|
case irc.RPL_TOPICWHOTIME:
|
||||||
case irc.RPL_NAMREPLY:
|
case irc.RPL_NAMREPLY:
|
||||||
case irc.RPL_ENDOFNAMES:
|
case irc.RPL_ENDOFNAMES:
|
||||||
case irc.RPL_MONONLINE:
|
|
||||||
case irc.RPL_MONOFFLINE:
|
|
||||||
case irc.RPL_SASLSUCCESS:
|
case irc.RPL_SASLSUCCESS:
|
||||||
case irc.RPL_CHANNEL_URL:
|
case irc.RPL_CHANNEL_URL:
|
||||||
case "AWAY":
|
case "AWAY":
|
||||||
@@ -1024,7 +1039,7 @@ export default class App extends Component {
|
|||||||
// Restore opened channel and user buffers
|
// Restore opened channel and user buffers
|
||||||
let join = [];
|
let join = [];
|
||||||
for (let buf of this.bufferStore.list(client.params)) {
|
for (let buf of this.bufferStore.list(client.params)) {
|
||||||
if (buf.name === "*") {
|
if (buf.name === "*" || buf.closed) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1162,9 +1177,13 @@ export default class App extends Component {
|
|||||||
notif.close();
|
notif.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let unread;
|
||||||
|
let closed = true;
|
||||||
this.setBufferState({ server: serverID, name: target }, (buf) => {
|
this.setBufferState({ server: serverID, name: target }, (buf) => {
|
||||||
|
closed = false;
|
||||||
|
|
||||||
// Re-compute unread status
|
// Re-compute unread status
|
||||||
let unread = Unread.NONE;
|
unread = Unread.NONE;
|
||||||
for (let i = buf.messages.length - 1; i >= 0; i--) {
|
for (let i = buf.messages.length - 1; i >= 0; i--) {
|
||||||
let msg = buf.messages[i];
|
let msg = buf.messages[i];
|
||||||
if (msg.command !== "PRIVMSG" && msg.command !== "NOTICE") {
|
if (msg.command !== "PRIVMSG" && msg.command !== "NOTICE") {
|
||||||
@@ -1182,14 +1201,15 @@ export default class App extends Component {
|
|||||||
unread = Unread.MESSAGE;
|
unread = Unread.MESSAGE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { unread };
|
||||||
|
}, () => {
|
||||||
this.bufferStore.put({
|
this.bufferStore.put({
|
||||||
name: target,
|
name: target,
|
||||||
server: client.params,
|
server: client.params,
|
||||||
unread,
|
unread,
|
||||||
|
closed,
|
||||||
receipts: { [ReceiptType.READ]: readReceipt },
|
receipts: { [ReceiptType.READ]: readReceipt },
|
||||||
});
|
});
|
||||||
|
|
||||||
return { unread };
|
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
@@ -1204,7 +1224,7 @@ export default class App extends Component {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchBacklog(serverID) {
|
async fetchBacklog(serverID) {
|
||||||
let client = this.clients.get(serverID);
|
let client = this.clients.get(serverID);
|
||||||
if (!client.caps.enabled.has("draft/chathistory")) {
|
if (!client.caps.enabled.has("draft/chathistory")) {
|
||||||
return;
|
return;
|
||||||
@@ -1219,45 +1239,48 @@ export default class App extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let now = irc.formatDate(new Date());
|
let now = irc.formatDate(new Date());
|
||||||
client.fetchHistoryTargets(now, lastReceipt.time).then((targets) => {
|
let targets = await client.fetchHistoryTargets(now, lastReceipt.time);
|
||||||
targets.forEach((target) => {
|
targets.forEach(async (target) => {
|
||||||
let from = lastReceipt;
|
let from = lastReceipt;
|
||||||
let to = { time: now };
|
let to = { time: now };
|
||||||
|
|
||||||
// Maybe we've just received a READ update from the
|
// Maybe we've just received a READ update from the
|
||||||
// server, avoid over-fetching history
|
// server, avoid over-fetching history
|
||||||
let stored = this.bufferStore.get({ name: target.name, server: client.params });
|
let stored = this.bufferStore.get({ name: target.name, server: client.params });
|
||||||
let readReceipt = getReceipt(stored, ReceiptType.READ);
|
let readReceipt = getReceipt(stored, ReceiptType.READ);
|
||||||
if (isReceiptBefore(from, readReceipt)) {
|
if (isReceiptBefore(from, readReceipt)) {
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Query read marker if this is a user (ie, we haven't received
|
|
||||||
// the read marker as part of a JOIN burst)
|
|
||||||
if (client.supportsReadMarker() && client.isNick(target.name)) {
|
|
||||||
client.fetchReadMarker(target.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
client.fetchHistoryBetween(target.name, from, to, CHATHISTORY_MAX_SIZE).then((result) => {
|
|
||||||
for (let msg of result.messages) {
|
|
||||||
let destBuffers = this.routeMessage(serverID, msg);
|
|
||||||
for (let bufName of destBuffers) {
|
|
||||||
this.handleChatMessage(serverID, bufName, msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}).catch((err) => {
|
|
||||||
console.error("Failed to fetch backlog for '" + target.name + "': ", err);
|
|
||||||
this.showError("Failed to fetch backlog for '" + target.name + "'");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1362,14 +1385,21 @@ export default class App extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
whoChannelBuffer(target, serverID) {
|
async whoChannelBuffer(target, serverID) {
|
||||||
let client = this.clients.get(serverID);
|
let client = this.clients.get(serverID);
|
||||||
|
|
||||||
client.who(target, {
|
// Prevent multiple WHO commands for the same channel in parallel
|
||||||
fields: ["flags", "hostname", "nick", "realname", "username", "account"],
|
this.setBufferState({ name: target, server: serverID }, { hasInitialWho: true });
|
||||||
}).then(() => {
|
|
||||||
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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
open(target, serverID, password) {
|
open(target, serverID, password) {
|
||||||
@@ -1469,7 +1499,11 @@ export default class App extends Component {
|
|||||||
|
|
||||||
client.unmonitor(buf.name);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1641,7 +1675,7 @@ export default class App extends Component {
|
|||||||
this.openDialog("help");
|
this.openDialog("help");
|
||||||
}
|
}
|
||||||
|
|
||||||
handleBufferScrollTop() {
|
async handleBufferScrollTop() {
|
||||||
let buf = this.state.buffers.get(this.state.activeBuffer);
|
let buf = this.state.buffers.get(this.state.activeBuffer);
|
||||||
if (!buf || buf.type == BufferType.SERVER) {
|
if (!buf || buf.type == BufferType.SERVER) {
|
||||||
return;
|
return;
|
||||||
@@ -1671,35 +1705,34 @@ export default class App extends Component {
|
|||||||
limit = 200;
|
limit = 200;
|
||||||
}
|
}
|
||||||
|
|
||||||
client.fetchHistoryBefore(buf.name, before, limit).then((result) => {
|
let result = await client.fetchHistoryBefore(buf.name, before, limit);
|
||||||
this.endOfHistory.set(buf.id, !result.more);
|
this.endOfHistory.set(buf.id, !result.more);
|
||||||
|
|
||||||
if (result.messages.length > 0) {
|
if (result.messages.length > 0) {
|
||||||
let msg = result.messages[result.messages.length - 1];
|
let msg = result.messages[result.messages.length - 1];
|
||||||
let receipts = { [ReceiptType.DELIVERED]: receiptFromMessage(msg) };
|
let receipts = { [ReceiptType.DELIVERED]: receiptFromMessage(msg) };
|
||||||
if (this.state.activeBuffer === buf.id) {
|
if (this.state.activeBuffer === buf.id) {
|
||||||
receipts[ReceiptType.READ] = receiptFromMessage(msg);
|
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 };
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
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.addChatMessage(buf.server, buf.name, msg);
|
this.addChatMessage(buf.server, buf.name, msg);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
openDialog(name, data) {
|
openDialog(name, data) {
|
||||||
@@ -1817,12 +1850,13 @@ export default class App extends Component {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
handleNetworkSubmit(attrs, autojoin) {
|
async handleNetworkSubmit(attrs, autojoin) {
|
||||||
let client = this.clients.values().next().value;
|
let client = this.clients.values().next().value;
|
||||||
|
|
||||||
|
this.dismissDialog();
|
||||||
|
|
||||||
if (this.state.dialogData && this.state.dialogData.id) {
|
if (this.state.dialogData && this.state.dialogData.id) {
|
||||||
if (Object.keys(attrs).length == 0) {
|
if (Object.keys(attrs).length == 0) {
|
||||||
this.dismissDialog();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1832,22 +1866,19 @@ export default class App extends Component {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
attrs = { ...attrs, tls: "1" };
|
attrs = { ...attrs, tls: "1" };
|
||||||
client.createBouncerNetwork(attrs).then((id) => {
|
let id = await client.createBouncerNetwork(attrs);
|
||||||
if (!autojoin) {
|
if (!autojoin) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// By this point, bouncer-networks-notify should've advertised
|
// By this point, bouncer-networks-notify should've advertised
|
||||||
// the new network
|
// the new network
|
||||||
let serverID = this.serverFromBouncerNetwork(id);
|
let serverID = this.serverFromBouncerNetwork(id);
|
||||||
let client = this.clients.get(serverID);
|
let newClient = this.clients.get(serverID);
|
||||||
client.params.autojoin = [autojoin];
|
newClient.params.autojoin = [autojoin];
|
||||||
|
|
||||||
this.switchToChannel = autojoin;
|
this.switchToChannel = autojoin;
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.dismissDialog();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleNetworkRemove() {
|
handleNetworkRemove() {
|
||||||
@@ -1883,13 +1914,37 @@ export default class App extends Component {
|
|||||||
this.disconnectAll();
|
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() {
|
componentDidMount() {
|
||||||
this.baseTitle = document.title;
|
this.baseTitle = document.title;
|
||||||
setupKeybindings(this);
|
setupKeybindings(this);
|
||||||
|
window.addEventListener("focus", this.handleWindowFocus);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
document.title = this.baseTitle;
|
document.title = this.baseTitle;
|
||||||
|
window.removeEventListener("focus", this.handleWindowFocus);
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
@@ -2070,6 +2125,17 @@ export default class App extends Component {
|
|||||||
</>
|
</>
|
||||||
`;
|
`;
|
||||||
break;
|
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;
|
let error = null;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { html, Component } from "../lib/index.js";
|
|||||||
import linkify from "../lib/linkify.js";
|
import linkify from "../lib/linkify.js";
|
||||||
import * as irc from "../lib/irc.js";
|
import * as irc from "../lib/irc.js";
|
||||||
import { strip as stripANSI } from "../lib/ansi.js";
|
import { strip as stripANSI } from "../lib/ansi.js";
|
||||||
import { BufferType, ServerStatus, BufferEventsDisplayMode, getNickURL, getChannelURL, getMessageURL, isMessageBeforeReceipt, SettingsContext } from "../state.js";
|
import { BufferType, ServerStatus, BufferEventsDisplayMode, getMessageURL, isMessageBeforeReceipt, SettingsContext } from "../state.js";
|
||||||
import * as store from "../store.js";
|
import * as store from "../store.js";
|
||||||
import Membership from "./membership.js";
|
import Membership from "./membership.js";
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ function Nick(props) {
|
|||||||
|
|
||||||
let colorIndex = djb2(props.nick) % 16 + 1;
|
let colorIndex = djb2(props.nick) % 16 + 1;
|
||||||
return html`
|
return html`
|
||||||
<a href=${getNickURL(props.nick)} class="nick nick-${colorIndex}" onClick=${handleClick}>${props.nick}</a>
|
<a href=${irc.formatURL({ entity: props.nick })} class="nick nick-${colorIndex}" onClick=${handleClick}>${props.nick}</a>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ class LogLine extends Component {
|
|||||||
}
|
}
|
||||||
function createChannel(channel) {
|
function createChannel(channel) {
|
||||||
return html`
|
return html`
|
||||||
<a href=${getChannelURL(channel)} onClick=${onChannelClick}>
|
<a href=${irc.formatURL({ entity: channel })} onClick=${onChannelClick}>
|
||||||
${channel}
|
${channel}
|
||||||
</a>
|
</a>
|
||||||
`;
|
`;
|
||||||
@@ -267,6 +267,13 @@ class LogLine extends Component {
|
|||||||
let date = new Date(parseInt(msg.params[2], 10) * 1000);
|
let date = new Date(parseInt(msg.params[2], 10) * 1000);
|
||||||
content = html`Channel was created on ${date.toLocaleString()}`;
|
content = html`Channel was created on ${date.toLocaleString()}`;
|
||||||
break;
|
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:
|
default:
|
||||||
if (irc.isError(msg.command) && msg.command != irc.ERR_NOMOTD) {
|
if (irc.isError(msg.command) && msg.command != irc.ERR_NOMOTD) {
|
||||||
lineClass = "error";
|
lineClass = "error";
|
||||||
@@ -653,6 +660,7 @@ export default class Buffer extends Component {
|
|||||||
let hasUnreadSeparator = false;
|
let hasUnreadSeparator = false;
|
||||||
let prevDate = new Date();
|
let prevDate = new Date();
|
||||||
let foldMessages = [];
|
let foldMessages = [];
|
||||||
|
let lastMonitor = null;
|
||||||
buf.messages.forEach((msg) => {
|
buf.messages.forEach((msg) => {
|
||||||
let sep = [];
|
let sep = [];
|
||||||
|
|
||||||
@@ -660,6 +668,14 @@ export default class Buffer extends Component {
|
|||||||
return;
|
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)) {
|
if (!hasUnreadSeparator && buf.type != BufferType.SERVER && !isMessageBeforeReceipt(msg, buf.prevReadReceipt)) {
|
||||||
sep.push(html`<${UnreadSeparator} key="unread"/>`);
|
sep.push(html`<${UnreadSeparator} key="unread"/>`);
|
||||||
hasUnreadSeparator = true;
|
hasUnreadSeparator = true;
|
||||||
|
|||||||
@@ -118,8 +118,14 @@ export default class Composer extends Component {
|
|||||||
|
|
||||||
handleWindowKeyDown(event) {
|
handleWindowKeyDown(event) {
|
||||||
// If an <input> or <button> is focused, ignore.
|
// If an <input> or <button> is focused, ignore.
|
||||||
if (document.activeElement !== document.body && document.activeElement.tagName !== "SECTION") {
|
if (document.activeElement && document.activeElement !== document.body) {
|
||||||
return;
|
switch (document.activeElement.tagName.toLowerCase()) {
|
||||||
|
case "section":
|
||||||
|
case "a":
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If a modifier is pressed, reserve for key bindings.
|
// If a modifier is pressed, reserve for key bindings.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ function KeyBindingsHelp() {
|
|||||||
let l = keybindings.map((binding) => {
|
let l = keybindings.map((binding) => {
|
||||||
let keys = [];
|
let keys = [];
|
||||||
if (binding.ctrlKey) {
|
if (binding.ctrlKey) {
|
||||||
keys.psuh("Ctrl");
|
keys.push("Ctrl");
|
||||||
}
|
}
|
||||||
if (binding.altKey) {
|
if (binding.altKey) {
|
||||||
keys.push("Alt");
|
keys.push("Alt");
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { html, Component } from "../lib/index.js";
|
import { html, Component } from "../lib/index.js";
|
||||||
import { getNickURL } from "../state.js";
|
|
||||||
import { strip as stripANSI } from "../lib/ansi.js";
|
import { strip as stripANSI } from "../lib/ansi.js";
|
||||||
import Membership from "./membership.js";
|
import Membership from "./membership.js";
|
||||||
import * as irc from "../lib/irc.js";
|
import * as irc from "../lib/irc.js";
|
||||||
@@ -73,7 +72,7 @@ class MemberItem extends Component {
|
|||||||
return html`
|
return html`
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href=${getNickURL(this.props.nick)}
|
href=${irc.formatURL({ entity: this.props.nick, enttype: "user" })}
|
||||||
class=${classes.join(" ")}
|
class=${classes.join(" ")}
|
||||||
title=${title}
|
title=${title}
|
||||||
onClick=${this.handleClick}
|
onClick=${this.handleClick}
|
||||||
|
|||||||
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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
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) {
|
export function setup(app) {
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ const WHOX_FIELDS = {
|
|||||||
"realname": "r",
|
"realname": "r",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const FALLBACK_SERVER_PREFIX = { name: "*" };
|
||||||
|
|
||||||
let lastLabel = 0;
|
let lastLabel = 0;
|
||||||
let lastWhoxToken = 0;
|
let lastWhoxToken = 0;
|
||||||
|
|
||||||
@@ -108,7 +110,7 @@ export default class Client extends EventTarget {
|
|||||||
};
|
};
|
||||||
|
|
||||||
status = Client.Status.DISCONNECTED;
|
status = Client.Status.DISCONNECTED;
|
||||||
serverPrefix = { name: "*" };
|
serverPrefix = FALLBACK_SERVER_PREFIX;
|
||||||
nick = null;
|
nick = null;
|
||||||
supportsCap = false;
|
supportsCap = false;
|
||||||
caps = new irc.CapRegistry();
|
caps = new irc.CapRegistry();
|
||||||
@@ -190,7 +192,7 @@ export default class Client extends EventTarget {
|
|||||||
this.ws = null;
|
this.ws = null;
|
||||||
this.setStatus(Client.Status.DISCONNECTED);
|
this.setStatus(Client.Status.DISCONNECTED);
|
||||||
this.nick = null;
|
this.nick = null;
|
||||||
this.serverPrefix = null;
|
this.serverPrefix = FALLBACK_SERVER_PREFIX;
|
||||||
this.caps = new irc.CapRegistry();
|
this.caps = new irc.CapRegistry();
|
||||||
this.batches = new Map();
|
this.batches = new Map();
|
||||||
Object.keys(this.pendingCmds).forEach((k) => {
|
Object.keys(this.pendingCmds).forEach((k) => {
|
||||||
@@ -470,7 +472,7 @@ export default class Client extends EventTarget {
|
|||||||
initialResp = { command: "AUTHENTICATE", params: [respStr] };
|
initialResp = { command: "AUTHENTICATE", params: [respStr] };
|
||||||
break;
|
break;
|
||||||
case "EXTERNAL":
|
case "EXTERNAL":
|
||||||
initialResp = { command: "AUTHENTICATE", params: [base64.encode("")] };
|
initialResp = { command: "AUTHENTICATE", params: ["+"] };
|
||||||
break;
|
break;
|
||||||
case "OAUTHBEARER":
|
case "OAUTHBEARER":
|
||||||
let raw = "n,,\x01auth=Bearer " + params.token + "\x01\x01";
|
let raw = "n,,\x01auth=Bearer " + params.token + "\x01\x01";
|
||||||
@@ -902,62 +904,56 @@ export default class Client extends EventTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Fetch one page of history before the given date. */
|
/* 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 max = Math.min(limit, this.isupport.chatHistory());
|
||||||
let params = ["BEFORE", target, "timestamp=" + before, max];
|
let params = ["BEFORE", target, "timestamp=" + before, max];
|
||||||
return this.roundtripChatHistory(params).then((messages) => {
|
let messages = await this.roundtripChatHistory(params);
|
||||||
return { messages, more: messages.length >= max };
|
return { messages, more: messages.length >= max };
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fetch history in ascending order. */
|
/* 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 max = Math.min(limit, this.isupport.chatHistory());
|
||||||
let params = ["AFTER", target, "timestamp=" + after.time, max];
|
let params = ["AFTER", target, "timestamp=" + after.time, max];
|
||||||
return this.roundtripChatHistory(params).then((messages) => {
|
let messages = await this.roundtripChatHistory(params);
|
||||||
limit -= messages.length;
|
limit -= messages.length;
|
||||||
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 await this.fetchHistoryBetween(target, after, before, limit);
|
||||||
}
|
}
|
||||||
return { messages };
|
return { messages };
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchHistoryTargets(t1, t2) {
|
async fetchHistoryTargets(t1, t2) {
|
||||||
let msg = {
|
let msg = {
|
||||||
command: "CHATHISTORY",
|
command: "CHATHISTORY",
|
||||||
params: ["TARGETS", "timestamp=" + t1, "timestamp=" + t2, 1000],
|
params: ["TARGETS", "timestamp=" + t1, "timestamp=" + t2, 1000],
|
||||||
};
|
};
|
||||||
return this.fetchBatch(msg, "draft/chathistory-targets").then((batch) => {
|
let batch = await this.fetchBatch(msg, "draft/chathistory-targets");
|
||||||
return batch.messages.map((msg) => {
|
return batch.messages.map((msg) => {
|
||||||
if (msg.command != "CHATHISTORY" || msg.params[0] != "TARGETS") {
|
console.assert(msg.command === "CHATHISTORY" && msg.params[0] === "TARGETS");
|
||||||
throw new Error("Cannot fetch chat history targets: unexpected message " + msg);
|
return {
|
||||||
}
|
name: msg.params[1],
|
||||||
return {
|
latestMessage: msg.params[2],
|
||||||
name: msg.params[1],
|
};
|
||||||
latestMessage: msg.params[2],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
listBouncerNetworks() {
|
async listBouncerNetworks() {
|
||||||
let req = { command: "BOUNCER", params: ["LISTNETWORKS"] };
|
let req = { command: "BOUNCER", params: ["LISTNETWORKS"] };
|
||||||
return this.fetchBatch(req, "soju.im/bouncer-networks").then((batch) => {
|
let batch = await this.fetchBatch(req, "soju.im/bouncer-networks");
|
||||||
let networks = new Map();
|
let networks = new Map();
|
||||||
for (let msg of batch.messages) {
|
for (let msg of batch.messages) {
|
||||||
console.assert(msg.command === "BOUNCER" && msg.params[0] === "NETWORK");
|
console.assert(msg.command === "BOUNCER" && msg.params[0] === "NETWORK");
|
||||||
let id = msg.params[1];
|
let id = msg.params[1];
|
||||||
let params = irc.parseTags(msg.params[2]);
|
let params = irc.parseTags(msg.params[2]);
|
||||||
networks.set(id, params);
|
networks.set(id, params);
|
||||||
}
|
}
|
||||||
return networks;
|
return networks;
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
monitor(target) {
|
monitor(target) {
|
||||||
|
|||||||
11
lib/irc.js
11
lib/irc.js
@@ -839,6 +839,17 @@ export function parseURL(str) {
|
|||||||
return { host, enttype, entity };
|
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 {
|
export class CapRegistry {
|
||||||
available = new Map();
|
available = new Map();
|
||||||
enabled = new Set();
|
enabled = new Set();
|
||||||
|
|||||||
4152
package-lock.json
generated
4152
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@
|
|||||||
"targets": {
|
"targets": {
|
||||||
"default": {
|
"default": {
|
||||||
"source": "index.html",
|
"source": "index.html",
|
||||||
|
"context": "browser",
|
||||||
"publicUrl": "."
|
"publicUrl": "."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
64
state.js
64
state.js
@@ -43,22 +43,14 @@ export const BufferEventsDisplayMode = {
|
|||||||
|
|
||||||
export const SettingsContext = createContext("settings");
|
export const SettingsContext = createContext("settings");
|
||||||
|
|
||||||
export function getNickURL(nick) {
|
|
||||||
return "irc:///" + encodeURIComponent(nick) + ",isuser";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getChannelURL(channel) {
|
|
||||||
return "irc:///" + encodeURIComponent(channel);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getBufferURL(buf) {
|
export function getBufferURL(buf) {
|
||||||
switch (buf.type) {
|
switch (buf.type) {
|
||||||
case BufferType.SERVER:
|
case BufferType.SERVER:
|
||||||
return "irc:///";
|
return irc.formatURL();
|
||||||
case BufferType.CHANNEL:
|
case BufferType.CHANNEL:
|
||||||
return getChannelURL(buf.name);
|
return irc.formatURL({ entity: buf.name });
|
||||||
case BufferType.NICK:
|
case BufferType.NICK:
|
||||||
return getNickURL(buf.name);
|
return irc.formatURL({ entity: buf.name, enttype: "user" });
|
||||||
}
|
}
|
||||||
throw new Error("Unknown buffer type: " + buf.type);
|
throw new Error("Unknown buffer type: " + buf.type);
|
||||||
}
|
}
|
||||||
@@ -461,23 +453,6 @@ export const State = {
|
|||||||
return { members };
|
return { members };
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case irc.RPL_WHOREPLY:
|
|
||||||
case irc.RPL_WHOSPCRPL:
|
|
||||||
who = client.parseWhoReply(msg);
|
|
||||||
|
|
||||||
if (who.flags !== undefined) {
|
|
||||||
who.away = who.flags.indexOf("G") >= 0; // H for here, G for gone
|
|
||||||
who.operator = who.flags.indexOf("*") >= 0;
|
|
||||||
let botFlag = client.isupport.bot();
|
|
||||||
if (botFlag) {
|
|
||||||
who.bot = who.flags.indexOf(botFlag) >= 0;
|
|
||||||
}
|
|
||||||
delete who.flags;
|
|
||||||
}
|
|
||||||
|
|
||||||
who.offline = false;
|
|
||||||
|
|
||||||
return updateUser(who.nick, who);
|
|
||||||
case irc.RPL_ENDOFWHO:
|
case irc.RPL_ENDOFWHO:
|
||||||
target = msg.params[1];
|
target = msg.params[1];
|
||||||
if (msg.list.length == 0 && !client.isChannel(target) && target.indexOf("*") < 0) {
|
if (msg.list.length == 0 && !client.isChannel(target) && target.indexOf("*") < 0) {
|
||||||
@@ -485,6 +460,28 @@ export const State = {
|
|||||||
return updateUser(target, (user) => {
|
return updateUser(target, (user) => {
|
||||||
return { offline: true };
|
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;
|
break;
|
||||||
case "JOIN":
|
case "JOIN":
|
||||||
@@ -648,21 +645,12 @@ export const State = {
|
|||||||
return { members };
|
return { members };
|
||||||
});
|
});
|
||||||
case irc.RPL_MONONLINE:
|
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:
|
case irc.RPL_MONOFFLINE:
|
||||||
targets = msg.params[1].split(",");
|
targets = msg.params[1].split(",");
|
||||||
|
|
||||||
for (let target of targets) {
|
for (let target of targets) {
|
||||||
let prefix = irc.parsePrefix(target);
|
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 };
|
state = { ...state, ...update };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
25
store.js
25
store.js
@@ -1,4 +1,4 @@
|
|||||||
import { ReceiptType } from "./state.js";
|
import { ReceiptType, Unread } from "./state.js";
|
||||||
|
|
||||||
const PREFIX = "gamja_";
|
const PREFIX = "gamja_";
|
||||||
|
|
||||||
@@ -62,8 +62,6 @@ export class Buffer {
|
|||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
name: buf.name.toLowerCase(),
|
name: buf.name.toLowerCase(),
|
||||||
server: {
|
server: {
|
||||||
url: buf.server.url,
|
|
||||||
nick: buf.server.nick.toLowerCase(),
|
|
||||||
bouncerNetwork: buf.server.bouncerNetwork,
|
bouncerNetwork: buf.server.bouncerNetwork,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -87,7 +85,7 @@ export class Buffer {
|
|||||||
let updated = !this.m.has(key);
|
let updated = !this.m.has(key);
|
||||||
let prev = this.m.get(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) {
|
if (buf.unread !== undefined && buf.unread !== prev.unread) {
|
||||||
unread = buf.unread;
|
unread = buf.unread;
|
||||||
updated = true;
|
updated = true;
|
||||||
@@ -109,6 +107,12 @@ export class Buffer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let closed = prev.closed || false;
|
||||||
|
if (buf.closed !== undefined && buf.closed !== prev.closed) {
|
||||||
|
closed = buf.closed;
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -117,9 +121,8 @@ export class Buffer {
|
|||||||
name: buf.name,
|
name: buf.name,
|
||||||
unread,
|
unread,
|
||||||
receipts,
|
receipts,
|
||||||
|
closed,
|
||||||
server: {
|
server: {
|
||||||
url: buf.server.url,
|
|
||||||
nick: buf.server.nick,
|
|
||||||
bouncerNetwork: buf.server.bouncerNetwork,
|
bouncerNetwork: buf.server.bouncerNetwork,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -134,19 +137,25 @@ export class Buffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
list(server) {
|
list(server) {
|
||||||
|
// Some gamja versions would store the same buffer multiple times
|
||||||
|
let names = new Set();
|
||||||
let buffers = [];
|
let buffers = [];
|
||||||
for (const buf of this.m.values()) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
buffers.push(buf);
|
buffers.push(buf);
|
||||||
|
names.add(buf.name);
|
||||||
}
|
}
|
||||||
return buffers;
|
return buffers;
|
||||||
}
|
}
|
||||||
|
|
||||||
clear(server) {
|
clear(server) {
|
||||||
if (server) {
|
if (server) {
|
||||||
for (const buf of this.m.values()) {
|
for (const buf of this.list(server)) {
|
||||||
this.m.delete(this.key(buf));
|
this.m.delete(this.key(buf));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
36
style.css
36
style.css
@@ -352,7 +352,8 @@ form input[type="text"],
|
|||||||
form input[type="username"],
|
form input[type="username"],
|
||||||
form input[type="password"],
|
form input[type="password"],
|
||||||
form input[type="url"],
|
form input[type="url"],
|
||||||
form input[type="email"] {
|
form input[type="email"],
|
||||||
|
form input[type="search"] {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
@@ -561,6 +562,29 @@ kbd {
|
|||||||
border-radius: 3px;
|
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) {
|
@media (prefers-color-scheme: dark) {
|
||||||
html {
|
html {
|
||||||
scrollbar-color: var(--gray) transparent;
|
scrollbar-color: var(--gray) transparent;
|
||||||
@@ -588,7 +612,8 @@ kbd {
|
|||||||
form input[type="username"],
|
form input[type="username"],
|
||||||
form input[type="password"],
|
form input[type="password"],
|
||||||
form input[type="url"],
|
form input[type="url"],
|
||||||
form input[type="email"] {
|
form input[type="email"],
|
||||||
|
form input[type="search"] {
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
background: var(--sidebar-background);
|
background: var(--sidebar-background);
|
||||||
border: 1px solid #495057;
|
border: 1px solid #495057;
|
||||||
@@ -598,7 +623,8 @@ kbd {
|
|||||||
form input[type="username"]:focus,
|
form input[type="username"]:focus,
|
||||||
form input[type="password"]:focus,
|
form input[type="password"]:focus,
|
||||||
form input[type="url"]:focus,
|
form input[type="url"]:focus,
|
||||||
form input[type="email"]:focus {
|
form input[type="email"]:focus,
|
||||||
|
form input[type="search"]:focus {
|
||||||
outline: 0;
|
outline: 0;
|
||||||
border-color: #3897ff;
|
border-color: #3897ff;
|
||||||
}
|
}
|
||||||
@@ -677,6 +703,10 @@ kbd {
|
|||||||
border: 1px solid var(--outline-color);
|
border: 1px solid var(--outline-color);
|
||||||
box-shadow: inset 0 -1px 0 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) {
|
@media (max-width: 640px) {
|
||||||
|
|||||||
Reference in New Issue
Block a user