mirror of
https://codeberg.org/emersion/gamja
synced 2025-04-22 01:23:56 +02:00
Compare commits
No commits in common. "master" and "v1.0.0-beta.1" have entirely different histories.
master
...
v1.0.0-bet
27
.build.yml
27
.build.yml
@ -1,27 +0,0 @@
|
|||||||
image: alpine/latest
|
|
||||||
packages:
|
|
||||||
- npm
|
|
||||||
- rsync
|
|
||||||
sources:
|
|
||||||
- https://codeberg.org/emersion/gamja.git
|
|
||||||
secrets:
|
|
||||||
- 7a146c8e-aeb4-46e7-99bf-05af7486bbe9 # deploy SSH key
|
|
||||||
artifacts:
|
|
||||||
- gamja/gamja.tar.gz
|
|
||||||
tasks:
|
|
||||||
- setup: |
|
|
||||||
cd gamja
|
|
||||||
npm install --include=dev
|
|
||||||
- build: |
|
|
||||||
cd gamja
|
|
||||||
npm run build
|
|
||||||
tar -czf gamja.tar.gz -C dist .
|
|
||||||
- lint: |
|
|
||||||
cd gamja
|
|
||||||
npm run -- lint --max-warnings 0
|
|
||||||
- deploy: |
|
|
||||||
cd gamja/dist
|
|
||||||
[ "$(git rev-parse HEAD)" = "$(git rev-parse origin/master)" ] || complete-build
|
|
||||||
rsync --rsh="ssh -o StrictHostKeyChecking=no" -rP \
|
|
||||||
--delete --exclude=config.json \
|
|
||||||
. deploy-gamja@sheeta.emersion.fr:/srv/http/gamja
|
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -1,5 +1,2 @@
|
|||||||
node_modules
|
node_modules
|
||||||
/config.json
|
/config.json
|
||||||
|
|
||||||
.parcel-cache
|
|
||||||
/dist
|
|
||||||
|
106
README.md
106
README.md
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
A simple IRC web client.
|
A simple IRC web client.
|
||||||
|
|
||||||
<img src="https://fs.emersion.fr/protected/img/gamja/main.png" alt="Screenshot" width="800">
|

|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@ -10,35 +10,98 @@ Requires an IRC WebSocket server.
|
|||||||
|
|
||||||
First install dependencies:
|
First install dependencies:
|
||||||
|
|
||||||
npm install --omit=dev
|
npm install --production
|
||||||
|
|
||||||
Then [configure an HTTP server] to serve the gamja files.
|
### [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 /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, you can set the
|
||||||
|
`server.ping` option in `config.json` to an interval, in seconds, between which
|
||||||
|
gamja will send opportunistic pings.
|
||||||
|
|
||||||
### Development server
|
### Development server
|
||||||
|
|
||||||
If you don't have an IRC WebSocket server at hand, gamja's development server
|
Start your IRC WebSocket server, e.g. on port 8080. Then run:
|
||||||
can be used. For instance, to run gamja on Libera Chat:
|
|
||||||
|
|
||||||
npm install --include=dev
|
npm install
|
||||||
npm start -- irc.libera.chat
|
npm start
|
||||||
|
|
||||||
See `npm start -- -h` for a list of options.
|
This will start a development HTTP server for gamja. Connect to it and append
|
||||||
|
`?server=ws://localhost:8080` to the URL.
|
||||||
|
|
||||||
### Production build
|
## Query parameters
|
||||||
|
|
||||||
Optionally, [Parcel] can be used to build a minified version of gamja.
|
gamja settings can be overridden using URL query parameters:
|
||||||
|
|
||||||
npm install --include=dev
|
- `server`: path or URL to the WebSocket server
|
||||||
npm run build
|
- `nick`: nickname
|
||||||
|
- `channels`: comma-separated list of channels to join (`#` needs to be escaped)
|
||||||
|
|
||||||
## Configuration
|
Alternatively, the channels can be set with the URL fragment (ie, by just
|
||||||
|
appending the channel name to the gamja URL).
|
||||||
|
|
||||||
gamja can be configured via a [configuration file] and via [URL parameters].
|
## 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, and "disabled" to never ask for a password. Defaults to
|
||||||
|
// "optional".
|
||||||
|
"auth": "optional",
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Send patches on [Codeberg], report bugs on the [issue tracker]. Discuss
|
Send patches on the [mailing list], report bugs on the [issue tracker]. Discuss
|
||||||
in [#soju on Libera Chat].
|
in #soju on Libera Chat.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
@ -46,11 +109,8 @@ AGPLv3, see LICENSE.
|
|||||||
|
|
||||||
Copyright (C) 2020 The gamja Contributors
|
Copyright (C) 2020 The gamja Contributors
|
||||||
|
|
||||||
[gamja]: https://codeberg.org/emersion/gamja
|
[gamja]: https://sr.ht/~emersion/gamja/
|
||||||
[Codeberg]: https://codeberg.org/emersion/gamja
|
[soju]: https://soju.im
|
||||||
|
[webircgateway]: https://github.com/kiwiirc/webircgateway
|
||||||
|
[mailing list]: https://lists.sr.ht/~emersion/public-inbox
|
||||||
[issue tracker]: https://todo.sr.ht/~emersion/gamja
|
[issue tracker]: https://todo.sr.ht/~emersion/gamja
|
||||||
[Parcel]: https://parceljs.org
|
|
||||||
[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
|
|
||||||
|
201
commands.js
201
commands.js
@ -25,20 +25,21 @@ function getActiveChannel(app) {
|
|||||||
return activeBuffer.name;
|
return activeBuffer.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setUserHostMode(app, args, mode) {
|
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);
|
||||||
let whois = await client.whois(nick);
|
client.whois(nick).then((whois) => {
|
||||||
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}`],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,25 +54,19 @@ function markServerBufferUnread(app) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const join = {
|
const join = {
|
||||||
name: "join",
|
usage: "<name>",
|
||||||
usage: "<name> [password]",
|
|
||||||
description: "Join a channel",
|
description: "Join a channel",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
let channel = args[0];
|
let channel = args[0];
|
||||||
if (!channel) {
|
if (!channel) {
|
||||||
throw new Error("Missing channel name");
|
throw new Error("Missing channel name");
|
||||||
}
|
}
|
||||||
if (args.length > 1) {
|
app.open(channel);
|
||||||
app.open(channel, null, args[1]);
|
|
||||||
} else {
|
|
||||||
app.open(channel);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const kick = {
|
const kick = {
|
||||||
name: "kick",
|
usage: "<nick>",
|
||||||
usage: "<nick> [comment]",
|
|
||||||
description: "Remove a user from the channel",
|
description: "Remove a user from the channel",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
let nick = args[0];
|
let nick = args[0];
|
||||||
@ -84,23 +79,6 @@ const kick = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const ban = {
|
|
||||||
name: "ban",
|
|
||||||
usage: "[nick]",
|
|
||||||
description: "Ban a user from the channel, or display the current ban list",
|
|
||||||
execute: (app, args) => {
|
|
||||||
if (args.length === 0) {
|
|
||||||
let activeChannel = getActiveChannel(app);
|
|
||||||
getActiveClient(app).send({
|
|
||||||
command: "MODE",
|
|
||||||
params: [activeChannel, "+b"],
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
return setUserHostMode(app, args, "+b");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function givemode(app, args, mode) {
|
function givemode(app, args, mode) {
|
||||||
// TODO: Handle several users at once
|
// TODO: Handle several users at once
|
||||||
let nick = args[0];
|
let nick = args[0];
|
||||||
@ -114,22 +92,23 @@ function givemode(app, args, mode) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const commands = [
|
export default {
|
||||||
{
|
"ban": {
|
||||||
name: "away",
|
usage: "[nick]",
|
||||||
usage: "[message]",
|
description: "Ban a user from the channel, or display the current ban list",
|
||||||
description: "Set away message",
|
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
const params = [];
|
if (args.length == 0) {
|
||||||
if (args.length) {
|
let activeChannel = getActiveChannel(app);
|
||||||
params.push(args.join(" "));
|
getActiveClient(app).send({
|
||||||
|
command: "MODE",
|
||||||
|
params: [activeChannel, "+b"],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return setUserHostMode(app, args, "+b");
|
||||||
}
|
}
|
||||||
getActiveClient(app).send({ command: "AWAY", params });
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ban,
|
"buffer": {
|
||||||
{
|
|
||||||
name: "buffer",
|
|
||||||
usage: "<name>",
|
usage: "<name>",
|
||||||
description: "Switch to a buffer",
|
description: "Switch to a buffer",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
@ -143,45 +122,39 @@ const commands = [
|
|||||||
throw new Error("Unknown buffer");
|
throw new Error("Unknown buffer");
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
"close": {
|
||||||
name: "close",
|
|
||||||
description: "Close the current buffer",
|
description: "Close the current buffer",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
let activeBuffer = app.state.buffers.get(app.state.activeBuffer);
|
let activeBuffer = app.state.buffers.get(app.state.activeBuffer);
|
||||||
if (!activeBuffer || activeBuffer.type === BufferType.SERVER) {
|
if (!activeBuffer || activeBuffer.type == BufferType.SERVER) {
|
||||||
throw new Error("Not in a user or channel buffer");
|
throw new Error("Not in a user or channel buffer");
|
||||||
}
|
}
|
||||||
app.close(activeBuffer.id);
|
app.close(activeBuffer.id);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
"deop": {
|
||||||
name: "deop",
|
|
||||||
usage: "<nick>",
|
usage: "<nick>",
|
||||||
description: "Remove operator status for a user on this channel",
|
description: "Remove operator status for a user on this channel",
|
||||||
execute: (app, args) => givemode(app, args, "-o"),
|
execute: (app, args) => givemode(app, args, "-o"),
|
||||||
},
|
},
|
||||||
{
|
"devoice": {
|
||||||
name: "devoice",
|
|
||||||
usage: "<nick>",
|
usage: "<nick>",
|
||||||
description: "Remove voiced status for a user on this channel",
|
description: "Remove voiced status for a user on this channel",
|
||||||
execute: (app, args) => givemode(app, args, "-v"),
|
execute: (app, args) => givemode(app, args, "-v"),
|
||||||
},
|
},
|
||||||
{
|
"disconnect": {
|
||||||
name: "disconnect",
|
|
||||||
description: "Disconnect from the server",
|
description: "Disconnect from the server",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
app.disconnect();
|
app.disconnect();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
"help": {
|
||||||
name: "help",
|
|
||||||
description: "Show help menu",
|
description: "Show help menu",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
app.openHelp();
|
app.openHelp();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
"invite": {
|
||||||
name: "invite",
|
|
||||||
usage: "<nick>",
|
usage: "<nick>",
|
||||||
description: "Invite a user to the channel",
|
description: "Invite a user to the channel",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
@ -190,17 +163,15 @@ const commands = [
|
|||||||
throw new Error("Missing nick");
|
throw new Error("Missing nick");
|
||||||
}
|
}
|
||||||
let activeChannel = getActiveChannel(app);
|
let activeChannel = getActiveChannel(app);
|
||||||
getActiveClient(app).send({
|
getActiveClient(app).send({ command: "INVITE", params: [
|
||||||
command: "INVITE",
|
nick, activeChannel,
|
||||||
params: [nick, activeChannel],
|
]});
|
||||||
});
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ ...join, name: "j" },
|
"j": join,
|
||||||
join,
|
"join": join,
|
||||||
kick,
|
"kick": kick,
|
||||||
{
|
"kickban": {
|
||||||
name: "kickban",
|
|
||||||
usage: "<target>",
|
usage: "<target>",
|
||||||
description: "Ban a user and removes them from the channel",
|
description: "Ban a user and removes them from the channel",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
@ -208,8 +179,7 @@ const commands = [
|
|||||||
ban.execute(app, args);
|
ban.execute(app, args);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
"lusers": {
|
||||||
name: "lusers",
|
|
||||||
usage: "[<mask> [<target>]]",
|
usage: "[<mask> [<target>]]",
|
||||||
description: "Request user statistics about the network",
|
description: "Request user statistics about the network",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
@ -217,8 +187,7 @@ const commands = [
|
|||||||
markServerBufferUnread(app);
|
markServerBufferUnread(app);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
"me": {
|
||||||
name: "me",
|
|
||||||
usage: "<action>",
|
usage: "<action>",
|
||||||
description: "Send an action message to the current buffer",
|
description: "Send an action message to the current buffer",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
@ -228,8 +197,7 @@ const commands = [
|
|||||||
app.privmsg(target, text);
|
app.privmsg(target, text);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
"mode": {
|
||||||
name: "mode",
|
|
||||||
usage: "[target] [modes] [mode args...]",
|
usage: "[target] [modes] [mode args...]",
|
||||||
description: "Query or change a channel or user mode",
|
description: "Query or change a channel or user mode",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
@ -241,8 +209,7 @@ const commands = [
|
|||||||
getActiveClient(app).send({ command: "MODE", params: args });
|
getActiveClient(app).send({ command: "MODE", params: args });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
"motd": {
|
||||||
name: "motd",
|
|
||||||
usage: "[server]",
|
usage: "[server]",
|
||||||
description: "Get the Message Of The Day",
|
description: "Get the Message Of The Day",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
@ -250,8 +217,7 @@ const commands = [
|
|||||||
markServerBufferUnread(app);
|
markServerBufferUnread(app);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
"msg": {
|
||||||
name: "msg",
|
|
||||||
usage: "<target> <message>",
|
usage: "<target> <message>",
|
||||||
description: "Send a message to a nickname or a channel",
|
description: "Send a message to a nickname or a channel",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
@ -260,8 +226,7 @@ const commands = [
|
|||||||
getActiveClient(app).send({ command: "PRIVMSG", params: [target, text] });
|
getActiveClient(app).send({ command: "PRIVMSG", params: [target, text] });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
"nick": {
|
||||||
name: "nick",
|
|
||||||
usage: "<nick>",
|
usage: "<nick>",
|
||||||
description: "Change current nickname",
|
description: "Change current nickname",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
@ -269,8 +234,7 @@ const commands = [
|
|||||||
getActiveClient(app).send({ command: "NICK", params: [newNick] });
|
getActiveClient(app).send({ command: "NICK", params: [newNick] });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
"notice": {
|
||||||
name: "notice",
|
|
||||||
usage: "<target> <message>",
|
usage: "<target> <message>",
|
||||||
description: "Send a notice to a nickname or a channel",
|
description: "Send a notice to a nickname or a channel",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
@ -279,14 +243,12 @@ const commands = [
|
|||||||
getActiveClient(app).send({ command: "NOTICE", params: [target, text] });
|
getActiveClient(app).send({ command: "NOTICE", params: [target, text] });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
"op": {
|
||||||
name: "op",
|
|
||||||
usage: "<nick>",
|
usage: "<nick>",
|
||||||
description: "Give a user operator status on this channel",
|
description: "Give a user operator status on this channel",
|
||||||
execute: (app, args) => givemode(app, args, "+o"),
|
execute: (app, args) => givemode(app, args, "+o"),
|
||||||
},
|
},
|
||||||
{
|
"part": {
|
||||||
name: "part",
|
|
||||||
usage: "[reason]",
|
usage: "[reason]",
|
||||||
description: "Leave a channel",
|
description: "Leave a channel",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
@ -299,8 +261,7 @@ const commands = [
|
|||||||
getActiveClient(app).send({ command: "PART", params });
|
getActiveClient(app).send({ command: "PART", params });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
"query": {
|
||||||
name: "query",
|
|
||||||
usage: "<nick> [message]",
|
usage: "<nick> [message]",
|
||||||
description: "Open a buffer to send messages to a nickname",
|
description: "Open a buffer to send messages to a nickname",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
@ -316,12 +277,11 @@ const commands = [
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
"quiet": {
|
||||||
name: "quiet",
|
|
||||||
usage: "[nick]",
|
usage: "[nick]",
|
||||||
description: "Quiet a user in the channel, or display the current quiet list",
|
description: "Quiet a user in the channel, or display the current quiet list",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
if (args.length === 0) {
|
if (args.length == 0) {
|
||||||
getActiveClient(app).send({
|
getActiveClient(app).send({
|
||||||
command: "MODE",
|
command: "MODE",
|
||||||
params: [getActiveChannel(app), "+q"],
|
params: [getActiveChannel(app), "+q"],
|
||||||
@ -331,15 +291,13 @@ const commands = [
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
"quit": {
|
||||||
name: "quit",
|
|
||||||
description: "Quit",
|
description: "Quit",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
app.close({ name: SERVER_BUFFER });
|
app.close({ name: SERVER_BUFFER });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
"quote": {
|
||||||
name: "quote",
|
|
||||||
usage: "<command>",
|
usage: "<command>",
|
||||||
description: "Send a raw IRC command to the server",
|
description: "Send a raw IRC command to the server",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
@ -352,29 +310,25 @@ const commands = [
|
|||||||
getActiveClient(app).send(msg);
|
getActiveClient(app).send(msg);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
"reconnect": {
|
||||||
name: "reconnect",
|
|
||||||
description: "Reconnect to the server",
|
description: "Reconnect to the server",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
app.reconnect();
|
app.reconnect();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
"setname": {
|
||||||
name: "setname",
|
|
||||||
usage: "<realname>",
|
usage: "<realname>",
|
||||||
description: "Change current realname",
|
description: "Change current realname",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
let newRealname = args.join(" ");
|
let newRealname = args.join(" ");
|
||||||
let client = getActiveClient(app);
|
let client = getActiveClient(app);
|
||||||
if (!client.caps.enabled.has("setname")) {
|
if (!client.enabledCaps["setname"]) {
|
||||||
throw new Error("Server doesn't support changing the realname");
|
throw new Error("Server doesn't support changing the realname");
|
||||||
}
|
}
|
||||||
client.send({ command: "SETNAME", params: [newRealname] });
|
client.send({ command: "SETNAME", params: [newRealname] });
|
||||||
// TODO: save to local storage
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
"stats": {
|
||||||
name: "stats",
|
|
||||||
usage: "<query> [server]",
|
usage: "<query> [server]",
|
||||||
description: "Request server statistics",
|
description: "Request server statistics",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
@ -390,8 +344,7 @@ const commands = [
|
|||||||
markServerBufferUnread(app);
|
markServerBufferUnread(app);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
"topic": {
|
||||||
name: "topic",
|
|
||||||
usage: "<topic>",
|
usage: "<topic>",
|
||||||
description: "Change the topic of the current channel",
|
description: "Change the topic of the current channel",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
@ -403,39 +356,39 @@ const commands = [
|
|||||||
getActiveClient(app).send({ command: "TOPIC", params });
|
getActiveClient(app).send({ command: "TOPIC", params });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
"unban": {
|
||||||
name: "unban",
|
|
||||||
usage: "<nick>",
|
usage: "<nick>",
|
||||||
description: "Remove a user from the ban list",
|
description: "Remove a user from the ban list",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
return setUserHostMode(app, args, "-b");
|
return setUserHostMode(app, args, "-b");
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
"unquiet": {
|
||||||
name: "unquiet",
|
|
||||||
usage: "<nick>",
|
usage: "<nick>",
|
||||||
description: "Remove a user from the quiet list",
|
description: "Remove a user from the quiet list",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
return setUserHostMode(app, args, "-q");
|
return setUserHostMode(app, args, "-q");
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
"unvoice": {
|
||||||
name: "voice",
|
usage: "<nick>",
|
||||||
|
description: "Remove a user from the voiced list",
|
||||||
|
execute: (app, args) => givemode(app, args, "-v"),
|
||||||
|
},
|
||||||
|
"voice": {
|
||||||
usage: "<nick>",
|
usage: "<nick>",
|
||||||
description: "Give a user voiced status on this channel",
|
description: "Give a user voiced status on this channel",
|
||||||
execute: (app, args) => givemode(app, args, "+v"),
|
execute: (app, args) => givemode(app, args, "+v"),
|
||||||
},
|
},
|
||||||
{
|
"who": {
|
||||||
name: "who",
|
usage: "[<mask> [o]]",
|
||||||
usage: "<mask>",
|
|
||||||
description: "Retrieve a list of users",
|
description: "Retrieve a list of users",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
getActiveClient(app).send({ command: "WHO", params: args });
|
getActiveClient(app).send({ command: "WHO", params: args });
|
||||||
markServerBufferUnread(app);
|
markServerBufferUnread(app);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
"whois": {
|
||||||
name: "whois",
|
|
||||||
usage: "<nick>",
|
usage: "<nick>",
|
||||||
description: "Retrieve information about a user",
|
description: "Retrieve information about a user",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
@ -447,8 +400,7 @@ const commands = [
|
|||||||
markServerBufferUnread(app);
|
markServerBufferUnread(app);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
"whowas": {
|
||||||
name: "whowas",
|
|
||||||
usage: "<nick> [count]",
|
usage: "<nick> [count]",
|
||||||
description: "Retrieve information about an offline user",
|
description: "Retrieve information about an offline user",
|
||||||
execute: (app, args) => {
|
execute: (app, args) => {
|
||||||
@ -459,15 +411,4 @@ const commands = [
|
|||||||
markServerBufferUnread(app);
|
markServerBufferUnread(app);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
};
|
||||||
name: "list",
|
|
||||||
usage: "[filter]",
|
|
||||||
description: "Retrieve a list of channels from a network",
|
|
||||||
execute: (app, args) => {
|
|
||||||
getActiveClient(app).send({ command: "LIST", params: args });
|
|
||||||
markServerBufferUnread(app);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default new Map(commands.map((cmd) => [cmd.name, cmd]));
|
|
||||||
|
1725
components/app.js
1725
components/app.js
File diff suppressed because it is too large
Load Diff
@ -1,51 +0,0 @@
|
|||||||
import { html, Component } from "../lib/index.js";
|
|
||||||
|
|
||||||
export default class NetworkForm extends Component {
|
|
||||||
state = {
|
|
||||||
username: "",
|
|
||||||
password: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.handleInput = this.handleInput.bind(this);
|
|
||||||
this.handleSubmit = this.handleSubmit.bind(this);
|
|
||||||
|
|
||||||
if (props.username) {
|
|
||||||
this.state.username = props.username;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleInput(event) {
|
|
||||||
let target = event.target;
|
|
||||||
let value = target.type === "checkbox" ? target.checked : target.value;
|
|
||||||
this.setState({ [target.name]: value });
|
|
||||||
}
|
|
||||||
|
|
||||||
handleSubmit(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
this.props.onSubmit(this.state.username, this.state.password);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return html`
|
|
||||||
<form onInput=${this.handleInput} onSubmit=${this.handleSubmit}>
|
|
||||||
<label>
|
|
||||||
Username:<br/>
|
|
||||||
<input type="username" name="username" value=${this.state.username} required/>
|
|
||||||
</label>
|
|
||||||
<br/><br/>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
Password:<br/>
|
|
||||||
<input type="password" name="password" value=${this.state.password} required autofocus/>
|
|
||||||
</label>
|
|
||||||
<br/><br/>
|
|
||||||
|
|
||||||
<button>Login</button>
|
|
||||||
</form>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,4 +1,4 @@
|
|||||||
import { html } from "../lib/index.js";
|
import { html, Component } from "../lib/index.js";
|
||||||
import linkify from "../lib/linkify.js";
|
import linkify from "../lib/linkify.js";
|
||||||
import { strip as stripANSI } from "../lib/ansi.js";
|
import { strip as stripANSI } from "../lib/ansi.js";
|
||||||
import { BufferType, ServerStatus, getServerName } from "../state.js";
|
import { BufferType, ServerStatus, getServerName } from "../state.js";
|
||||||
@ -21,12 +21,24 @@ function NickStatus(props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function BufferHeader(props) {
|
export default function BufferHeader(props) {
|
||||||
let fullyConnected = props.server.status === ServerStatus.REGISTERED;
|
function handleCloseClick(event) {
|
||||||
if (props.bouncerNetwork) {
|
event.preventDefault();
|
||||||
fullyConnected = fullyConnected && props.bouncerNetwork.state === "connected";
|
props.onClose();
|
||||||
|
}
|
||||||
|
function handleJoinClick(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
props.onJoin();
|
||||||
|
}
|
||||||
|
function handleAddNetworkClick(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
props.onAddNetwork();
|
||||||
|
}
|
||||||
|
function handleManageNetworkClick(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
props.onManageNetwork();
|
||||||
}
|
}
|
||||||
|
|
||||||
let description = null, actions = [];
|
let description = null, actions = null;
|
||||||
switch (props.buffer.type) {
|
switch (props.buffer.type) {
|
||||||
case BufferType.SERVER:
|
case BufferType.SERVER:
|
||||||
switch (props.server.status) {
|
switch (props.server.status) {
|
||||||
@ -44,9 +56,6 @@ export default function BufferHeader(props) {
|
|||||||
switch (props.bouncerNetwork.state) {
|
switch (props.bouncerNetwork.state) {
|
||||||
case "disconnected":
|
case "disconnected":
|
||||||
description = "Bouncer disconnected from network";
|
description = "Bouncer disconnected from network";
|
||||||
if (props.bouncerNetwork.error) {
|
|
||||||
description += ": " + props.bouncerNetwork.error;
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case "connecting":
|
case "connecting":
|
||||||
description = "Bouncer connecting to network...";
|
description = "Bouncer connecting to network...";
|
||||||
@ -65,89 +74,56 @@ export default function BufferHeader(props) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let joinButton = html`
|
if (props.isBouncer) {
|
||||||
<button
|
if (props.server.isupport.get("BOUNCER_NETID")) {
|
||||||
key="join"
|
actions = html`
|
||||||
onClick=${props.onJoin}
|
<button
|
||||||
>Join channel</button>
|
key="join"
|
||||||
`;
|
onClick=${handleJoinClick}
|
||||||
let reconnectButton = html`
|
>Join channel</button>
|
||||||
<button
|
<button
|
||||||
key="reconect"
|
key="manage"
|
||||||
onClick=${props.onReconnect}
|
onClick=${handleManageNetworkClick}
|
||||||
>Reconnect</button>
|
>Manage network</button>
|
||||||
`;
|
`;
|
||||||
let settingsButton = html`
|
|
||||||
<button
|
|
||||||
key="settings"
|
|
||||||
onClick="${props.onOpenSettings}"
|
|
||||||
>Settings</button>
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (props.server.isBouncer) {
|
|
||||||
if (props.server.bouncerNetID) {
|
|
||||||
if (fullyConnected) {
|
|
||||||
actions.push(joinButton);
|
|
||||||
}
|
|
||||||
if (props.server.status === ServerStatus.REGISTERED) {
|
|
||||||
actions.push(html`
|
|
||||||
<button
|
|
||||||
key="manage"
|
|
||||||
onClick=${props.onManageNetwork}
|
|
||||||
>Manage network</button>
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if (fullyConnected) {
|
actions = html`
|
||||||
actions.push(html`
|
<button
|
||||||
<button
|
key="add"
|
||||||
key="add"
|
onClick=${handleAddNetworkClick}
|
||||||
onClick=${props.onAddNetwork}
|
>Add network</button>
|
||||||
>Add network</button>
|
<button
|
||||||
`);
|
key="disconnect"
|
||||||
} else if (props.server.status === ServerStatus.DISCONNECTED) {
|
class="danger"
|
||||||
actions.push(reconnectButton);
|
onClick=${handleCloseClick}
|
||||||
}
|
>Disconnect</button>
|
||||||
actions.push(settingsButton);
|
`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (fullyConnected) {
|
actions = html`
|
||||||
actions.push(joinButton);
|
<button
|
||||||
} else if (props.server.status === ServerStatus.DISCONNECTED) {
|
key="join"
|
||||||
actions.push(reconnectButton);
|
onClick=${handleJoinClick}
|
||||||
}
|
>Join channel</button>
|
||||||
actions.push(settingsButton);
|
<button
|
||||||
|
key="disconnect"
|
||||||
|
class="danger"
|
||||||
|
onClick=${handleCloseClick}
|
||||||
|
>Disconnect</button>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case BufferType.CHANNEL:
|
case BufferType.CHANNEL:
|
||||||
if (props.buffer.topic) {
|
if (props.buffer.topic) {
|
||||||
description = linkify(stripANSI(props.buffer.topic), props.onChannelClick);
|
description = linkify(stripANSI(props.buffer.topic), props.onChannelClick);
|
||||||
}
|
}
|
||||||
if (props.buffer.joined) {
|
actions = html`
|
||||||
actions.push(html`
|
<button
|
||||||
<button
|
key="part"
|
||||||
key="part"
|
class="danger"
|
||||||
class="danger"
|
onClick=${handleCloseClick}
|
||||||
onClick=${props.onClose}
|
>Leave</button>
|
||||||
>Leave</button>
|
`;
|
||||||
`);
|
|
||||||
} else {
|
|
||||||
if (fullyConnected) {
|
|
||||||
actions.push(html`
|
|
||||||
<button
|
|
||||||
key="join"
|
|
||||||
onClick=${props.onJoin}
|
|
||||||
>Join</button>
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
actions.push(html`
|
|
||||||
<button
|
|
||||||
key="part"
|
|
||||||
class="danger"
|
|
||||||
onClick=${props.onClose}
|
|
||||||
>Close</button>
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case BufferType.NICK:
|
case BufferType.NICK:
|
||||||
if (props.user) {
|
if (props.user) {
|
||||||
@ -168,37 +144,9 @@ export default function BufferHeader(props) {
|
|||||||
details.push(`${props.user.username}@${props.user.hostname}`);
|
details.push(`${props.user.username}@${props.user.hostname}`);
|
||||||
}
|
}
|
||||||
if (props.user.account) {
|
if (props.user.account) {
|
||||||
let desc = `This user is verified and has logged in to the server with the account ${props.user.account}.`;
|
details.push(`authenticated as ${props.user.account}`);
|
||||||
let item;
|
|
||||||
if (props.user.account === props.buffer.name) {
|
|
||||||
item = "authenticated";
|
|
||||||
} else {
|
|
||||||
item = `authenticated as ${props.user.account}`;
|
|
||||||
}
|
|
||||||
details.push(html`<abbr title=${desc}>${item}</abbr>`);
|
|
||||||
} else if (props.server.reliableUserAccounts) {
|
|
||||||
// If the server supports MONITOR and WHOX, we can faithfully
|
|
||||||
// keep user.account up-to-date for user queries
|
|
||||||
let desc = "This user has not been verified and is not logged in.";
|
|
||||||
details.push(html`<abbr title=${desc}>unauthenticated</abbr>`);
|
|
||||||
}
|
|
||||||
if (props.user.operator) {
|
|
||||||
let desc = "This user is a server operator, they have administrator privileges.";
|
|
||||||
details.push(html`<abbr title=${desc}>server operator</abbr>`);
|
|
||||||
}
|
|
||||||
if (props.user.bot) {
|
|
||||||
let desc = "This user is an automated bot.";
|
|
||||||
details.push(html`<abbr title=${desc}>bot</abbr>`);
|
|
||||||
}
|
|
||||||
details = details.map((item, i) => {
|
|
||||||
if (i === 0) {
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
return [", ", item];
|
|
||||||
});
|
|
||||||
if (details.length > 0) {
|
|
||||||
details = ["(", details, ")"];
|
|
||||||
}
|
}
|
||||||
|
details = details.length > 0 ? `(${details.join(", ")})` : null;
|
||||||
|
|
||||||
description = html`<${NickStatus} status=${status}/> ${realname} ${details}`;
|
description = html`<${NickStatus} status=${status}/> ${realname} ${details}`;
|
||||||
}
|
}
|
||||||
@ -207,15 +155,15 @@ export default function BufferHeader(props) {
|
|||||||
<button
|
<button
|
||||||
key="close"
|
key="close"
|
||||||
class="danger"
|
class="danger"
|
||||||
onClick=${props.onClose}
|
onClick=${handleCloseClick}
|
||||||
>Close</button>
|
>Close</button>
|
||||||
`;
|
`;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let name = props.buffer.name;
|
let name = props.buffer.name;
|
||||||
if (props.buffer.type === BufferType.SERVER) {
|
if (props.buffer.type == BufferType.SERVER) {
|
||||||
name = getServerName(props.server, props.bouncerNetwork);
|
name = getServerName(props.server, props.bouncerNetwork, props.isBouncer);
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
|
@ -1,70 +1,42 @@
|
|||||||
import * as irc from "../lib/irc.js";
|
import * as irc from "../lib/irc.js";
|
||||||
import { strip as stripANSI } from "../lib/ansi.js";
|
import { html, Component } from "../lib/index.js";
|
||||||
import { html } 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) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
props.onClick();
|
props.onClick();
|
||||||
}
|
}
|
||||||
function handleMouseDown(event) {
|
|
||||||
if (event.button === 1) { // middle click
|
|
||||||
event.preventDefault();
|
|
||||||
props.onClose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let name = props.buffer.name;
|
let name = props.buffer.name;
|
||||||
if (props.buffer.type === BufferType.SERVER) {
|
if (props.buffer.type == BufferType.SERVER) {
|
||||||
name = getServerName(props.server, props.bouncerNetwork);
|
name = getServerName(props.server, props.bouncerNetwork, props.isBouncer);
|
||||||
}
|
}
|
||||||
|
|
||||||
let title;
|
|
||||||
let classes = ["type-" + props.buffer.type];
|
let classes = ["type-" + props.buffer.type];
|
||||||
if (props.active) {
|
if (props.active) {
|
||||||
classes.push("active");
|
classes.push("active");
|
||||||
}
|
}
|
||||||
if (props.buffer.unread !== Unread.NONE) {
|
if (props.buffer.unread != Unread.NONE) {
|
||||||
classes.push("unread-" + props.buffer.unread);
|
classes.push("unread-" + props.buffer.unread);
|
||||||
}
|
}
|
||||||
switch (props.buffer.type) {
|
|
||||||
case BufferType.SERVER:
|
|
||||||
let isError = props.server.status === ServerStatus.DISCONNECTED;
|
|
||||||
if (props.bouncerNetwork && props.bouncerNetwork.error) {
|
|
||||||
isError = true;
|
|
||||||
}
|
|
||||||
if (isError) {
|
|
||||||
classes.push("error");
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case BufferType.NICK:
|
|
||||||
let user = props.server.users.get(name);
|
|
||||||
if (user && irc.isMeaningfulRealname(user.realname, name)) {
|
|
||||||
title = stripANSI(user.realname);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<li class="${classes.join(" ")}">
|
<li class="${classes.join(" ")}">
|
||||||
<a
|
<a href=${getBufferURL(props.buffer)} onClick=${handleClick}>${name}</a>
|
||||||
href=${getBufferURL(props.buffer)}
|
|
||||||
title=${title}
|
|
||||||
onClick=${handleClick}
|
|
||||||
onMouseDown=${handleMouseDown}
|
|
||||||
>${name}</a>
|
|
||||||
</li>
|
</li>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export default function BufferList(props) {
|
export default function BufferList(props) {
|
||||||
let items = Array.from(props.buffers.values()).map((buf) => {
|
let items = Array.from(props.buffers.values()).map((buf) => {
|
||||||
let server = props.servers.get(buf.server);
|
let server = props.servers.get(buf.server);
|
||||||
|
|
||||||
let bouncerNetwork = null;
|
let bouncerNetwork = null;
|
||||||
if (server.bouncerNetID) {
|
let bouncerNetID = server.isupport.get("BOUNCER_NETID");
|
||||||
bouncerNetwork = props.bouncerNetworks.get(server.bouncerNetID);
|
if (bouncerNetID) {
|
||||||
|
bouncerNetwork = props.bouncerNetworks.get(bouncerNetID);
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
@ -72,10 +44,10 @@ export default function BufferList(props) {
|
|||||||
key=${buf.id}
|
key=${buf.id}
|
||||||
buffer=${buf}
|
buffer=${buf}
|
||||||
server=${server}
|
server=${server}
|
||||||
|
isBouncer=${props.isBouncer}
|
||||||
bouncerNetwork=${bouncerNetwork}
|
bouncerNetwork=${bouncerNetwork}
|
||||||
onClick=${() => props.onBufferClick(buf)}
|
onClick=${() => props.onBufferClick(buf)}
|
||||||
onClose=${() => props.onBufferClose(buf)}
|
active=${props.activeBuffer == buf.id}
|
||||||
active=${props.activeBuffer === buf.id}
|
|
||||||
/>
|
/>
|
||||||
`;
|
`;
|
||||||
});
|
});
|
||||||
|
@ -2,8 +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, getMessageURL, isMessageBeforeReceipt, SettingsContext } from "../state.js";
|
import { BufferType, getNickURL, getChannelURL, getMessageURL } from "../state.js";
|
||||||
import * as store from "../store.js";
|
|
||||||
import Membership from "./membership.js";
|
import Membership from "./membership.js";
|
||||||
|
|
||||||
function djb2(s) {
|
function djb2(s) {
|
||||||
@ -21,38 +20,21 @@ function Nick(props) {
|
|||||||
props.onClick();
|
props.onClick();
|
||||||
}
|
}
|
||||||
|
|
||||||
let title;
|
|
||||||
if (props.user && irc.isMeaningfulRealname(props.user.realname, props.nick)) {
|
|
||||||
title = stripANSI(props.user.realname);
|
|
||||||
}
|
|
||||||
|
|
||||||
let colorIndex = djb2(props.nick) % 16 + 1;
|
let colorIndex = djb2(props.nick) % 16 + 1;
|
||||||
return html`
|
return html`
|
||||||
<a
|
<a href=${getNickURL(props.nick)} class="nick nick-${colorIndex}" onClick=${handleClick}>${props.nick}</a>
|
||||||
href=${irc.formatURL({ entity: props.nick })}
|
|
||||||
title=${title}
|
|
||||||
class="nick nick-${colorIndex}"
|
|
||||||
onClick=${handleClick}
|
|
||||||
>${props.nick}</a>
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function _Timestamp({ date, url, showSeconds }) {
|
function Timestamp({ date, url }) {
|
||||||
if (!date) {
|
if (!date) {
|
||||||
let timestamp = "--:--";
|
return html`<spam class="timestamp">--:--:--</span>`;
|
||||||
if (showSeconds) {
|
|
||||||
timestamp += ":--";
|
|
||||||
}
|
|
||||||
return html`<span class="timestamp">${timestamp}</span>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let hh = date.getHours().toString().padStart(2, "0");
|
let hh = date.getHours().toString().padStart(2, "0");
|
||||||
let mm = date.getMinutes().toString().padStart(2, "0");
|
let mm = date.getMinutes().toString().padStart(2, "0");
|
||||||
let timestamp = `${hh}:${mm}`;
|
let ss = date.getSeconds().toString().padStart(2, "0");
|
||||||
if (showSeconds) {
|
let timestamp = `${hh}:${mm}:${ss}`;
|
||||||
let ss = date.getSeconds().toString().padStart(2, "0");
|
|
||||||
timestamp += ":" + ss;
|
|
||||||
}
|
|
||||||
return html`
|
return html`
|
||||||
<a
|
<a
|
||||||
href=${url}
|
href=${url}
|
||||||
@ -65,16 +47,6 @@ function _Timestamp({ date, url, showSeconds }) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Timestamp(props) {
|
|
||||||
return html`
|
|
||||||
<${SettingsContext.Consumer}>
|
|
||||||
${(settings) => html`
|
|
||||||
<${_Timestamp} ...${props} showSeconds=${settings.secondsInTimestamps}/>
|
|
||||||
`}
|
|
||||||
</>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check whether a message can be folded.
|
* Check whether a message can be folded.
|
||||||
*
|
*
|
||||||
@ -94,7 +66,7 @@ function canFoldMessage(msg) {
|
|||||||
|
|
||||||
class LogLine extends Component {
|
class LogLine extends Component {
|
||||||
shouldComponentUpdate(nextProps) {
|
shouldComponentUpdate(nextProps) {
|
||||||
return this.props.message !== nextProps.message || this.props.redacted !== nextProps.redacted;
|
return this.props.message !== nextProps.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
@ -104,20 +76,18 @@ class LogLine extends Component {
|
|||||||
|
|
||||||
let onNickClick = this.props.onNickClick;
|
let onNickClick = this.props.onNickClick;
|
||||||
let onChannelClick = this.props.onChannelClick;
|
let onChannelClick = this.props.onChannelClick;
|
||||||
let onVerifyClick = this.props.onVerifyClick;
|
|
||||||
|
|
||||||
function createNick(nick) {
|
function createNick(nick) {
|
||||||
return html`
|
return html`
|
||||||
<${Nick}
|
<${Nick} nick=${nick} onClick=${() => onNickClick(nick)}/>
|
||||||
nick=${nick}
|
|
||||||
user=${server.users.get(nick)}
|
|
||||||
onClick=${() => onNickClick(nick)}
|
|
||||||
/>
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
function createChannel(channel) {
|
function createChannel(channel) {
|
||||||
|
function onClick(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
onChannelClick(channel);
|
||||||
|
}
|
||||||
return html`
|
return html`
|
||||||
<a href=${irc.formatURL({ entity: channel })} onClick=${onChannelClick}>
|
<a href=${getChannelURL(channel)} onClick=${onClick}>
|
||||||
${channel}
|
${channel}
|
||||||
</a>
|
</a>
|
||||||
`;
|
`;
|
||||||
@ -125,16 +95,16 @@ class LogLine extends Component {
|
|||||||
|
|
||||||
let lineClass = "";
|
let lineClass = "";
|
||||||
let content;
|
let content;
|
||||||
let invitee, target, account;
|
let invitee;
|
||||||
switch (msg.command) {
|
switch (msg.command) {
|
||||||
case "NOTICE":
|
case "NOTICE":
|
||||||
case "PRIVMSG":
|
case "PRIVMSG":
|
||||||
target = msg.params[0];
|
let target = msg.params[0];
|
||||||
let text = msg.params[1];
|
let text = msg.params[1];
|
||||||
|
|
||||||
let ctcp = irc.parseCTCP(msg);
|
let ctcp = irc.parseCTCP(msg);
|
||||||
if (ctcp) {
|
if (ctcp) {
|
||||||
if (ctcp.command === "ACTION") {
|
if (ctcp.command == "ACTION") {
|
||||||
lineClass = "me-tell";
|
lineClass = "me-tell";
|
||||||
content = html`* ${createNick(msg.prefix.name)} ${linkify(stripANSI(ctcp.param), onChannelClick)}`;
|
content = html`* ${createNick(msg.prefix.name)} ${linkify(stripANSI(ctcp.param), onChannelClick)}`;
|
||||||
} else {
|
} else {
|
||||||
@ -143,21 +113,16 @@ class LogLine extends Component {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
lineClass = "talk";
|
||||||
let prefix = "<", suffix = ">";
|
let prefix = "<", suffix = ">";
|
||||||
if (msg.command === "NOTICE") {
|
if (msg.command == "NOTICE") {
|
||||||
lineClass += " notice";
|
|
||||||
prefix = suffix = "-";
|
prefix = suffix = "-";
|
||||||
}
|
}
|
||||||
if (this.props.redacted) {
|
content = html`${prefix}${createNick(msg.prefix.name)}${suffix} ${linkify(stripANSI(text), onChannelClick)}`;
|
||||||
content = html`<i>This message has been deleted.</i>`;
|
|
||||||
} else {
|
|
||||||
content = html`${linkify(stripANSI(text), onChannelClick)}`;
|
|
||||||
lineClass += " talk";
|
|
||||||
}
|
|
||||||
content = html`<span class="nick-caret">${prefix}</span>${createNick(msg.prefix.name)}<span class="nick-caret">${suffix}</span> ${content}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let allowedPrefixes = server.statusMsg;
|
let status = null;
|
||||||
|
let allowedPrefixes = server.isupport.get("STATUSMSG");
|
||||||
if (target !== buf.name && allowedPrefixes) {
|
if (target !== buf.name && allowedPrefixes) {
|
||||||
let parts = irc.parseTargetPrefix(target, allowedPrefixes);
|
let parts = irc.parseTargetPrefix(target, allowedPrefixes);
|
||||||
if (parts.name === buf.name) {
|
if (parts.name === buf.name) {
|
||||||
@ -165,10 +130,6 @@ class LogLine extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.tags["+draft/channel-context"]) {
|
|
||||||
content = html`<em>(only visible to you)</em> ${content}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.isHighlight) {
|
if (msg.isHighlight) {
|
||||||
lineClass += " highlight";
|
lineClass += " highlight";
|
||||||
}
|
}
|
||||||
@ -200,95 +161,15 @@ class LogLine extends Component {
|
|||||||
`;
|
`;
|
||||||
break;
|
break;
|
||||||
case "MODE":
|
case "MODE":
|
||||||
target = msg.params[0];
|
|
||||||
let modeStr = msg.params[1];
|
|
||||||
|
|
||||||
let user = html`${createNick(msg.prefix.name)}`;
|
|
||||||
|
|
||||||
// TODO: use irc.forEachChannelModeUpdate()
|
|
||||||
if (buf.type === BufferType.CHANNEL && modeStr.length === 2 && server.cm(buf.name) === server.cm(target)) {
|
|
||||||
let plusMinus = modeStr[0];
|
|
||||||
let mode = modeStr[1];
|
|
||||||
let arg = msg.params[2];
|
|
||||||
|
|
||||||
let verb;
|
|
||||||
switch (mode) {
|
|
||||||
case "b":
|
|
||||||
verb = plusMinus === "+" ? "added" : "removed";
|
|
||||||
content = html`${user} has ${verb} a ban on ${arg}`;
|
|
||||||
break;
|
|
||||||
case "e":
|
|
||||||
verb = plusMinus === "+" ? "added" : "removed";
|
|
||||||
content = html`${user} has ${verb} a ban exemption on ${arg}`;
|
|
||||||
break;
|
|
||||||
case "l":
|
|
||||||
if (plusMinus === "+") {
|
|
||||||
content = html`${user} has set the channel user limit to ${arg}`;
|
|
||||||
} else {
|
|
||||||
content = html`${user} has unset the channel user limit`;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "i":
|
|
||||||
verb = plusMinus === "+" ? "marked": "unmarked";
|
|
||||||
content = html`${user} has ${verb} as invite-only`;
|
|
||||||
break;
|
|
||||||
case "m":
|
|
||||||
verb = plusMinus === "+" ? "marked": "unmarked";
|
|
||||||
content = html`${user} has ${verb} as moderated`;
|
|
||||||
break;
|
|
||||||
case "s":
|
|
||||||
verb = plusMinus === "+" ? "marked": "unmarked";
|
|
||||||
content = html`${user} has ${verb} as secret`;
|
|
||||||
break;
|
|
||||||
case "t":
|
|
||||||
verb = plusMinus === "+" ? "locked": "unlocked";
|
|
||||||
content = html`${user} has ${verb} the channel topic`;
|
|
||||||
break;
|
|
||||||
case "n":
|
|
||||||
verb = plusMinus === "+" ? "allowed": "denied";
|
|
||||||
content = html`${user} has ${verb} external messages to this channel`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (content) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Channel membership modes
|
|
||||||
let membership;
|
|
||||||
for (let prefix in irc.STD_MEMBERSHIP_MODES) {
|
|
||||||
if (irc.STD_MEMBERSHIP_MODES[prefix] === mode) {
|
|
||||||
membership = irc.STD_MEMBERSHIP_NAMES[prefix];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (membership && arg) {
|
|
||||||
let verb = plusMinus === "+" ? "granted" : "revoked";
|
|
||||||
let preposition = plusMinus === "+" ? "to" : "from";
|
|
||||||
content = html`
|
|
||||||
${user} has ${verb} ${membership} privileges ${preposition} ${createNick(arg)}
|
|
||||||
`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
content = html`
|
content = html`
|
||||||
${user} sets mode ${msg.params.slice(1).join(" ")}
|
* ${createNick(msg.prefix.name)} sets mode ${msg.params.slice(1).join(" ")}
|
||||||
`;
|
`;
|
||||||
if (server.cm(buf.name) !== server.cm(target)) {
|
|
||||||
content = html`${content} on ${target}`;
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case "TOPIC":
|
case "TOPIC":
|
||||||
let topic = msg.params[1];
|
let topic = msg.params[1];
|
||||||
if (topic) {
|
content = html`
|
||||||
content = html`
|
${createNick(msg.prefix.name)} changed the topic to: ${linkify(stripANSI(topic), onChannelClick)}
|
||||||
${createNick(msg.prefix.name)} changed the topic to: ${linkify(stripANSI(topic), onChannelClick)}
|
`;
|
||||||
`;
|
|
||||||
} else {
|
|
||||||
content = html`
|
|
||||||
${createNick(msg.prefix.name)} cleared the topic
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case "INVITE":
|
case "INVITE":
|
||||||
invitee = msg.params[0];
|
invitee = msg.params[0];
|
||||||
@ -305,10 +186,6 @@ class LogLine extends Component {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case irc.RPL_WELCOME:
|
|
||||||
let nick = msg.params[0];
|
|
||||||
content = html`Connected to server, your nickname is ${nick}`;
|
|
||||||
break;
|
|
||||||
case irc.RPL_INVITING:
|
case irc.RPL_INVITING:
|
||||||
invitee = msg.params[1];
|
invitee = msg.params[1];
|
||||||
content = html`${createNick(invitee)} has been invited to the channel`;
|
content = html`${createNick(invitee)} has been invited to the channel`;
|
||||||
@ -317,66 +194,11 @@ class LogLine extends Component {
|
|||||||
lineClass = "motd";
|
lineClass = "motd";
|
||||||
content = linkify(stripANSI(msg.params[1]), onChannelClick);
|
content = linkify(stripANSI(msg.params[1]), onChannelClick);
|
||||||
break;
|
break;
|
||||||
case irc.RPL_LOGGEDIN:
|
|
||||||
account = msg.params[2];
|
|
||||||
content = html`You are now authenticated as ${account}`;
|
|
||||||
break;
|
|
||||||
case irc.RPL_LOGGEDOUT:
|
|
||||||
content = html`You are now unauthenticated`;
|
|
||||||
break;
|
|
||||||
case "REGISTER":
|
|
||||||
account = msg.params[1];
|
|
||||||
let reason = msg.params[2];
|
|
||||||
|
|
||||||
function handleVerifyClick(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
onVerifyClick(account, reason);
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (msg.params[0]) {
|
|
||||||
case "SUCCESS":
|
|
||||||
content = html`A new account has been created, you are now authenticated as ${account}`;
|
|
||||||
break;
|
|
||||||
case "VERIFICATION_REQUIRED":
|
|
||||||
content = html`A new account has been created, but you need to <a href="#" onClick=${handleVerifyClick}>verify it</a>: ${linkify(reason)}`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "VERIFY":
|
|
||||||
account = msg.params[1];
|
|
||||||
content = html`The new account has been verified, you are now authenticated as ${account}`;
|
|
||||||
break;
|
|
||||||
case irc.RPL_UMODEIS:
|
|
||||||
let mode = msg.params[1];
|
|
||||||
if (mode) {
|
|
||||||
content = html`Your user mode is ${mode}`;
|
|
||||||
} else {
|
|
||||||
content = html`You have no user mode`;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case irc.RPL_CHANNELMODEIS:
|
|
||||||
content = html`Channel mode is ${msg.params.slice(2).join(" ")}`;
|
|
||||||
break;
|
|
||||||
case irc.RPL_CREATIONTIME:
|
|
||||||
let date = new Date(parseInt(msg.params[2], 10) * 1000);
|
|
||||||
content = html`Channel was created on ${date.toLocaleString()}`;
|
|
||||||
break;
|
|
||||||
// MONITOR messages are only displayed in user buffers
|
|
||||||
case irc.RPL_MONONLINE:
|
|
||||||
content = html`${createNick(buf.name)} is online`;
|
|
||||||
break;
|
|
||||||
case irc.RPL_MONOFFLINE:
|
|
||||||
content = html`${createNick(buf.name)} is offline`;
|
|
||||||
break;
|
|
||||||
default:
|
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";
|
||||||
}
|
}
|
||||||
content = html`${msg.command} ${linkify(msg.params.join(" "))}`;
|
content = html`${msg.command} ${msg.params.join(" ")}`;
|
||||||
}
|
|
||||||
|
|
||||||
if (!content) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
@ -419,16 +241,11 @@ class FoldGroup extends Component {
|
|||||||
render() {
|
render() {
|
||||||
let msgs = this.props.messages;
|
let msgs = this.props.messages;
|
||||||
let buf = this.props.buffer;
|
let buf = this.props.buffer;
|
||||||
let server = this.props.server;
|
|
||||||
|
|
||||||
let onNickClick = this.props.onNickClick;
|
let onNickClick = this.props.onNickClick;
|
||||||
function createNick(nick) {
|
function createNick(nick) {
|
||||||
return html`
|
return html`
|
||||||
<${Nick}
|
<${Nick} nick=${nick} onClick=${() => onNickClick(nick)}/>
|
||||||
nick=${nick}
|
|
||||||
user=${server.users.get(nick)}
|
|
||||||
onClick=${() => onNickClick(nick)}
|
|
||||||
/>
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -449,9 +266,7 @@ class FoldGroup extends Component {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let nicks = new Set(byCommand[cmd].map((msg) => msg.prefix.name));
|
let plural = byCommand[cmd].length > 1;
|
||||||
|
|
||||||
let plural = nicks.size > 1;
|
|
||||||
let action;
|
let action;
|
||||||
switch (cmd) {
|
switch (cmd) {
|
||||||
case "JOIN":
|
case "JOIN":
|
||||||
@ -471,7 +286,9 @@ class FoldGroup extends Component {
|
|||||||
content.push(", ");
|
content.push(", ");
|
||||||
}
|
}
|
||||||
|
|
||||||
content.push(createNickList([...nicks], createNick));
|
let nicks = byCommand[cmd].map((msg) => msg.prefix.name);
|
||||||
|
|
||||||
|
content.push(createNickList(nicks, createNick));
|
||||||
content.push(" " + action);
|
content.push(" " + action);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -567,82 +384,6 @@ class NotificationNagger extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ProtocolHandlerNagger extends Component {
|
|
||||||
state = { nag: true };
|
|
||||||
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.handleClick = this.handleClick.bind(this);
|
|
||||||
|
|
||||||
this.state.nag = !store.naggedProtocolHandler.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
handleClick(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
let url = window.location.origin + window.location.pathname + "?open=%s";
|
|
||||||
try {
|
|
||||||
navigator.registerProtocolHandler("irc", url);
|
|
||||||
navigator.registerProtocolHandler("ircs", url);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to register protocol handler: ", err);
|
|
||||||
}
|
|
||||||
|
|
||||||
store.naggedProtocolHandler.put(true);
|
|
||||||
this.setState({ nag: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
if (!navigator.registerProtocolHandler || !this.state.nag) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
let name = this.props.bouncerName || "this bouncer";
|
|
||||||
return html`
|
|
||||||
<div class="logline">
|
|
||||||
<${Timestamp}/>
|
|
||||||
${" "}
|
|
||||||
<a href="#" onClick=${this.handleClick}>Register our protocol handler</a> to open IRC links with ${name}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function AccountNagger({ server, onAuthClick, onRegisterClick }) {
|
|
||||||
let accDesc = "an account on this server";
|
|
||||||
if (server.name) {
|
|
||||||
accDesc = "a " + server.name + " account";
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAuthClick(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
onAuthClick();
|
|
||||||
}
|
|
||||||
function handleRegisterClick(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
onRegisterClick();
|
|
||||||
}
|
|
||||||
|
|
||||||
let msg = [html`
|
|
||||||
You are unauthenticated on this server,
|
|
||||||
${" "}
|
|
||||||
<a href="#" onClick=${handleAuthClick}>login</a>
|
|
||||||
${" "}
|
|
||||||
`];
|
|
||||||
|
|
||||||
if (server.supportsAccountRegistration) {
|
|
||||||
msg.push(html`or <a href="#" onClick=${handleRegisterClick}>register</a> ${accDesc}`);
|
|
||||||
} else {
|
|
||||||
msg.push(html`if you have ${accDesc}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<div class="logline">
|
|
||||||
<${Timestamp}/> ${msg}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
class DateSeparator extends Component {
|
class DateSeparator extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
@ -654,7 +395,10 @@ class DateSeparator extends Component {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
let date = this.props.date;
|
let date = this.props.date;
|
||||||
let text = date.toLocaleDateString([], { year: "numeric", month: "2-digit", day: "2-digit" });
|
let YYYY = date.getFullYear().toString().padStart(4, "0");
|
||||||
|
let MM = (date.getMonth() + 1).toString().padStart(2, "0");
|
||||||
|
let DD = date.getDate().toString().padStart(2, "0");
|
||||||
|
let text = `${YYYY}-${MM}-${DD}`;
|
||||||
return html`
|
return html`
|
||||||
<div class="separator date-separator">
|
<div class="separator date-separator">
|
||||||
${text}
|
${text}
|
||||||
@ -673,41 +417,23 @@ function sameDate(d1, d2) {
|
|||||||
|
|
||||||
export default class Buffer extends Component {
|
export default class Buffer extends Component {
|
||||||
shouldComponentUpdate(nextProps) {
|
shouldComponentUpdate(nextProps) {
|
||||||
return this.props.buffer !== nextProps.buffer ||
|
return this.props.buffer !== nextProps.buffer;
|
||||||
this.props.settings !== nextProps.settings;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
let buf = this.props.buffer;
|
let buf = this.props.buffer;
|
||||||
|
let server = this.props.server;
|
||||||
if (!buf) {
|
if (!buf) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let server = this.props.server;
|
|
||||||
let settings = this.props.settings;
|
|
||||||
let serverName = server.name;
|
|
||||||
|
|
||||||
let children = [];
|
let children = [];
|
||||||
if (buf.type === BufferType.SERVER) {
|
if (buf.type == BufferType.SERVER) {
|
||||||
children.push(html`<${NotificationNagger}/>`);
|
children.push(html`<${NotificationNagger}/>`);
|
||||||
}
|
}
|
||||||
if (buf.type === BufferType.SERVER && server.isBouncer && !server.bouncerNetID) {
|
|
||||||
children.push(html`<${ProtocolHandlerNagger} bouncerName=${serverName}/>`);
|
|
||||||
}
|
|
||||||
if (buf.type === BufferType.SERVER && server.status === ServerStatus.REGISTERED && server.supportsSASLPlain && !server.account) {
|
|
||||||
children.push(html`
|
|
||||||
<${AccountNagger}
|
|
||||||
server=${server}
|
|
||||||
onAuthClick=${this.props.onAuthClick}
|
|
||||||
onRegisterClick=${this.props.onRegisterClick}
|
|
||||||
/>
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let onChannelClick = this.props.onChannelClick;
|
let onChannelClick = this.props.onChannelClick;
|
||||||
let onNickClick = this.props.onNickClick;
|
let onNickClick = this.props.onNickClick;
|
||||||
let onVerifyClick = this.props.onVerifyClick;
|
|
||||||
|
|
||||||
function createLogLine(msg) {
|
function createLogLine(msg) {
|
||||||
return html`
|
return html`
|
||||||
<${LogLine}
|
<${LogLine}
|
||||||
@ -715,46 +441,13 @@ export default class Buffer extends Component {
|
|||||||
message=${msg}
|
message=${msg}
|
||||||
buffer=${buf}
|
buffer=${buf}
|
||||||
server=${server}
|
server=${server}
|
||||||
redacted=${buf.redacted.has(msg.tags.msgid)}
|
|
||||||
onChannelClick=${onChannelClick}
|
onChannelClick=${onChannelClick}
|
||||||
onNickClick=${onNickClick}
|
onNickClick=${onNickClick}
|
||||||
onVerifyClick=${onVerifyClick}
|
|
||||||
/>
|
/>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
function createFoldGroup(msgs) {
|
function createFoldGroup(msgs) {
|
||||||
// Merge NICK change chains
|
// Filter out PART → JOIN pairs
|
||||||
let nickChanges = new Map();
|
|
||||||
let mergedMsgs = [];
|
|
||||||
for (let msg of msgs) {
|
|
||||||
let keep = true;
|
|
||||||
switch (msg.command) {
|
|
||||||
case "PART":
|
|
||||||
case "QUIT":
|
|
||||||
nickChanges.delete(msg.prefix.name);
|
|
||||||
break;
|
|
||||||
case "NICK":
|
|
||||||
let prev = nickChanges.get(msg.prefix.name);
|
|
||||||
if (!prev) {
|
|
||||||
// Future NICK messages may mutate this one
|
|
||||||
msg = { ...msg };
|
|
||||||
nickChanges.set(msg.params[0], msg);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
prev.params = msg.params;
|
|
||||||
nickChanges.delete(msg.prefix.name);
|
|
||||||
nickChanges.set(msg.params[0], prev);
|
|
||||||
keep = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (keep) {
|
|
||||||
mergedMsgs.push(msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
msgs = mergedMsgs;
|
|
||||||
|
|
||||||
// Filter out PART → JOIN pairs, as well as no-op NICKs from previous step
|
|
||||||
let partIndexes = new Map();
|
let partIndexes = new Map();
|
||||||
let keep = [];
|
let keep = [];
|
||||||
msgs.forEach((msg, i) => {
|
msgs.forEach((msg, i) => {
|
||||||
@ -765,8 +458,6 @@ export default class Buffer extends Component {
|
|||||||
keep[partIndexes.get(msg.prefix.name)] = false;
|
keep[partIndexes.get(msg.prefix.name)] = false;
|
||||||
partIndexes.delete(msg.prefix.name);
|
partIndexes.delete(msg.prefix.name);
|
||||||
keep.push(false);
|
keep.push(false);
|
||||||
} else if (msg.command === "NICK" && msg.prefix.name === msg.params[0]) {
|
|
||||||
keep.push(false);
|
|
||||||
} else {
|
} else {
|
||||||
keep.push(true);
|
keep.push(true);
|
||||||
}
|
}
|
||||||
@ -792,23 +483,10 @@ 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 = [];
|
||||||
|
|
||||||
if (settings.bufferEvents === BufferEventsDisplayMode.HIDE && canFoldMessage(msg)) {
|
if (!hasUnreadSeparator && buf.type != BufferType.SERVER && buf.prevReadReceipt && msg.tags.time > buf.prevReadReceipt.time) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.command === irc.RPL_MONONLINE || msg.command === irc.RPL_MONOFFLINE) {
|
|
||||||
let skip = !lastMonitor || msg.command === lastMonitor;
|
|
||||||
lastMonitor = msg.command;
|
|
||||||
if (skip) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasUnreadSeparator && buf.type !== BufferType.SERVER && !isMessageBeforeReceipt(msg, buf.prevReadReceipt)) {
|
|
||||||
sep.push(html`<${UnreadSeparator} key="unread"/>`);
|
sep.push(html`<${UnreadSeparator} key="unread"/>`);
|
||||||
hasUnreadSeparator = true;
|
hasUnreadSeparator = true;
|
||||||
}
|
}
|
||||||
@ -821,12 +499,12 @@ export default class Buffer extends Component {
|
|||||||
|
|
||||||
if (sep.length > 0) {
|
if (sep.length > 0) {
|
||||||
children.push(createFoldGroup(foldMessages));
|
children.push(createFoldGroup(foldMessages));
|
||||||
children.push(...sep);
|
children.push(sep);
|
||||||
foldMessages = [];
|
foldMessages = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: consider checking the time difference too
|
// TODO: consider checking the time difference too
|
||||||
if (settings.bufferEvents === BufferEventsDisplayMode.FOLD && canFoldMessage(msg)) {
|
if (canFoldMessage(msg)) {
|
||||||
foldMessages.push(msg);
|
foldMessages.push(msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -1,16 +1,5 @@
|
|||||||
import { html, Component, createRef } from "../lib/index.js";
|
import { html, Component, createRef } from "../lib/index.js";
|
||||||
|
|
||||||
function encodeContentDisposition(filename) {
|
|
||||||
// Encode filename according to RFC 5987 if necessary. Note,
|
|
||||||
// encodeURIComponent will percent-encode a superset of attr-char.
|
|
||||||
let encodedFilename = encodeURIComponent(filename);
|
|
||||||
if (encodedFilename === filename) {
|
|
||||||
return "attachment; filename=\"" + filename + "\"";
|
|
||||||
} else {
|
|
||||||
return "attachment; filename*=UTF-8''" + encodedFilename;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class Composer extends Component {
|
export default class Composer extends Component {
|
||||||
state = {
|
state = {
|
||||||
text: "",
|
text: "",
|
||||||
@ -24,9 +13,6 @@ export default class Composer extends Component {
|
|||||||
this.handleInput = this.handleInput.bind(this);
|
this.handleInput = this.handleInput.bind(this);
|
||||||
this.handleSubmit = this.handleSubmit.bind(this);
|
this.handleSubmit = this.handleSubmit.bind(this);
|
||||||
this.handleInputKeyDown = this.handleInputKeyDown.bind(this);
|
this.handleInputKeyDown = this.handleInputKeyDown.bind(this);
|
||||||
this.handleInputPaste = this.handleInputPaste.bind(this);
|
|
||||||
this.handleDragOver = this.handleDragOver.bind(this);
|
|
||||||
this.handleDrop = this.handleDrop.bind(this);
|
|
||||||
this.handleWindowKeyDown = this.handleWindowKeyDown.bind(this);
|
this.handleWindowKeyDown = this.handleWindowKeyDown.bind(this);
|
||||||
this.handleWindowPaste = this.handleWindowPaste.bind(this);
|
this.handleWindowPaste = this.handleWindowPaste.bind(this);
|
||||||
}
|
}
|
||||||
@ -130,118 +116,10 @@ export default class Composer extends Component {
|
|||||||
this.setState({ text: autocomplete.text });
|
this.setState({ text: autocomplete.text });
|
||||||
}
|
}
|
||||||
|
|
||||||
canUploadFiles() {
|
|
||||||
let client = this.props.client;
|
|
||||||
return client && client.isupport.filehost() && !this.props.readOnly;
|
|
||||||
}
|
|
||||||
|
|
||||||
async uploadFile(file) {
|
|
||||||
let client = this.props.client;
|
|
||||||
let endpoint = client.isupport.filehost();
|
|
||||||
|
|
||||||
let auth;
|
|
||||||
if (client.params.saslPlain) {
|
|
||||||
let params = client.params.saslPlain;
|
|
||||||
auth = "Basic " + btoa(params.username + ":" + params.password);
|
|
||||||
} else if (client.params.saslOauthBearer) {
|
|
||||||
auth = "Bearer " + client.params.saslOauthBearer.token;
|
|
||||||
}
|
|
||||||
|
|
||||||
let headers = {
|
|
||||||
"Content-Length": file.size,
|
|
||||||
"Content-Disposition": encodeContentDisposition(file.name),
|
|
||||||
};
|
|
||||||
if (file.type) {
|
|
||||||
headers["Content-Type"] = file.type;
|
|
||||||
}
|
|
||||||
if (auth) {
|
|
||||||
headers["Authorization"] = auth;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: show a loading UI while uploading
|
|
||||||
// TODO: show a cancel button
|
|
||||||
let resp = await fetch(endpoint, {
|
|
||||||
method: "POST",
|
|
||||||
body: file,
|
|
||||||
headers,
|
|
||||||
credentials: "include",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!resp.ok) {
|
|
||||||
throw new Error(`HTTP request failed (${resp.status})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let loc = resp.headers.get("Location");
|
|
||||||
if (!loc) {
|
|
||||||
throw new Error("filehost response missing Location header field");
|
|
||||||
}
|
|
||||||
|
|
||||||
return new URL(loc, endpoint);
|
|
||||||
}
|
|
||||||
|
|
||||||
async uploadFileList(fileList) {
|
|
||||||
let promises = [];
|
|
||||||
for (let file of fileList) {
|
|
||||||
promises.push(this.uploadFile(file));
|
|
||||||
}
|
|
||||||
|
|
||||||
let urls = await Promise.all(promises);
|
|
||||||
|
|
||||||
this.setState((state) => {
|
|
||||||
if (state.text) {
|
|
||||||
return { text: state.text + " " + urls.join(" ") };
|
|
||||||
} else {
|
|
||||||
return { text: urls.join(" ") };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleInputPaste(event) {
|
|
||||||
if (event.clipboardData.files.length === 0 || !this.canUploadFiles()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopImmediatePropagation();
|
|
||||||
|
|
||||||
await this.uploadFileList(event.clipboardData.files);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleDragOver(event) {
|
|
||||||
if (event.dataTransfer.items.length === 0 || !this.canUploadFiles()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let item of event.dataTransfer.items) {
|
|
||||||
if (item.kind !== "file") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleDrop(event) {
|
|
||||||
if (event.dataTransfer.files.length === 0 || !this.canUploadFiles()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopImmediatePropagation();
|
|
||||||
|
|
||||||
await this.uploadFileList(event.dataTransfer.files);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleWindowKeyDown(event) {
|
handleWindowKeyDown(event) {
|
||||||
// If an <input> or <button> is focused, ignore.
|
// If an <input> or <button> is focused, ignore.
|
||||||
if (document.activeElement && document.activeElement !== document.body) {
|
if (document.activeElement !== document.body && document.activeElement.tagName !== "SECTION") {
|
||||||
switch (document.activeElement.tagName.toLowerCase()) {
|
return;
|
||||||
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.
|
||||||
@ -265,7 +143,7 @@ export default class Composer extends Component {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.props.readOnly || (this.props.commandOnly && event.key !== "/")) {
|
if (this.props.readOnly && event.key !== "/") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -289,12 +167,7 @@ export default class Composer extends Component {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.clipboardData.files.length > 0) {
|
let text = event.clipboardData.getData('text');
|
||||||
this.handleInputPaste(event);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let text = event.clipboardData.getData("text");
|
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopImmediatePropagation();
|
event.stopImmediatePropagation();
|
||||||
@ -328,11 +201,6 @@ export default class Composer extends Component {
|
|||||||
className = "read-only";
|
className = "read-only";
|
||||||
}
|
}
|
||||||
|
|
||||||
let placeholder = "Type a message";
|
|
||||||
if (this.props.commandOnly) {
|
|
||||||
placeholder = "Type a command (see /help)";
|
|
||||||
}
|
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<form
|
<form
|
||||||
id="composer"
|
id="composer"
|
||||||
@ -346,13 +214,9 @@ export default class Composer extends Component {
|
|||||||
ref=${this.textInput}
|
ref=${this.textInput}
|
||||||
value=${this.state.text}
|
value=${this.state.text}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
placeholder=${placeholder}
|
placeholder="Type a message"
|
||||||
enterkeyhint="send"
|
enterkeyhint="send"
|
||||||
onKeyDown=${this.handleInputKeyDown}
|
onKeyDown=${this.handleInputKeyDown}
|
||||||
onPaste=${this.handleInputPaste}
|
|
||||||
onDragOver=${this.handleDragOver}
|
|
||||||
onDrop=${this.handleDrop}
|
|
||||||
maxlength=${this.props.maxLen}
|
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
`;
|
`;
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { html, Component, createRef } from "../lib/index.js";
|
import { html, Component, createRef } from "../lib/index.js";
|
||||||
import linkify from "../lib/linkify.js";
|
|
||||||
|
|
||||||
export default class ConnectForm extends Component {
|
export default class ConnectForm extends Component {
|
||||||
state = {
|
state = {
|
||||||
@ -10,14 +9,14 @@ export default class ConnectForm extends Component {
|
|||||||
rememberMe: false,
|
rememberMe: false,
|
||||||
username: "",
|
username: "",
|
||||||
realname: "",
|
realname: "",
|
||||||
autojoin: true,
|
autojoin: "",
|
||||||
};
|
};
|
||||||
nickInput = createRef();
|
nickInput = createRef();
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
this.handleInput = this.handleInput.bind(this);
|
this.handleChange = this.handleChange.bind(this);
|
||||||
this.handleSubmit = this.handleSubmit.bind(this);
|
this.handleSubmit = this.handleSubmit.bind(this);
|
||||||
|
|
||||||
if (props.params) {
|
if (props.params) {
|
||||||
@ -28,13 +27,14 @@ export default class ConnectForm extends Component {
|
|||||||
rememberMe: props.params.autoconnect || false,
|
rememberMe: props.params.autoconnect || false,
|
||||||
username: props.params.username || "",
|
username: props.params.username || "",
|
||||||
realname: props.params.realname || "",
|
realname: props.params.realname || "",
|
||||||
|
autojoin: (props.params.autojoin || []).join(","),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleInput(event) {
|
handleChange(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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,15 +61,15 @@ export default class ConnectForm extends Component {
|
|||||||
username: params.username || params.nick,
|
username: params.username || params.nick,
|
||||||
password: this.state.password,
|
password: this.state.password,
|
||||||
};
|
};
|
||||||
} else if (this.props.auth === "external") {
|
|
||||||
params.saslExternal = true;
|
|
||||||
} else if (this.props.auth === "oauth2") {
|
|
||||||
params.saslOauthBearer = this.props.params.saslOauthBearer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.state.autojoin) {
|
this.state.autojoin.split(",").forEach(function(ch) {
|
||||||
params.autojoin = this.props.params.autojoin || [];
|
ch = ch.trim();
|
||||||
}
|
if (!ch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
params.autojoin.push(ch);
|
||||||
|
});
|
||||||
|
|
||||||
this.props.onSubmit(params);
|
this.props.onSubmit(params);
|
||||||
}
|
}
|
||||||
@ -107,12 +107,12 @@ export default class ConnectForm extends Component {
|
|||||||
`;
|
`;
|
||||||
} else if (this.props.error) {
|
} else if (this.props.error) {
|
||||||
status = html`
|
status = html`
|
||||||
<p class="error-text">${linkify(this.props.error)}</p>
|
<p class="error-text">${this.props.error}</p>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let auth = null;
|
let auth = null;
|
||||||
if (this.props.auth !== "disabled" && this.props.auth !== "external" && this.props.auth !== "oauth2") {
|
if (this.props.auth !== "disabled") {
|
||||||
auth = html`
|
auth = html`
|
||||||
<label>
|
<label>
|
||||||
Password:<br/>
|
Password:<br/>
|
||||||
@ -129,25 +129,25 @@ export default class ConnectForm extends Component {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let autojoin = null;
|
let autojoin = html`
|
||||||
let channels = this.props.params.autojoin || [];
|
<label>
|
||||||
if (channels.length > 0) {
|
Auto-join channels:<br/>
|
||||||
let s = channels.length > 1 ? "s" : "";
|
<input
|
||||||
autojoin = html`
|
type="text"
|
||||||
<label>
|
name="autojoin"
|
||||||
<input
|
value=${this.state.autojoin}
|
||||||
type="checkbox"
|
disabled=${disabled}
|
||||||
name="autojoin"
|
placeholder="Comma-separated list of channels"
|
||||||
checked=${this.state.autojoin}
|
/>
|
||||||
/>
|
</label>
|
||||||
Auto-join channel${s} <strong>${channels.join(", ")}</strong>
|
<br/>
|
||||||
</label>
|
`;
|
||||||
<br/><br/>
|
|
||||||
`;
|
// Show autojoin field in advanced options, except if it's pre-filled
|
||||||
}
|
let isAutojoinAdvanced = (this.props.params.autojoin || []).length === 0;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<form onInput=${this.handleInput} onSubmit=${this.handleSubmit}>
|
<form onChange=${this.handleChange} onSubmit=${this.handleSubmit}>
|
||||||
<h2>Connect to IRC</h2>
|
<h2>Connect to IRC</h2>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
@ -159,14 +159,13 @@ 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/>
|
||||||
|
|
||||||
${auth}
|
${auth}
|
||||||
|
|
||||||
${autojoin}
|
${!isAutojoinAdvanced ? [autojoin, html`<br/>`] : null}
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
<input
|
<input
|
||||||
@ -213,7 +212,7 @@ export default class ConnectForm extends Component {
|
|||||||
<label>
|
<label>
|
||||||
Server password:<br/>
|
Server password:<br/>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="text"
|
||||||
name="pass"
|
name="pass"
|
||||||
value=${this.state.pass}
|
value=${this.state.pass}
|
||||||
disabled=${disabled}
|
disabled=${disabled}
|
||||||
@ -221,6 +220,8 @@ export default class ConnectForm extends Component {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<br/><br/>
|
<br/><br/>
|
||||||
|
|
||||||
|
${isAutojoinAdvanced ? autojoin : null}
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
|
@ -21,13 +21,13 @@ export default class Dialog extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleBackdropClick(event) {
|
handleBackdropClick(event) {
|
||||||
if (event.target.className === "dialog") {
|
if (event.target.className == "dialog") {
|
||||||
this.dismiss();
|
this.dismiss();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleKeyDown(event) {
|
handleKeyDown(event) {
|
||||||
if (event.key === "Escape") {
|
if (event.key == "Escape") {
|
||||||
this.dismiss();
|
this.dismiss();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { html } from "../lib/index.js";
|
import { html, Component } from "../lib/index.js";
|
||||||
import { keybindings } from "../keybindings.js";
|
import { keybindings } from "../keybindings.js";
|
||||||
import commands from "../commands.js";
|
import commands from "../commands.js";
|
||||||
|
|
||||||
@ -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.push("Ctrl");
|
keys.psuh("Ctrl");
|
||||||
}
|
}
|
||||||
if (binding.altKey) {
|
if (binding.altKey) {
|
||||||
keys.push("Alt");
|
keys.push("Alt");
|
||||||
@ -26,32 +26,27 @@ function KeyBindingsHelp() {
|
|||||||
`;
|
`;
|
||||||
});
|
});
|
||||||
|
|
||||||
l.push(html`
|
return html`
|
||||||
<dt><kbd>Tab</kbd></dt>
|
<dl>
|
||||||
<dd>Automatically complete nickname or channel</dd>
|
<dt><kbd>/</kbd></dt>
|
||||||
`);
|
<dd>Start writing a command</dd>
|
||||||
|
|
||||||
if (!window.matchMedia("(pointer: none)").matches) {
|
${l}
|
||||||
l.push(html`
|
</dl>
|
||||||
<dt><strong>Middle mouse click</strong></dt>
|
`;
|
||||||
<dd>Close buffer</dd>
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return html`<dl>${l}</dl>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommandsHelp() {
|
function CommandsHelp() {
|
||||||
let l = [...commands.keys()].map((name) => {
|
let l = Object.keys(commands).map((name) => {
|
||||||
let cmd = commands.get(name);
|
let cmd = commands[name];
|
||||||
|
|
||||||
let usage = [html`<strong>/${name}</strong>`];
|
let usage = "/" + name;
|
||||||
if (cmd.usage) {
|
if (cmd.usage) {
|
||||||
usage.push(" " + cmd.usage);
|
usage += " " + cmd.usage;
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<dt><code>${usage}</code></dt>
|
<dt><strong><code>${usage}</code></strong></dt>
|
||||||
<dd>${cmd.description}</dd>
|
<dd>${cmd.description}</dd>
|
||||||
`;
|
`;
|
||||||
});
|
});
|
||||||
|
@ -8,17 +8,13 @@ export default class JoinForm extends Component {
|
|||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
this.handleInput = this.handleInput.bind(this);
|
this.handleChange = this.handleChange.bind(this);
|
||||||
this.handleSubmit = this.handleSubmit.bind(this);
|
this.handleSubmit = this.handleSubmit.bind(this);
|
||||||
|
|
||||||
if (props.channel) {
|
|
||||||
this.state.channel = props.channel;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleInput(event) {
|
handleChange(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 +30,7 @@ export default class JoinForm extends Component {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
return html`
|
return html`
|
||||||
<form onInput=${this.handleInput} onSubmit=${this.handleSubmit}>
|
<form onChange=${this.handleChange} 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/>
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
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";
|
||||||
@ -12,8 +13,7 @@ class MemberItem extends Component {
|
|||||||
|
|
||||||
shouldComponentUpdate(nextProps) {
|
shouldComponentUpdate(nextProps) {
|
||||||
return this.props.nick !== nextProps.nick
|
return this.props.nick !== nextProps.nick
|
||||||
|| this.props.membership !== nextProps.membership
|
|| this.props.membership !== nextProps.membership;
|
||||||
|| this.props.user !== nextProps.user;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleClick(event) {
|
handleClick(event) {
|
||||||
@ -22,9 +22,27 @@ class MemberItem extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
let title;
|
// XXX: If we were feeling creative we could generate unique colors for
|
||||||
|
// each item in ISUPPORT CHANMODES. But I am not feeling creative.
|
||||||
|
const membmap = {
|
||||||
|
"~": "owner",
|
||||||
|
"&": "admin",
|
||||||
|
"@": "op",
|
||||||
|
"%": "halfop",
|
||||||
|
"+": "voice",
|
||||||
|
};
|
||||||
|
const membclass = membmap[this.props.membership[0]] || "";
|
||||||
|
let membership = "";
|
||||||
|
if (this.props.membership) {
|
||||||
|
membership = html`
|
||||||
|
<span class="membership ${membclass}" title=${membclass}>
|
||||||
|
${this.props.membership}
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
let title = null;
|
||||||
let user = this.props.user;
|
let user = this.props.user;
|
||||||
let classes = ["nick"];
|
|
||||||
if (user) {
|
if (user) {
|
||||||
let mask = "";
|
let mask = "";
|
||||||
if (user.username && user.hostname) {
|
if (user.username && user.hostname) {
|
||||||
@ -43,18 +61,13 @@ class MemberItem extends Component {
|
|||||||
if (user.account) {
|
if (user.account) {
|
||||||
title += `\nAuthenticated as ${user.account}`;
|
title += `\nAuthenticated as ${user.account}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.away) {
|
|
||||||
classes.push("away");
|
|
||||||
title += "\nAway";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href=${irc.formatURL({ entity: this.props.nick, enttype: "user" })}
|
href=${getNickURL(this.props.nick)}
|
||||||
class=${classes.join(" ")}
|
class="nick"
|
||||||
title=${title}
|
title=${title}
|
||||||
onClick=${this.handleClick}
|
onClick=${this.handleClick}
|
||||||
>
|
>
|
||||||
@ -81,13 +94,12 @@ function sortMembers(a, b) {
|
|||||||
return i - j;
|
return i - j;
|
||||||
}
|
}
|
||||||
|
|
||||||
return nickA.localeCompare(nickB);
|
return nickA < nickB ? -1 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class MemberList extends Component {
|
export default class MemberList extends Component {
|
||||||
shouldComponentUpdate(nextProps) {
|
shouldComponentUpdate(nextProps) {
|
||||||
return this.props.members !== nextProps.members
|
return this.props.members !== nextProps.members;
|
||||||
|| this.props.users !== nextProps.users;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
@ -1,14 +1,21 @@
|
|||||||
import { html } from "../lib/index.js";
|
import { html, Component } from "../lib/index.js";
|
||||||
import * as irc from "../lib/irc.js";
|
|
||||||
|
// XXX: If we were feeling creative we could generate unique colors for
|
||||||
|
// each item in ISUPPORT CHANMODES. But I am not feeling creative.
|
||||||
|
const names = {
|
||||||
|
"~": "owner",
|
||||||
|
"&": "admin",
|
||||||
|
"@": "op",
|
||||||
|
"%": "halfop",
|
||||||
|
"+": "voice",
|
||||||
|
};
|
||||||
|
|
||||||
export default function Membership(props) {
|
export default function Membership(props) {
|
||||||
if (!this.props.value) {
|
if (!this.props.value) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// XXX: If we were feeling creative we could generate unique colors for
|
const name = names[this.props.value[0]] || "";
|
||||||
// each item in ISUPPORT CHANMODES. But I am not feeling creative.
|
|
||||||
const name = irc.STD_MEMBERSHIP_NAMES[this.props.value[0]] || "";
|
|
||||||
return html`
|
return html`
|
||||||
<span class="membership ${name}" title=${name}>
|
<span class="membership ${name}" title=${name}>
|
||||||
${this.props.value}
|
${this.props.value}
|
||||||
|
@ -14,7 +14,7 @@ export default class NetworkForm extends Component {
|
|||||||
prevParams = null;
|
prevParams = null;
|
||||||
state = {
|
state = {
|
||||||
...defaultParams,
|
...defaultParams,
|
||||||
autojoin: true,
|
isNew: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@ -22,9 +22,11 @@ export default class NetworkForm extends Component {
|
|||||||
|
|
||||||
this.prevParams = { ...defaultParams };
|
this.prevParams = { ...defaultParams };
|
||||||
|
|
||||||
this.handleInput = this.handleInput.bind(this);
|
this.handleChange = this.handleChange.bind(this);
|
||||||
this.handleSubmit = this.handleSubmit.bind(this);
|
this.handleSubmit = this.handleSubmit.bind(this);
|
||||||
|
|
||||||
|
this.state.isNew = !props.params;
|
||||||
|
|
||||||
if (props.params) {
|
if (props.params) {
|
||||||
Object.keys(defaultParams).forEach((k) => {
|
Object.keys(defaultParams).forEach((k) => {
|
||||||
if (props.params[k] !== undefined) {
|
if (props.params[k] !== undefined) {
|
||||||
@ -35,9 +37,9 @@ export default class NetworkForm extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleInput(event) {
|
handleChange(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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -46,22 +48,18 @@ export default class NetworkForm extends Component {
|
|||||||
|
|
||||||
let params = {};
|
let params = {};
|
||||||
Object.keys(defaultParams).forEach((k) => {
|
Object.keys(defaultParams).forEach((k) => {
|
||||||
if (!this.props.isNew && this.prevParams[k] === this.state[k]) {
|
if (this.prevParams[k] == this.state[k]) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.props.isNew && defaultParams[k] === this.state[k]) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
params[k] = this.state[k];
|
params[k] = this.state[k];
|
||||||
});
|
});
|
||||||
|
|
||||||
let autojoin = this.state.autojoin ? this.props.autojoin : null;
|
this.props.onSubmit(params);
|
||||||
this.props.onSubmit(params, autojoin);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
let removeNetwork = null;
|
let removeNetwork = null;
|
||||||
if (!this.props.isNew) {
|
if (!this.state.isNew) {
|
||||||
removeNetwork = html`
|
removeNetwork = html`
|
||||||
<button type="button" class="danger" onClick=${() => this.props.onRemove()}>
|
<button type="button" class="danger" onClick=${() => this.props.onRemove()}>
|
||||||
Remove network
|
Remove network
|
||||||
@ -69,31 +67,14 @@ export default class NetworkForm extends Component {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let autojoin = null;
|
|
||||||
if (this.props.autojoin) {
|
|
||||||
autojoin = html`
|
|
||||||
<label>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="autojoin"
|
|
||||||
checked=${this.state.autojoin}
|
|
||||||
/>
|
|
||||||
Auto-join channel <strong>${this.props.autojoin}</strong>
|
|
||||||
</label>
|
|
||||||
<br/><br/>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<form onInput=${this.handleInput} onSubmit=${this.handleSubmit}>
|
<form onChange=${this.handleChange} 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/>
|
||||||
</label>
|
</label>
|
||||||
<br/><br/>
|
<br/><br/>
|
||||||
|
|
||||||
${autojoin}
|
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary role="button">Advanced options</summary>
|
<summary role="button">Advanced options</summary>
|
||||||
|
|
||||||
@ -140,7 +121,7 @@ export default class NetworkForm extends Component {
|
|||||||
${removeNetwork}
|
${removeNetwork}
|
||||||
${" "}
|
${" "}
|
||||||
<button>
|
<button>
|
||||||
${this.props.isNew ? "Add network" : "Save network"}
|
${this.state.isNew ? "Add network" : "Save network"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
`;
|
`;
|
||||||
|
@ -1,54 +0,0 @@
|
|||||||
import { html, Component } from "../lib/index.js";
|
|
||||||
|
|
||||||
export default class RegisterForm extends Component {
|
|
||||||
state = {
|
|
||||||
email: "",
|
|
||||||
password: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.handleInput = this.handleInput.bind(this);
|
|
||||||
this.handleSubmit = this.handleSubmit.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleInput(event) {
|
|
||||||
let target = event.target;
|
|
||||||
let value = target.type === "checkbox" ? target.checked : target.value;
|
|
||||||
this.setState({ [target.name]: value });
|
|
||||||
}
|
|
||||||
|
|
||||||
handleSubmit(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
this.props.onSubmit(this.state.email, this.state.password);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return html`
|
|
||||||
<form onInput=${this.handleInput} onSubmit=${this.handleSubmit}>
|
|
||||||
<label>
|
|
||||||
E-mail:<br/>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
name="email"
|
|
||||||
value=${this.state.email}
|
|
||||||
required=${this.props.emailRequired}
|
|
||||||
placeholder=${this.props.emailRequired ? null : "(optional)"}
|
|
||||||
autofocus
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<br/><br/>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
Password:<br/>
|
|
||||||
<input type="password" name="password" value=${this.state.password} required/>
|
|
||||||
</label>
|
|
||||||
<br/><br/>
|
|
||||||
|
|
||||||
<button>Register</button>
|
|
||||||
</form>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,4 +1,4 @@
|
|||||||
import { Component } from "../lib/index.js";
|
import { html, Component } from "../lib/index.js";
|
||||||
|
|
||||||
let store = new Map();
|
let store = new Map();
|
||||||
|
|
||||||
@ -11,10 +11,10 @@ export default class ScrollManager extends Component {
|
|||||||
|
|
||||||
isAtBottom() {
|
isAtBottom() {
|
||||||
let target = this.props.target.current;
|
let target = this.props.target.current;
|
||||||
return Math.abs(target.scrollHeight - target.clientHeight - target.scrollTop) <= 10;
|
return target.scrollTop >= target.scrollHeight - target.offsetHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
saveScrollPosition(scrollKey) {
|
saveScrollPosition() {
|
||||||
let target = this.props.target.current;
|
let target = this.props.target.current;
|
||||||
|
|
||||||
let sticky = target.querySelectorAll(this.props.stickTo);
|
let sticky = target.querySelectorAll(this.props.stickTo);
|
||||||
@ -29,14 +29,11 @@ export default class ScrollManager extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
store.set(scrollKey, stickToKey);
|
store.set(this.props.scrollKey, stickToKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
restoreScrollPosition() {
|
restoreScrollPosition() {
|
||||||
let target = this.props.target.current;
|
let target = this.props.target.current;
|
||||||
if (!target.firstChild) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let stickToKey = store.get(this.props.scrollKey);
|
let stickToKey = store.get(this.props.scrollKey);
|
||||||
if (!stickToKey) {
|
if (!stickToKey) {
|
||||||
@ -48,13 +45,13 @@ export default class ScrollManager extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (target.scrollTop === 0) {
|
if (target.scrollTop == 0) {
|
||||||
this.props.onScrollTop();
|
this.props.onScrollTop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleScroll() {
|
handleScroll() {
|
||||||
if (this.props.target.current.scrollTop === 0) {
|
if (this.props.target.current.scrollTop == 0) {
|
||||||
this.props.onScrollTop();
|
this.props.onScrollTop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -64,9 +61,9 @@ export default class ScrollManager extends Component {
|
|||||||
this.props.target.current.addEventListener("scroll", this.handleScroll);
|
this.props.target.current.addEventListener("scroll", this.handleScroll);
|
||||||
}
|
}
|
||||||
|
|
||||||
getSnapshotBeforeUpdate(prevProps) {
|
componentWillReceiveProps(nextProps) {
|
||||||
if (this.props.scrollKey !== prevProps.scrollKey || this.props.children !== prevProps.children) {
|
if (this.props.scrollKey !== nextProps.scrollKey || this.props.children !== nextProps.children) {
|
||||||
this.saveScrollPosition(prevProps.scrollKey);
|
this.saveScrollPosition();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,7 +76,7 @@ export default class ScrollManager extends Component {
|
|||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
this.props.target.current.removeEventListener("scroll", this.handleScroll);
|
this.props.target.current.removeEventListener("scroll", this.handleScroll);
|
||||||
this.saveScrollPosition(this.props.scrollKey);
|
this.saveScrollPosition();
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
@ -1,112 +0,0 @@
|
|||||||
import { html, Component } from "../lib/index.js";
|
|
||||||
|
|
||||||
export default class SettingsForm extends Component {
|
|
||||||
state = {};
|
|
||||||
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state.secondsInTimestamps = props.settings.secondsInTimestamps;
|
|
||||||
this.state.bufferEvents = props.settings.bufferEvents;
|
|
||||||
|
|
||||||
this.handleInput = this.handleInput.bind(this);
|
|
||||||
this.handleSubmit = this.handleSubmit.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleInput(event) {
|
|
||||||
let target = event.target;
|
|
||||||
let value = target.type === "checkbox" ? target.checked : target.value;
|
|
||||||
this.setState({ [target.name]: value }, () => {
|
|
||||||
this.props.onChange(this.state);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleSubmit(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
this.props.onClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
registerProtocol() {
|
|
||||||
let url = window.location.origin + window.location.pathname + "?open=%s";
|
|
||||||
try {
|
|
||||||
navigator.registerProtocolHandler("irc", url);
|
|
||||||
navigator.registerProtocolHandler("ircs", url);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to register protocol handler: ", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
let protocolHandler = null;
|
|
||||||
if (this.props.showProtocolHandler) {
|
|
||||||
protocolHandler = html`
|
|
||||||
<div class="protocol-handler">
|
|
||||||
<div class="left">
|
|
||||||
Set gamja as your default IRC client for this browser.
|
|
||||||
IRC links will be automatically opened here.
|
|
||||||
</div>
|
|
||||||
<div class="right">
|
|
||||||
<button type="button" onClick=${() => this.registerProtocol()}>
|
|
||||||
Enable
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<br/><br/>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<form onInput=${this.handleInput} onSubmit=${this.handleSubmit}>
|
|
||||||
<label>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="secondsInTimestamps"
|
|
||||||
checked=${this.state.secondsInTimestamps}
|
|
||||||
/>
|
|
||||||
Show seconds in time indicator
|
|
||||||
</label>
|
|
||||||
<br/><br/>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="bufferEvents"
|
|
||||||
value="fold"
|
|
||||||
checked=${this.state.bufferEvents === "fold"}
|
|
||||||
/>
|
|
||||||
Show and fold chat events
|
|
||||||
</label>
|
|
||||||
<br/>
|
|
||||||
<label>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="bufferEvents"
|
|
||||||
value="expand"
|
|
||||||
checked=${this.state.bufferEvents === "expand"}
|
|
||||||
/>
|
|
||||||
Show and expand chat events
|
|
||||||
</label>
|
|
||||||
<br/>
|
|
||||||
<label>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="bufferEvents"
|
|
||||||
value="hide"
|
|
||||||
checked=${this.state.bufferEvents === "hide"}
|
|
||||||
/>
|
|
||||||
Hide chat events
|
|
||||||
</label>
|
|
||||||
<br/><br/>
|
|
||||||
|
|
||||||
${protocolHandler}
|
|
||||||
|
|
||||||
<button type="button" class="danger" onClick=${() => this.props.onDisconnect()}>
|
|
||||||
Disconnect
|
|
||||||
</button>
|
|
||||||
<button>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,171 +0,0 @@
|
|||||||
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>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,45 +0,0 @@
|
|||||||
import { html, Component } from "../lib/index.js";
|
|
||||||
import linkify from "../lib/linkify.js";
|
|
||||||
|
|
||||||
export default class RegisterForm extends Component {
|
|
||||||
state = {
|
|
||||||
code: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.handleInput = this.handleInput.bind(this);
|
|
||||||
this.handleSubmit = this.handleSubmit.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleInput(event) {
|
|
||||||
let target = event.target;
|
|
||||||
let value = target.type === "checkbox" ? target.checked : target.value;
|
|
||||||
this.setState({ [target.name]: value });
|
|
||||||
}
|
|
||||||
|
|
||||||
handleSubmit(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
this.props.onSubmit(this.state.code);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return html`
|
|
||||||
<form onInput=${this.handleInput} onSubmit=${this.handleSubmit}>
|
|
||||||
<p>Your account <strong>${this.props.account}</strong> has been created, but a verification code is required to complete the registration.</p>
|
|
||||||
|
|
||||||
<p>${linkify(this.props.message)}</p>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
Verification code:<br/>
|
|
||||||
<input type="text" name="code" value=${this.state.code} required autofocus autocomplete="off"/>
|
|
||||||
</label>
|
|
||||||
<br/><br/>
|
|
||||||
|
|
||||||
<button>Verify account</button>
|
|
||||||
</form>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,79 +0,0 @@
|
|||||||
import * as http from "http";
|
|
||||||
import * as tls from "tls";
|
|
||||||
import split from "split";
|
|
||||||
import { Server as StaticServer } from "node-static";
|
|
||||||
import { WebSocketServer } from "ws";
|
|
||||||
|
|
||||||
const WS_BAD_GATEWAY = 1014;
|
|
||||||
|
|
||||||
const usage = `usage: [options...] [host]
|
|
||||||
|
|
||||||
Starts an HTTP server delivering static files. If [host] is specified, the
|
|
||||||
server will proxy WebSocket connections to the specified remote IRC server.
|
|
||||||
|
|
||||||
Options:
|
|
||||||
-p <port> Listening port (default: 8080)
|
|
||||||
-h Show help message
|
|
||||||
`;
|
|
||||||
|
|
||||||
let localPort = 8080;
|
|
||||||
let remoteHost;
|
|
||||||
let remotePort = 6697;
|
|
||||||
|
|
||||||
let args = process.argv.slice(2);
|
|
||||||
while (args.length > 0 && args[0].startsWith("-")) {
|
|
||||||
switch (args[0]) {
|
|
||||||
case "-p":
|
|
||||||
localPort = parseInt(args[1], 10);
|
|
||||||
args = args.slice(2);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
console.log(usage);
|
|
||||||
process.exit(args[0] === "-h" ? 0 : 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
remoteHost = args[0];
|
|
||||||
|
|
||||||
let staticServer = new StaticServer(".");
|
|
||||||
|
|
||||||
let server = http.createServer((req, res) => {
|
|
||||||
staticServer.serve(req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (remoteHost) {
|
|
||||||
let wsServer = new WebSocketServer({ server });
|
|
||||||
wsServer.on("connection", (ws) => {
|
|
||||||
let client = tls.connect(remotePort, remoteHost, {
|
|
||||||
ALPNProtocols: ["irc"],
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.on("message", (data) => {
|
|
||||||
client.write(data.toString() + "\r\n");
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.on("close", () => {
|
|
||||||
client.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
client.pipe(split()).on("data", (data) => {
|
|
||||||
ws.send(data.toString());
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on("end", () => {
|
|
||||||
ws.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on("error", (err) => {
|
|
||||||
console.log(err);
|
|
||||||
ws.close(WS_BAD_GATEWAY);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
server.listen(localPort, "localhost");
|
|
||||||
|
|
||||||
let msg = "HTTP server listening on http://localhost:" + localPort;
|
|
||||||
if (remoteHost) {
|
|
||||||
msg += " and proxying WebSockets to " + remoteHost;
|
|
||||||
}
|
|
||||||
console.log(msg);
|
|
@ -1,50 +0,0 @@
|
|||||||
# 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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Errors while parsing the configuration file are logged in the
|
|
||||||
[browser's web console].
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
[browser's web console]: https://firefox-source-docs.mozilla.org/devtools-user/web_console/index.html
|
|
68
doc/setup.md
68
doc/setup.md
@ -1,68 +0,0 @@
|
|||||||
# 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 ws+insecure://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 Host $host;
|
|
||||||
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
|
|
@ -1,15 +0,0 @@
|
|||||||
# URL parameters
|
|
||||||
|
|
||||||
gamja settings can be overridden using URL query parameters:
|
|
||||||
|
|
||||||
- `server`: path or URL to the WebSocket server
|
|
||||||
- `nick`: nickname (if the character `*` appears in the string, it will be
|
|
||||||
replaced with a randomly generated value)
|
|
||||||
- `channels`: comma-separated list of channels to join (`#` needs to be escaped)
|
|
||||||
- `open`: [IRC URL] to open
|
|
||||||
- `debug`: enable debug logs if set to `1`, disable debug logs if set to `0`
|
|
||||||
|
|
||||||
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
|
|
@ -1,56 +0,0 @@
|
|||||||
import globals from "globals";
|
|
||||||
import js from "@eslint/js";
|
|
||||||
import stylisticJs from "@stylistic/eslint-plugin-js";
|
|
||||||
|
|
||||||
export default [
|
|
||||||
{
|
|
||||||
ignores: ["dist/"],
|
|
||||||
},
|
|
||||||
js.configs.recommended,
|
|
||||||
{
|
|
||||||
languageOptions: {
|
|
||||||
globals: {
|
|
||||||
...globals.browser,
|
|
||||||
"process": "readonly",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: { "@stylistic/js": stylisticJs },
|
|
||||||
rules: {
|
|
||||||
"no-case-declarations": "off",
|
|
||||||
"no-unused-vars": ["error", {
|
|
||||||
args: "none",
|
|
||||||
caughtErrorsIgnorePattern: "^_",
|
|
||||||
destructuredArrayIgnorePattern: "^_",
|
|
||||||
}],
|
|
||||||
"no-var": "error",
|
|
||||||
"no-eval": "error",
|
|
||||||
"no-implied-eval": "error",
|
|
||||||
"eqeqeq": "error",
|
|
||||||
"no-invalid-this": "error",
|
|
||||||
"no-extend-native": "error",
|
|
||||||
"prefer-arrow-callback": "error",
|
|
||||||
"no-implicit-globals": "error",
|
|
||||||
"no-throw-literal": "error",
|
|
||||||
"no-implicit-coercion": "warn",
|
|
||||||
"object-shorthand": "warn",
|
|
||||||
"curly": "warn",
|
|
||||||
"camelcase": "warn",
|
|
||||||
"@stylistic/js/indent": ["warn", "tab"],
|
|
||||||
"@stylistic/js/quotes": ["warn", "double"],
|
|
||||||
"@stylistic/js/semi": "warn",
|
|
||||||
"@stylistic/js/brace-style": ["warn", "1tbs"],
|
|
||||||
"@stylistic/js/comma-dangle": ["warn", "always-multiline"],
|
|
||||||
"@stylistic/js/comma-spacing": "warn",
|
|
||||||
"@stylistic/js/arrow-parens": "warn",
|
|
||||||
"@stylistic/js/arrow-spacing": "warn",
|
|
||||||
"@stylistic/js/block-spacing": "warn",
|
|
||||||
"@stylistic/js/object-curly-spacing": ["warn", "always"],
|
|
||||||
"@stylistic/js/object-curly-newline": ["warn", {
|
|
||||||
multiline: true,
|
|
||||||
consistent: true,
|
|
||||||
}],
|
|
||||||
"@stylistic/js/array-bracket-spacing": ["warn", "never"],
|
|
||||||
"@stylistic/js/array-bracket-newline": ["warn", "consistent"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
10
index.html
10
index.html
@ -2,16 +2,20 @@
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; frame-src 'none'; object-src 'none'; connect-src *;">
|
|
||||||
<title>gamja IRC client</title>
|
<title>gamja IRC client</title>
|
||||||
<link rel="stylesheet" href="./style.css">
|
<link rel="stylesheet" href="./style.css">
|
||||||
<script type="module" src="./main.js"></script>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="manifest" href="manifest.json">
|
<link rel="manifest" href="manifest.json">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<noscript>
|
<noscript>
|
||||||
<p>This application requires JavaScript. Please enable it!</p>
|
<p>Unfortunately gamja requires JavaScript. Please enable it!</p>
|
||||||
</noscript>
|
</noscript>
|
||||||
|
<script type="module">
|
||||||
|
import { html, render } from "./lib/index.js";
|
||||||
|
import App from "./components/app.js";
|
||||||
|
|
||||||
|
render(html`<${App}/>`, document.body);
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { ReceiptType, Unread, BufferType, receiptFromMessage } from "./state.js";
|
import { ReceiptType, Unread, BufferType, SERVER_BUFFER } from "./state.js";
|
||||||
|
|
||||||
function getSiblingBuffer(buffers, bufID, delta) {
|
function getSiblingBuffer(buffers, bufID, delta) {
|
||||||
let bufList = Array.from(buffers.values());
|
let bufList = Array.from(buffers.values());
|
||||||
@ -19,29 +19,25 @@ export const keybindings = [
|
|||||||
app.setState((state) => {
|
app.setState((state) => {
|
||||||
let buffers = new Map();
|
let buffers = new Map();
|
||||||
state.buffers.forEach((buf) => {
|
state.buffers.forEach((buf) => {
|
||||||
|
if (buf.messages.length > 0) {
|
||||||
|
let lastMsg = buf.messages[buf.messages.length - 1];
|
||||||
|
app.setReceipt(buf.name, ReceiptType.READ, lastMsg);
|
||||||
|
}
|
||||||
|
|
||||||
buffers.set(buf.id, {
|
buffers.set(buf.id, {
|
||||||
...buf,
|
...buf,
|
||||||
unread: Unread.NONE,
|
unread: Unread.NONE,
|
||||||
prevReadReceipt: null,
|
prevReadReceipt: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
let receipts = {};
|
|
||||||
if (buf.messages.length > 0) {
|
|
||||||
let lastMsg = buf.messages[buf.messages.length - 1];
|
|
||||||
receipts[ReceiptType.READ] = receiptFromMessage(lastMsg);
|
|
||||||
}
|
|
||||||
|
|
||||||
let client = app.clients.get(buf.server);
|
let client = app.clients.get(buf.server);
|
||||||
app.bufferStore.put({
|
app.bufferStore.put({
|
||||||
name: buf.name,
|
name: buf.name,
|
||||||
server: client.params,
|
server: client.params,
|
||||||
unread: Unread.NONE,
|
unread: Unread.NONE,
|
||||||
receipts,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return { buffers };
|
return { buffers };
|
||||||
}, () => {
|
|
||||||
app.updateDocumentTitle();
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -96,14 +92,6 @@ 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) {
|
||||||
@ -121,9 +109,9 @@ export function setup(app) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
candidates = candidates.filter((binding) => {
|
candidates = candidates.filter((binding) => {
|
||||||
return Boolean(binding.altKey) === event.altKey && Boolean(binding.ctrlKey) === event.ctrlKey;
|
return !!binding.altKey == event.altKey && !!binding.ctrlKey == event.ctrlKey;
|
||||||
});
|
});
|
||||||
if (candidates.length !== 1) {
|
if (candidates.length != 1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
26
lib/ansi.js
26
lib/ansi.js
@ -10,26 +10,10 @@ 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++) {
|
||||||
@ -51,7 +35,7 @@ export function strip(text) {
|
|||||||
if (isDigit(text[i + 1])) {
|
if (isDigit(text[i + 1])) {
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
if (text[i + 1] === "," && isDigit(text[i + 2])) {
|
if (text[i + 1] == "," && isDigit(text[i + 2])) {
|
||||||
i += 2;
|
i += 2;
|
||||||
if (isDigit(text[i + 1])) {
|
if (isDigit(text[i + 1])) {
|
||||||
i++;
|
i++;
|
||||||
@ -59,13 +43,7 @@ export function strip(text) {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case COLOR_HEX:
|
case COLOR_HEX:
|
||||||
if (!isHexColor(text.slice(i + 1))) {
|
i += 6;
|
||||||
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;
|
||||||
|
@ -1,42 +0,0 @@
|
|||||||
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
||||||
|
|
||||||
/* The JS world is still in the stone age. We're in 2022 and we still don't
|
|
||||||
* have the technology to correctly base64-encode a UTF-8 string. Can't wait
|
|
||||||
* the next industrial revolution.
|
|
||||||
*
|
|
||||||
* For more info, see:
|
|
||||||
* https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem
|
|
||||||
*/
|
|
||||||
export function encode(data) {
|
|
||||||
if (!window.TextEncoder) {
|
|
||||||
return btoa(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
let encoder = new TextEncoder();
|
|
||||||
let bytes = encoder.encode(data);
|
|
||||||
|
|
||||||
let trailing = bytes.length % 3;
|
|
||||||
let out = "";
|
|
||||||
for (let i = 0; i < bytes.length - trailing; i += 3) {
|
|
||||||
let u24 = (bytes[i] << 16) + (bytes[i + 1] << 8) + bytes[i + 2];
|
|
||||||
out += alphabet[(u24 >> 18) & 0x3F];
|
|
||||||
out += alphabet[(u24 >> 12) & 0x3F];
|
|
||||||
out += alphabet[(u24 >> 6) & 0x3F];
|
|
||||||
out += alphabet[u24 & 0x3F];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trailing === 1) {
|
|
||||||
let u8 = bytes[bytes.length - 1];
|
|
||||||
out += alphabet[u8 >> 2];
|
|
||||||
out += alphabet[(u8 << 4) & 0x3F];
|
|
||||||
out += "==";
|
|
||||||
} else if (trailing === 2) {
|
|
||||||
let u16 = (bytes[bytes.length - 2] << 8) + bytes[bytes.length - 1];
|
|
||||||
out += alphabet[u16 >> 10];
|
|
||||||
out += alphabet[(u16 >> 4) & 0x3F];
|
|
||||||
out += alphabet[(u16 << 2) & 0x3F];
|
|
||||||
out += "=";
|
|
||||||
}
|
|
||||||
|
|
||||||
return out;
|
|
||||||
}
|
|
743
lib/client.js
743
lib/client.js
File diff suppressed because it is too large
Load Diff
@ -4,5 +4,5 @@ import { h } from "../node_modules/preact/dist/preact.module.js";
|
|||||||
import htm from "../node_modules/htm/dist/htm.module.js";
|
import htm from "../node_modules/htm/dist/htm.module.js";
|
||||||
export const html = htm.bind(h);
|
export const html = htm.bind(h);
|
||||||
|
|
||||||
import * as linkifyjs from "../node_modules/linkifyjs/dist/linkify.es.js";
|
import "../node_modules/anchorme/dist/browser/anchorme.min.js";
|
||||||
export { linkifyjs };
|
export const anchorme = window.anchorme;
|
||||||
|
457
lib/irc.js
457
lib/irc.js
@ -1,14 +1,9 @@
|
|||||||
import * as base64 from "./base64.js";
|
|
||||||
|
|
||||||
// RFC 1459
|
// RFC 1459
|
||||||
export const RPL_WELCOME = "001";
|
export const RPL_WELCOME = "001";
|
||||||
export const RPL_YOURHOST = "002";
|
export const RPL_YOURHOST = "002";
|
||||||
export const RPL_CREATED = "003";
|
export const RPL_CREATED = "003";
|
||||||
export const RPL_MYINFO = "004";
|
export const RPL_MYINFO = "004";
|
||||||
export const RPL_ISUPPORT = "005";
|
export const RPL_ISUPPORT = "005";
|
||||||
export const RPL_UMODEIS = "221";
|
|
||||||
export const RPL_TRYAGAIN = "263";
|
|
||||||
export const RPL_AWAY = "301";
|
|
||||||
export const RPL_WHOISUSER = "311";
|
export const RPL_WHOISUSER = "311";
|
||||||
export const RPL_WHOISSERVER = "312";
|
export const RPL_WHOISSERVER = "312";
|
||||||
export const RPL_WHOISOPERATOR = "313";
|
export const RPL_WHOISOPERATOR = "313";
|
||||||
@ -17,6 +12,7 @@ export const RPL_ENDOFWHOIS = "318";
|
|||||||
export const RPL_WHOISCHANNELS = "319";
|
export const RPL_WHOISCHANNELS = "319";
|
||||||
export const RPL_ENDOFWHO = "315";
|
export const RPL_ENDOFWHO = "315";
|
||||||
export const RPL_CHANNELMODEIS = "324";
|
export const RPL_CHANNELMODEIS = "324";
|
||||||
|
export const RPL_CREATIONTIME = "329";
|
||||||
export const RPL_NOTOPIC = "331";
|
export const RPL_NOTOPIC = "331";
|
||||||
export const RPL_TOPIC = "332";
|
export const RPL_TOPIC = "332";
|
||||||
export const RPL_TOPICWHOTIME = "333";
|
export const RPL_TOPICWHOTIME = "333";
|
||||||
@ -34,28 +30,17 @@ export const RPL_ENDOFBANLIST = "368";
|
|||||||
export const RPL_MOTD = "372";
|
export const RPL_MOTD = "372";
|
||||||
export const RPL_MOTDSTART = "375";
|
export const RPL_MOTDSTART = "375";
|
||||||
export const RPL_ENDOFMOTD = "376";
|
export const RPL_ENDOFMOTD = "376";
|
||||||
export const ERR_UNKNOWNERROR = "400";
|
|
||||||
export const ERR_NOSUCHNICK = "401";
|
export const ERR_NOSUCHNICK = "401";
|
||||||
export const ERR_NOSUCHCHANNEL = "403";
|
|
||||||
export const ERR_TOOMANYCHANNELS = "405";
|
|
||||||
export const ERR_UNKNOWNCOMMAND = "421";
|
|
||||||
export const ERR_NOMOTD = "422";
|
export const ERR_NOMOTD = "422";
|
||||||
export const ERR_ERRONEUSNICKNAME = "432";
|
export const ERR_ERRONEUSNICKNAME = "432";
|
||||||
export const ERR_NICKNAMEINUSE = "433";
|
export const ERR_NICKNAMEINUSE = "433";
|
||||||
export const ERR_NICKCOLLISION = "436";
|
export const ERR_NICKCOLLISION = "436";
|
||||||
export const ERR_NEEDMOREPARAMS = "461";
|
|
||||||
export const ERR_NOPERMFORHOST = "463";
|
export const ERR_NOPERMFORHOST = "463";
|
||||||
export const ERR_PASSWDMISMATCH = "464";
|
export const ERR_PASSWDMISMATCH = "464";
|
||||||
export const ERR_YOUREBANNEDCREEP = "465";
|
export const ERR_YOUREBANNEDCREEP = "465";
|
||||||
export const ERR_CHANNELISFULL = "471";
|
|
||||||
export const ERR_INVITEONLYCHAN = "473";
|
|
||||||
export const ERR_BANNEDFROMCHAN = "474";
|
|
||||||
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_QUIETLIST = "728";
|
export const RPL_QUIETLIST = "728";
|
||||||
export const RPL_ENDOFQUIETLIST = "729";
|
export const RPL_ENDOFQUIETLIST = "729";
|
||||||
// IRCv3 MONITOR: https://ircv3.net/specs/extensions/monitor
|
// IRCv3 MONITOR: https://ircv3.net/specs/extensions/monitor
|
||||||
@ -74,24 +59,9 @@ export const ERR_SASLTOOLONG = "905";
|
|||||||
export const ERR_SASLABORTED = "906";
|
export const ERR_SASLABORTED = "906";
|
||||||
export const ERR_SASLALREADY = "907";
|
export const ERR_SASLALREADY = "907";
|
||||||
|
|
||||||
export const STD_MEMBERSHIP_NAMES = {
|
export const STD_MEMBERSHIPS = "~&@%+";
|
||||||
"~": "owner",
|
export const STD_CHANTYPES = "#&+!";
|
||||||
"&": "admin",
|
export const STD_CHANMODES = "beI,k,l,imnst";
|
||||||
"@": "operator",
|
|
||||||
"%": "halfop",
|
|
||||||
"+": "voice",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const STD_MEMBERSHIP_MODES = {
|
|
||||||
"~": "q",
|
|
||||||
"&": "a",
|
|
||||||
"@": "o",
|
|
||||||
"%": "h",
|
|
||||||
"+": "v",
|
|
||||||
};
|
|
||||||
|
|
||||||
const STD_MEMBERSHIPS = "~&@%+";
|
|
||||||
const STD_CHANTYPES = "#&+!";
|
|
||||||
|
|
||||||
const tagEscapeMap = {
|
const tagEscapeMap = {
|
||||||
";": "\\:",
|
";": "\\:",
|
||||||
@ -120,10 +90,10 @@ export function parseTags(s) {
|
|||||||
let parts = s.split("=", 2);
|
let parts = s.split("=", 2);
|
||||||
let k = parts[0];
|
let k = parts[0];
|
||||||
let v = null;
|
let v = null;
|
||||||
if (parts.length === 2) {
|
if (parts.length == 2) {
|
||||||
v = unescapeTag(parts[1]);
|
v = unescapeTag(parts[1]);
|
||||||
if (v.endsWith("\\")) {
|
if (v.endsWith("\\")) {
|
||||||
v = v.slice(0, v.length - 1);
|
v = v.slice(0, v.length - 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tags[k] = v;
|
tags[k] = v;
|
||||||
@ -145,32 +115,38 @@ export function formatTags(tags) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parsePrefix(s) {
|
export function parsePrefix(s) {
|
||||||
let host = null;
|
let prefix = {
|
||||||
|
name: null,
|
||||||
|
user: null,
|
||||||
|
host: null,
|
||||||
|
};
|
||||||
|
|
||||||
let i = s.indexOf("@");
|
let i = s.indexOf("@");
|
||||||
if (i > 0) {
|
if (i < 0) {
|
||||||
host = s.slice(i + 1);
|
prefix.name = s;
|
||||||
s = s.slice(0, i);
|
return prefix;
|
||||||
}
|
}
|
||||||
|
prefix.host = s.slice(i + 1);
|
||||||
|
s = s.slice(0, i);
|
||||||
|
|
||||||
let user = null;
|
|
||||||
i = s.indexOf("!");
|
i = s.indexOf("!");
|
||||||
if (i > 0) {
|
if (i < 0) {
|
||||||
user = s.slice(i + 1);
|
prefix.name = s;
|
||||||
s = s.slice(0, i);
|
return prefix;
|
||||||
}
|
}
|
||||||
|
prefix.name = s.slice(0, i);
|
||||||
return { name: s, user, host };
|
prefix.user = s.slice(i + 1);
|
||||||
|
return prefix;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPrefix(prefix) {
|
function formatPrefix(prefix) {
|
||||||
let s = prefix.name;
|
if (!prefix.host) {
|
||||||
if (prefix.user) {
|
return prefix.name;
|
||||||
s += "!" + prefix.user;
|
|
||||||
}
|
}
|
||||||
if (prefix.host) {
|
if (!prefix.user) {
|
||||||
s += "@" + prefix.host;
|
return prefix.name + "@" + prefix.host;
|
||||||
}
|
}
|
||||||
return s;
|
return prefix.name + "!" + prefix.user + "@" + prefix.host;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseMessage(s) {
|
export function parseMessage(s) {
|
||||||
@ -241,7 +217,7 @@ export function formatMessage(msg) {
|
|||||||
s += msg.command;
|
s += msg.command;
|
||||||
if (msg.params && msg.params.length > 0) {
|
if (msg.params && msg.params.length > 0) {
|
||||||
for (let i = 0; i < msg.params.length - 1; i++) {
|
for (let i = 0; i < msg.params.length - 1; i++) {
|
||||||
s += " " + msg.params[i];
|
s += " " + msg.params[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
let last = String(msg.params[msg.params.length - 1]);
|
let last = String(msg.params[msg.params.length - 1]);
|
||||||
@ -271,12 +247,11 @@ export function parseTargetPrefix(s, allowedPrefixes = STD_MEMBERSHIPS) {
|
|||||||
|
|
||||||
const alphaNum = (() => {
|
const alphaNum = (() => {
|
||||||
try {
|
try {
|
||||||
return new RegExp(/^[\p{L}0-9]$/, "u");
|
return new RegExp(/^\p{L}$/, "u");
|
||||||
} catch (_e) {
|
} catch (e) {
|
||||||
return new RegExp(/^[a-zA-Z0-9]$/, "u");
|
return new RegExp(/^[a-zA-Z0-9]$/, "u");
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
const space = new RegExp(/^\s$/);
|
|
||||||
|
|
||||||
function isWordBoundary(ch) {
|
function isWordBoundary(ch) {
|
||||||
switch (ch) {
|
switch (ch) {
|
||||||
@ -284,44 +259,21 @@ function isWordBoundary(ch) {
|
|||||||
case "_":
|
case "_":
|
||||||
case "|":
|
case "|":
|
||||||
return false;
|
return false;
|
||||||
|
case "\u00A0":
|
||||||
|
return true;
|
||||||
default:
|
default:
|
||||||
return !alphaNum.test(ch);
|
return !alphaNum.test(ch);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isURIPrefix(text) {
|
|
||||||
for (let i = text.length - 1; i >= 0; i--) {
|
|
||||||
if (space.test(text[i])) {
|
|
||||||
text = text.slice(i);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let i = text.indexOf("://");
|
|
||||||
if (i <= 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// See RFC 3986 section 3
|
|
||||||
let ch = text[i - 1];
|
|
||||||
switch (ch) {
|
|
||||||
case "+":
|
|
||||||
case "-":
|
|
||||||
case ".":
|
|
||||||
return true;
|
|
||||||
default:
|
|
||||||
return alphaNum.test(ch);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isHighlight(msg, nick, cm) {
|
export function isHighlight(msg, nick, cm) {
|
||||||
if (msg.command !== "PRIVMSG" && msg.command !== "NOTICE") {
|
if (msg.command != "PRIVMSG" && msg.command != "NOTICE") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
nick = cm(nick);
|
nick = cm(nick);
|
||||||
|
|
||||||
if (msg.prefix && cm(msg.prefix.name) === nick) {
|
if (msg.prefix && cm(msg.prefix.name) == nick) {
|
||||||
return false; // Our own messages aren't highlights
|
return false; // Our own messages aren't highlights
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -337,10 +289,10 @@ export function isHighlight(msg, nick, cm) {
|
|||||||
if (i > 0) {
|
if (i > 0) {
|
||||||
left = text[i - 1];
|
left = text[i - 1];
|
||||||
}
|
}
|
||||||
if (i + nick.length < text.length) {
|
if (i < text.length) {
|
||||||
right = text[i + nick.length];
|
right = text[i + nick.length];
|
||||||
}
|
}
|
||||||
if (isWordBoundary(left) && isWordBoundary(right) && !isURIPrefix(text.slice(0, i))) {
|
if (isWordBoundary(left) && isWordBoundary(right)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -349,7 +301,7 @@ export function isHighlight(msg, nick, cm) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isServerBroadcast(msg) {
|
export function isServerBroadcast(msg) {
|
||||||
if (msg.command !== "PRIVMSG" && msg.command !== "NOTICE") {
|
if (msg.command != "PRIVMSG" && msg.command != "NOTICE") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return msg.params[0].startsWith("$");
|
return msg.params[0].startsWith("$");
|
||||||
@ -387,7 +339,7 @@ export function formatDate(date) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parseCTCP(msg) {
|
export function parseCTCP(msg) {
|
||||||
if (msg.command !== "PRIVMSG" && msg.command !== "NOTICE") {
|
if (msg.command != "PRIVMSG" && msg.command != "NOTICE") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -411,146 +363,28 @@ export function parseCTCP(msg) {
|
|||||||
return ctcp;
|
return ctcp;
|
||||||
}
|
}
|
||||||
|
|
||||||
function unescapeISUPPORTValue(s) {
|
export function parseISUPPORT(tokens, params) {
|
||||||
return s.replace(/\\x[0-9A-Z]{2}/gi, (esc) => {
|
let changed = [];
|
||||||
let hex = esc.slice(2);
|
tokens.forEach((tok) => {
|
||||||
return String.fromCharCode(parseInt(hex, 16));
|
if (tok.startsWith("-")) {
|
||||||
|
let k = tok.slice(1);
|
||||||
|
params.delete(k.toUpperCase());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let i = tok.indexOf("=");
|
||||||
|
let k = tok, v = "";
|
||||||
|
if (i >= 0) {
|
||||||
|
k = tok.slice(0, i);
|
||||||
|
v = tok.slice(i + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
k = k.toUpperCase();
|
||||||
|
|
||||||
|
params.set(k, v);
|
||||||
|
changed.push(k);
|
||||||
});
|
});
|
||||||
}
|
return changed;
|
||||||
|
|
||||||
export class Isupport {
|
|
||||||
raw = new Map();
|
|
||||||
|
|
||||||
parse(tokens) {
|
|
||||||
tokens.forEach((tok) => {
|
|
||||||
if (tok.startsWith("-")) {
|
|
||||||
let k = tok.slice(1);
|
|
||||||
this.raw.delete(k.toUpperCase());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let i = tok.indexOf("=");
|
|
||||||
let k = tok, v = "";
|
|
||||||
if (i >= 0) {
|
|
||||||
k = tok.slice(0, i);
|
|
||||||
v = unescapeISUPPORTValue(tok.slice(i + 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
k = k.toUpperCase();
|
|
||||||
|
|
||||||
this.raw.set(k, v);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
caseMapping() {
|
|
||||||
let name = this.raw.get("CASEMAPPING");
|
|
||||||
if (!name) {
|
|
||||||
return CaseMapping.RFC1459;
|
|
||||||
}
|
|
||||||
let cm = CaseMapping.byName(name);
|
|
||||||
if (!cm) {
|
|
||||||
console.error("Unsupported case-mapping '" + name + "', falling back to RFC 1459");
|
|
||||||
return CaseMapping.RFC1459;
|
|
||||||
}
|
|
||||||
return cm;
|
|
||||||
}
|
|
||||||
|
|
||||||
monitor() {
|
|
||||||
if (!this.raw.has("MONITOR")) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
let v = this.raw.get("MONITOR");
|
|
||||||
if (v === "") {
|
|
||||||
return Infinity;
|
|
||||||
}
|
|
||||||
return parseInt(v, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
whox() {
|
|
||||||
return this.raw.has("WHOX");
|
|
||||||
}
|
|
||||||
|
|
||||||
prefix() {
|
|
||||||
return this.raw.get("PREFIX") || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
chanTypes() {
|
|
||||||
return this.raw.get("CHANTYPES") || STD_CHANTYPES;
|
|
||||||
}
|
|
||||||
|
|
||||||
statusMsg() {
|
|
||||||
return this.raw.get("STATUSMSG");
|
|
||||||
}
|
|
||||||
|
|
||||||
network() {
|
|
||||||
return this.raw.get("NETWORK");
|
|
||||||
}
|
|
||||||
|
|
||||||
chatHistory() {
|
|
||||||
if (!this.raw.has("CHATHISTORY")) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
let n = parseInt(this.raw.get("CHATHISTORY"), 10);
|
|
||||||
if (n <= 0) {
|
|
||||||
return Infinity;
|
|
||||||
}
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
bouncerNetID() {
|
|
||||||
return this.raw.get("BOUNCER_NETID");
|
|
||||||
}
|
|
||||||
|
|
||||||
chanModes() {
|
|
||||||
const stdChanModes = ["beI", "k", "l", "imnst"];
|
|
||||||
if (!this.raw.has("CHANMODES")) {
|
|
||||||
return stdChanModes;
|
|
||||||
}
|
|
||||||
let chanModes = this.raw.get("CHANMODES").split(",");
|
|
||||||
if (chanModes.length !== 4) {
|
|
||||||
console.error("Invalid CHANMODES: ", this.raw.get("CHANMODES"));
|
|
||||||
return stdChanModes;
|
|
||||||
}
|
|
||||||
return chanModes;
|
|
||||||
}
|
|
||||||
|
|
||||||
bot() {
|
|
||||||
return this.raw.get("BOT");
|
|
||||||
}
|
|
||||||
|
|
||||||
userLen() {
|
|
||||||
if (!this.raw.has("USERLEN")) {
|
|
||||||
return 20;
|
|
||||||
}
|
|
||||||
return parseInt(this.raw.get("USERLEN"), 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
hostLen() {
|
|
||||||
if (!this.raw.has("HOSTLEN")) {
|
|
||||||
return 63;
|
|
||||||
}
|
|
||||||
return parseInt(this.raw.get("HOSTLEN"), 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
lineLen() {
|
|
||||||
if (!this.raw.has("LINELEN")) {
|
|
||||||
return 512;
|
|
||||||
}
|
|
||||||
return parseInt(this.raw.get("LINELEN"), 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
filehost() {
|
|
||||||
return this.raw.get("SOJU.IM/FILEHOST");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = {
|
||||||
@ -572,13 +406,13 @@ export const CaseMapping = {
|
|||||||
let ch = str[i];
|
let ch = str[i];
|
||||||
if ("A" <= ch && ch <= "Z") {
|
if ("A" <= ch && ch <= "Z") {
|
||||||
ch = ch.toLowerCase();
|
ch = ch.toLowerCase();
|
||||||
} else if (ch === "{") {
|
} else if (ch == "{") {
|
||||||
ch = "[";
|
ch = "[";
|
||||||
} else if (ch === "}") {
|
} else if (ch == "}") {
|
||||||
ch = "]";
|
ch = "]";
|
||||||
} else if (ch === "\\") {
|
} else if (ch == "\\") {
|
||||||
ch = "|";
|
ch = "|";
|
||||||
} else if (ch === "~") {
|
} else if (ch == "~") {
|
||||||
ch = "^";
|
ch = "^";
|
||||||
}
|
}
|
||||||
out += ch;
|
out += ch;
|
||||||
@ -592,11 +426,11 @@ export const CaseMapping = {
|
|||||||
let ch = str[i];
|
let ch = str[i];
|
||||||
if ("A" <= ch && ch <= "Z") {
|
if ("A" <= ch && ch <= "Z") {
|
||||||
ch = ch.toLowerCase();
|
ch = ch.toLowerCase();
|
||||||
} else if (ch === "{") {
|
} else if (ch == "{") {
|
||||||
ch = "[";
|
ch = "[";
|
||||||
} else if (ch === "}") {
|
} else if (ch == "}") {
|
||||||
ch = "]";
|
ch = "]";
|
||||||
} else if (ch === "\\") {
|
} else if (ch == "\\") {
|
||||||
ch = "|";
|
ch = "|";
|
||||||
}
|
}
|
||||||
out += ch;
|
out += ch;
|
||||||
@ -759,10 +593,11 @@ export function getMessageLabel(msg) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function forEachChannelModeUpdate(msg, isupport, callback) {
|
export function forEachChannelModeUpdate(msg, isupport, callback) {
|
||||||
let [a, b, c, d] = isupport.chanModes();
|
let chanmodes = isupport.get("CHANMODES") || STD_CHANMODES;
|
||||||
let prefix = isupport.prefix();
|
let prefix = isupport.get("PREFIX") || "";
|
||||||
|
|
||||||
let typeByMode = new Map();
|
let typeByMode = new Map();
|
||||||
|
let [a, b, c, d] = chanmodes.split(",");
|
||||||
Array.from(a).forEach((mode) => typeByMode.set(mode, "A"));
|
Array.from(a).forEach((mode) => typeByMode.set(mode, "A"));
|
||||||
Array.from(b).forEach((mode) => typeByMode.set(mode, "B"));
|
Array.from(b).forEach((mode) => typeByMode.set(mode, "B"));
|
||||||
Array.from(c).forEach((mode) => typeByMode.set(mode, "C"));
|
Array.from(c).forEach((mode) => typeByMode.set(mode, "C"));
|
||||||
@ -805,8 +640,6 @@ export function forEachChannelModeUpdate(msg, isupport, callback) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a realname is worth displaying.
|
|
||||||
*
|
|
||||||
* Since the realname is mandatory, many clients set a meaningless realname.
|
* Since the realname is mandatory, many clients set a meaningless realname.
|
||||||
*/
|
*/
|
||||||
export function isMeaningfulRealname(realname, nick) {
|
export function isMeaningfulRealname(realname, nick) {
|
||||||
@ -814,7 +647,7 @@ export function isMeaningfulRealname(realname, nick) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (realname.toLowerCase() === "realname" || realname.toLowerCase() === "unknown" || realname.toLowerCase() === "fullname") {
|
if (realname.toLowerCase() === "realname") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -822,151 +655,3 @@ export function isMeaningfulRealname(realname, nick) {
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Parse an irc:// URL.
|
|
||||||
*
|
|
||||||
* See: https://datatracker.ietf.org/doc/html/draft-butcher-irc-url-04
|
|
||||||
*/
|
|
||||||
export function parseURL(str) {
|
|
||||||
if (!str.startsWith("irc://") && !str.startsWith("ircs://")) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
str = str.slice(str.indexOf(":") + "://".length);
|
|
||||||
|
|
||||||
let loc;
|
|
||||||
let i = str.indexOf("/");
|
|
||||||
if (i < 0) {
|
|
||||||
loc = str;
|
|
||||||
str = "";
|
|
||||||
} else {
|
|
||||||
loc = str.slice(0, i);
|
|
||||||
str = str.slice(i + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
let host = loc;
|
|
||||||
i = loc.indexOf("@");
|
|
||||||
if (i >= 0) {
|
|
||||||
host = loc.slice(i + 1);
|
|
||||||
// TODO: parse authinfo
|
|
||||||
}
|
|
||||||
|
|
||||||
i = str.indexOf("?");
|
|
||||||
if (i >= 0) {
|
|
||||||
str = str.slice(0, i);
|
|
||||||
// TODO: parse options
|
|
||||||
}
|
|
||||||
|
|
||||||
let enttype;
|
|
||||||
i = str.indexOf(",");
|
|
||||||
if (i >= 0) {
|
|
||||||
let flags = str.slice(i + 1).split(",");
|
|
||||||
str = str.slice(0, i);
|
|
||||||
|
|
||||||
if (flags.indexOf("isuser") >= 0) {
|
|
||||||
enttype = "user";
|
|
||||||
} else if (flags.indexOf("ischannel") >= 0) {
|
|
||||||
enttype = "channel";
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: parse hosttype
|
|
||||||
}
|
|
||||||
|
|
||||||
let entity = decodeURIComponent(str);
|
|
||||||
if (!enttype) {
|
|
||||||
// TODO: technically we should use the PREFIX ISUPPORT here
|
|
||||||
enttype = entity.startsWith("#") ? "channel" : "user";
|
|
||||||
}
|
|
||||||
|
|
||||||
return { host, enttype, entity };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatURL({ host, enttype, entity } = {}) {
|
|
||||||
host = host || "";
|
|
||||||
entity = entity || "";
|
|
||||||
|
|
||||||
let s = "irc://" + host + "/" + encodeURIComponent(entity);
|
|
||||||
if (enttype) {
|
|
||||||
s += ",is" + enttype;
|
|
||||||
}
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CapRegistry {
|
|
||||||
available = new Map();
|
|
||||||
enabled = new Set();
|
|
||||||
|
|
||||||
addAvailable(s) {
|
|
||||||
let l = s.split(" ");
|
|
||||||
l.forEach((s) => {
|
|
||||||
let i = s.indexOf("=");
|
|
||||||
let k = s, v = "";
|
|
||||||
if (i >= 0) {
|
|
||||||
k = s.slice(0, i);
|
|
||||||
v = s.slice(i + 1);
|
|
||||||
}
|
|
||||||
this.available.set(k.toLowerCase(), v);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
parse(msg) {
|
|
||||||
if (msg.command !== "CAP") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let subCmd = msg.params[1];
|
|
||||||
let args = msg.params.slice(2);
|
|
||||||
switch (subCmd) {
|
|
||||||
case "LS":
|
|
||||||
this.addAvailable(args[args.length - 1]);
|
|
||||||
break;
|
|
||||||
case "NEW":
|
|
||||||
this.addAvailable(args[0]);
|
|
||||||
break;
|
|
||||||
case "DEL":
|
|
||||||
args[0].split(" ").forEach((cap) => {
|
|
||||||
cap = cap.toLowerCase();
|
|
||||||
this.available.delete(cap);
|
|
||||||
this.enabled.delete(cap);
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case "ACK":
|
|
||||||
args[0].split(" ").forEach((cap) => {
|
|
||||||
cap = cap.toLowerCase();
|
|
||||||
if (cap.startsWith("-")) {
|
|
||||||
this.enabled.delete(cap.slice(1));
|
|
||||||
} else {
|
|
||||||
this.enabled.add(cap);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
requestAvailable(l) {
|
|
||||||
l = l.filter((cap) => {
|
|
||||||
return this.available.has(cap) && !this.enabled.has(cap);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (l.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return { command: "CAP", params: ["REQ", l.join(" ")] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxSASLLength = 400;
|
|
||||||
|
|
||||||
export function generateAuthenticateMessages(payload) {
|
|
||||||
let encoded = base64.encode(payload);
|
|
||||||
|
|
||||||
// <= instead of < because we need to send a final empty response if the
|
|
||||||
// last chunk is exactly 400 bytes long
|
|
||||||
let msgs = [];
|
|
||||||
for (let i = 0; i <= encoded.length; i += maxSASLLength) {
|
|
||||||
let chunk = encoded.substring(i, i + maxSASLLength);
|
|
||||||
msgs.push({ command: "AUTHENTICATE", params: [chunk || "+"] });
|
|
||||||
}
|
|
||||||
|
|
||||||
return msgs;
|
|
||||||
}
|
|
||||||
|
@ -1,65 +1,65 @@
|
|||||||
import { linkifyjs, html } from "./index.js";
|
import { anchorme, html } from "./index.js";
|
||||||
|
|
||||||
linkifyjs.options.defaults.defaultProtocol = "https";
|
function linkifyChannel(text, transformChannel) {
|
||||||
|
// Don't match punctuation at the end of the channel name
|
||||||
|
const channelRegex = /(^|\s)(#[^\s]+[^\s.?!…():;,])/gi;
|
||||||
|
|
||||||
linkifyjs.registerCustomProtocol("irc");
|
let children = [];
|
||||||
linkifyjs.registerCustomProtocol("ircs");
|
let match;
|
||||||
linkifyjs.registerCustomProtocol("geo", true);
|
let last = 0;
|
||||||
|
while ((match = channelRegex.exec(text)) !== null) {
|
||||||
|
let channel = match[2];
|
||||||
|
let start = match.index + match[1].length;
|
||||||
|
let end = start + match[2].length;
|
||||||
|
|
||||||
const IRCChannelToken = linkifyjs.createTokenClass("ircChannel", {
|
children.push(text.substring(last, start));
|
||||||
isLink: true,
|
children.push(transformChannel(channel));
|
||||||
toHref() {
|
|
||||||
return "irc:///" + this.v;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
linkifyjs.registerPlugin("ircChannel", ({ scanner, parser }) => {
|
last = end;
|
||||||
const { POUND, UNDERSCORE, DOT, HYPHEN } = scanner.tokens;
|
}
|
||||||
const { alphanumeric } = scanner.tokens.groups;
|
children.push(text.substring(last));
|
||||||
|
|
||||||
const Prefix = parser.start.tt(POUND);
|
return children;
|
||||||
const Channel = new linkifyjs.State(IRCChannelToken);
|
}
|
||||||
const Divider = Channel.tt(DOT);
|
|
||||||
|
|
||||||
Prefix.ta(alphanumeric, Channel);
|
export default function linkify(text, onChannelClick) {
|
||||||
Prefix.tt(POUND, Channel);
|
function transformChannel(channel) {
|
||||||
Prefix.tt(UNDERSCORE, Channel);
|
function onClick(event) {
|
||||||
Prefix.tt(DOT, Divider);
|
event.preventDefault();
|
||||||
Prefix.tt(HYPHEN, Channel);
|
onChannelClick(channel);
|
||||||
Channel.ta(alphanumeric, Channel);
|
}
|
||||||
Channel.tt(POUND, Channel);
|
return html`
|
||||||
Channel.tt(UNDERSCORE, Channel);
|
<a
|
||||||
Channel.tt(HYPHEN, Channel);
|
href="irc:///${encodeURIComponent(channel)}"
|
||||||
Divider.ta(alphanumeric, Channel);
|
onClick=${onClick}
|
||||||
});
|
>${channel}</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function linkify(text, onClick) {
|
let links = anchorme.list(text);
|
||||||
let links = linkifyjs.find(text);
|
|
||||||
|
|
||||||
let children = [];
|
let children = [];
|
||||||
let last = 0;
|
let last = 0;
|
||||||
links.forEach((match) => {
|
links.forEach((match) => {
|
||||||
if (!match.isLink) {
|
const prefix = text.substring(last, match.start)
|
||||||
return;
|
children.push(...linkifyChannel(prefix, transformChannel));
|
||||||
|
|
||||||
|
let proto = match.protocol || "https://";
|
||||||
|
if (match.isEmail) {
|
||||||
|
proto = "mailto:";
|
||||||
}
|
}
|
||||||
|
|
||||||
const prefix = text.substring(last, match.start);
|
let url = match.string;
|
||||||
children.push(prefix);
|
if (!url.startsWith(proto)) {
|
||||||
|
url = proto + url;
|
||||||
|
}
|
||||||
|
|
||||||
children.push(html`
|
children.push(html`<a href=${url} target="_blank" rel="noreferrer noopener">${match.string}</a>`);
|
||||||
<a
|
|
||||||
href=${match.href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer noopener"
|
|
||||||
onClick=${onClick}
|
|
||||||
>${match.value}</a>
|
|
||||||
`);
|
|
||||||
|
|
||||||
last = match.end;
|
last = match.end;
|
||||||
});
|
});
|
||||||
|
|
||||||
const suffix = text.substring(last);
|
const suffix = text.substring(last)
|
||||||
children.push(suffix);
|
children.push(...linkifyChannel(suffix, transformChannel));
|
||||||
|
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
109
lib/oauth2.js
109
lib/oauth2.js
@ -1,109 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
4
main.js
4
main.js
@ -1,4 +0,0 @@
|
|||||||
import { html, render } from "./lib/index.js";
|
|
||||||
import App from "./components/app.js";
|
|
||||||
|
|
||||||
render(html`<${App}/>`, document.body);
|
|
4785
package-lock.json
generated
4785
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
32
package.json
32
package.json
@ -1,37 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "gamja",
|
"name": "gamja",
|
||||||
"type": "module",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"anchorme": "^2.1.2",
|
||||||
"htm": "^3.0.4",
|
"htm": "^3.0.4",
|
||||||
"linkifyjs": "^4.1.3",
|
"preact": "^10.5.9"
|
||||||
"preact": "^10.17.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.11.1",
|
"http-server": "^13.0.2"
|
||||||
"@parcel/packager-raw-url": "^2.0.0",
|
|
||||||
"@parcel/transformer-webmanifest": "^2.0.0",
|
|
||||||
"@stylistic/eslint-plugin-js": "^4.2.0",
|
|
||||||
"eslint": "^9.11.1",
|
|
||||||
"globals": "^16.0.0",
|
|
||||||
"node-static": "^0.7.11",
|
|
||||||
"parcel": "^2.0.0",
|
|
||||||
"split": "^1.0.1",
|
|
||||||
"ws": "^8.3.0"
|
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node ./dev-server.js",
|
"start": "http-server ."
|
||||||
"build": "parcel build",
|
|
||||||
"lint": "eslint"
|
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true
|
||||||
"targets": {
|
|
||||||
"default": {
|
|
||||||
"source": "index.html",
|
|
||||||
"context": "browser",
|
|
||||||
"publicUrl": "."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.13.0"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
342
state.js
342
state.js
@ -1,6 +1,5 @@
|
|||||||
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 { createContext } from "./lib/index.js";
|
|
||||||
|
|
||||||
export const SERVER_BUFFER = "*";
|
export const SERVER_BUFFER = "*";
|
||||||
|
|
||||||
@ -35,22 +34,22 @@ export const ReceiptType = {
|
|||||||
READ: "read",
|
READ: "read",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BufferEventsDisplayMode = {
|
export function getNickURL(nick) {
|
||||||
FOLD: "fold",
|
return "irc:///" + encodeURIComponent(nick) + ",isuser";
|
||||||
EXPAND: "expand",
|
}
|
||||||
HIDE: "hide",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SettingsContext = createContext("settings");
|
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.formatURL();
|
return "irc:///";
|
||||||
case BufferType.CHANNEL:
|
case BufferType.CHANNEL:
|
||||||
return irc.formatURL({ entity: buf.name });
|
return getChannelURL(buf.name);
|
||||||
case BufferType.NICK:
|
case BufferType.NICK:
|
||||||
return irc.formatURL({ entity: buf.name, enttype: "user" });
|
return getNickURL(buf.name);
|
||||||
}
|
}
|
||||||
throw new Error("Unknown buffer type: " + buf.type);
|
throw new Error("Unknown buffer type: " + buf.type);
|
||||||
}
|
}
|
||||||
@ -64,62 +63,20 @@ export function getMessageURL(buf, msg) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getServerName(server, bouncerNetwork) {
|
export function getServerName(server, bouncerNetwork, isBouncer) {
|
||||||
let netName = server.name;
|
if (bouncerNetwork && bouncerNetwork.name) {
|
||||||
|
|
||||||
if (bouncerNetwork && bouncerNetwork.name && bouncerNetwork.name !== bouncerNetwork.host) {
|
|
||||||
// User has picked a custom name for the network, use that
|
|
||||||
return bouncerNetwork.name;
|
return bouncerNetwork.name;
|
||||||
}
|
}
|
||||||
|
if (isBouncer) {
|
||||||
|
return "bouncer";
|
||||||
|
}
|
||||||
|
|
||||||
|
let netName = server.isupport.get("NETWORK");
|
||||||
if (netName) {
|
if (netName) {
|
||||||
// Server has specified a name
|
|
||||||
return netName;
|
return netName;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bouncerNetwork) {
|
return "server";
|
||||||
return bouncerNetwork.name || bouncerNetwork.host || "server";
|
|
||||||
} else if (server.isBouncer) {
|
|
||||||
return "bouncer";
|
|
||||||
} else {
|
|
||||||
return "server";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function receiptFromMessage(msg) {
|
|
||||||
// At this point all messages are supposed to have a time tag.
|
|
||||||
// App.addMessage ensures this is the case even if the server doesn't
|
|
||||||
// support server-time.
|
|
||||||
if (!msg.tags.time) {
|
|
||||||
throw new Error("Missing time message tag");
|
|
||||||
}
|
|
||||||
return { time: msg.tags.time };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isReceiptBefore(a, b) {
|
|
||||||
if (!b) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!a) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (!a.time || !b.time) {
|
|
||||||
throw new Error("Missing receipt time");
|
|
||||||
}
|
|
||||||
return a.time <= b.time;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isMessageBeforeReceipt(msg, receipt) {
|
|
||||||
if (!receipt) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!msg.tags.time) {
|
|
||||||
throw new Error("Missing time message tag");
|
|
||||||
}
|
|
||||||
if (!receipt.time) {
|
|
||||||
throw new Error("Missing receipt time");
|
|
||||||
}
|
|
||||||
return msg.tags.time <= receipt.time;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateState(state, updater) {
|
function updateState(state, updater) {
|
||||||
@ -136,63 +93,26 @@ function updateState(state, updater) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isServerBuffer(buf) {
|
function isServerBuffer(buf) {
|
||||||
return buf.type === BufferType.SERVER;
|
return buf.type == BufferType.SERVER;
|
||||||
}
|
|
||||||
|
|
||||||
function isChannelBuffer(buf) {
|
|
||||||
return buf.type === BufferType.CHANNEL;
|
|
||||||
}
|
|
||||||
|
|
||||||
function trimStartCharacter(s, c) {
|
|
||||||
let i = 0;
|
|
||||||
for (; i < s.length; ++i) {
|
|
||||||
if (s[i] !== c) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return s.substring(i);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBouncerNetworkNameFromBuffer(state, buffer) {
|
|
||||||
let server = state.servers.get(buffer.server);
|
|
||||||
let network = state.bouncerNetworks.get(server.bouncerNetID);
|
|
||||||
if (!network) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return getServerName(server, network);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Returns 1 if a should appear after b, -1 if a should appear before b, or
|
/* Returns 1 if a should appear after b, -1 if a should appear before b, or
|
||||||
* 0 otherwise. */
|
* 0 otherwise. */
|
||||||
function compareBuffers(state, a, b) {
|
function compareBuffers(a, b) {
|
||||||
if (a.server !== b.server) {
|
if (a.server != b.server) {
|
||||||
let aServerName = getBouncerNetworkNameFromBuffer(state, a);
|
|
||||||
let bServerName = getBouncerNetworkNameFromBuffer(state, b);
|
|
||||||
if (aServerName && bServerName && aServerName !== bServerName) {
|
|
||||||
return aServerName.localeCompare(bServerName);
|
|
||||||
}
|
|
||||||
return a.server > b.server ? 1 : -1;
|
return a.server > b.server ? 1 : -1;
|
||||||
}
|
}
|
||||||
if (isServerBuffer(a) !== isServerBuffer(b)) {
|
if (isServerBuffer(a) != isServerBuffer(b)) {
|
||||||
return isServerBuffer(b) ? 1 : -1;
|
return isServerBuffer(b) ? 1 : -1;
|
||||||
}
|
}
|
||||||
|
if (a.name != b.name) {
|
||||||
if (isChannelBuffer(a) && isChannelBuffer(b)) {
|
return a.name > b.name ? 1 : -1;
|
||||||
const strippedA = trimStartCharacter(a.name, a.name[0]);
|
|
||||||
const strippedB = trimStartCharacter(b.name, b.name[0]);
|
|
||||||
const cmp = strippedA.localeCompare(strippedB);
|
|
||||||
|
|
||||||
if (cmp !== 0) {
|
|
||||||
return cmp;
|
|
||||||
}
|
|
||||||
// if they are the same when stripped, fallthough to default logic
|
|
||||||
}
|
}
|
||||||
|
return 0;
|
||||||
return a.name.localeCompare(b.name);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateMembership(membership, letter, add, client) {
|
function updateMembership(membership, letter, add, client) {
|
||||||
let prefix = client.isupport.prefix();
|
let prefix = client.isupport.get("PREFIX") || "";
|
||||||
|
|
||||||
let prefixPrivs = new Map(irc.parseMembershipModes(prefix).map((membership, i) => {
|
let prefixPrivs = new Map(irc.parseMembershipModes(prefix).map((membership, i) => {
|
||||||
return [membership.prefix, i];
|
return [membership.prefix, i];
|
||||||
@ -215,9 +135,9 @@ function updateMembership(membership, letter, add, client) {
|
|||||||
|
|
||||||
/* Insert a message in an immutable list of sorted messages. */
|
/* Insert a message in an immutable list of sorted messages. */
|
||||||
function insertMessage(list, msg) {
|
function insertMessage(list, msg) {
|
||||||
if (list.length === 0) {
|
if (list.length == 0) {
|
||||||
return [msg];
|
return [msg];
|
||||||
} else if (!irc.findBatchByType(msg, "chathistory") || list[list.length - 1].tags.time <= msg.tags.time) {
|
} else if (list[list.length - 1].tags.time <= msg.tags.time) {
|
||||||
return list.concat(msg);
|
return list.concat(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -231,7 +151,7 @@ function insertMessage(list, msg) {
|
|||||||
}
|
}
|
||||||
console.assert(insertBefore >= 0, "");
|
console.assert(insertBefore >= 0, "");
|
||||||
|
|
||||||
list = [...list];
|
list = [ ...list ];
|
||||||
list.splice(insertBefore, 0, msg);
|
list.splice(insertBefore, 0, msg);
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
@ -246,11 +166,6 @@ export const State = {
|
|||||||
servers: new Map(),
|
servers: new Map(),
|
||||||
buffers: new Map(),
|
buffers: new Map(),
|
||||||
activeBuffer: null,
|
activeBuffer: null,
|
||||||
bouncerNetworks: new Map(),
|
|
||||||
settings: {
|
|
||||||
secondsInTimestamps: true,
|
|
||||||
bufferEvents: BufferEventsDisplayMode.FOLD,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
updateServer(state, id, updater) {
|
updateServer(state, id, updater) {
|
||||||
@ -310,7 +225,7 @@ export const State = {
|
|||||||
let cm = irc.CaseMapping.RFC1459;
|
let cm = irc.CaseMapping.RFC1459;
|
||||||
let server = state.servers.get(serverID);
|
let server = state.servers.get(serverID);
|
||||||
if (server) {
|
if (server) {
|
||||||
cm = server.cm;
|
cm = irc.CaseMapping.byName(server.isupport.get("CASEMAPPING")) || cm;
|
||||||
}
|
}
|
||||||
|
|
||||||
let nameCM = cm(name);
|
let nameCM = cm(name);
|
||||||
@ -331,17 +246,9 @@ export const State = {
|
|||||||
let servers = new Map(state.servers);
|
let servers = new Map(state.servers);
|
||||||
servers.set(id, {
|
servers.set(id, {
|
||||||
id,
|
id,
|
||||||
name: null, // from ISUPPORT NETWORK
|
|
||||||
status: ServerStatus.DISCONNECTED,
|
status: ServerStatus.DISCONNECTED,
|
||||||
cm: irc.CaseMapping.RFC1459,
|
isupport: new Map(),
|
||||||
users: new irc.CaseMapMap(null, irc.CaseMapping.RFC1459),
|
users: new irc.CaseMapMap(null, irc.CaseMapping.RFC1459),
|
||||||
account: null,
|
|
||||||
supportsSASLPlain: false,
|
|
||||||
supportsAccountRegistration: false,
|
|
||||||
reliableUserAccounts: false,
|
|
||||||
statusMsg: null, // from ISUPPORT STATUSMSG
|
|
||||||
isBouncer: false,
|
|
||||||
bouncerNetID: null,
|
|
||||||
});
|
});
|
||||||
return [id, { servers }];
|
return [id, { servers }];
|
||||||
},
|
},
|
||||||
@ -355,7 +262,7 @@ export const State = {
|
|||||||
let id = lastBufferID;
|
let id = lastBufferID;
|
||||||
|
|
||||||
let type;
|
let type;
|
||||||
if (name === SERVER_BUFFER) {
|
if (name == SERVER_BUFFER) {
|
||||||
type = BufferType.SERVER;
|
type = BufferType.SERVER;
|
||||||
} else if (client.isChannel(name)) {
|
} else if (client.isChannel(name)) {
|
||||||
type = BufferType.CHANNEL;
|
type = BufferType.CHANNEL;
|
||||||
@ -370,32 +277,16 @@ export const State = {
|
|||||||
type,
|
type,
|
||||||
server: serverID,
|
server: serverID,
|
||||||
serverInfo: null, // if server
|
serverInfo: null, // if server
|
||||||
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: [],
|
||||||
redacted: new Set(),
|
|
||||||
unread: Unread.NONE,
|
unread: Unread.NONE,
|
||||||
prevReadReceipt: null,
|
prevReadReceipt: null,
|
||||||
});
|
});
|
||||||
bufferList = bufferList.sort((a, b) => compareBuffers(state, a, b));
|
bufferList = bufferList.sort(compareBuffers);
|
||||||
let buffers = new Map(bufferList.map((buf) => [buf.id, buf]));
|
let buffers = new Map(bufferList.map((buf) => [buf.id, buf]));
|
||||||
return [id, { buffers }];
|
return [id, { buffers }];
|
||||||
},
|
},
|
||||||
storeBouncerNetwork(state, id, attrs) {
|
|
||||||
let bouncerNetworks = new Map(state.bouncerNetworks);
|
|
||||||
bouncerNetworks.set(id, {
|
|
||||||
...bouncerNetworks.get(id),
|
|
||||||
...attrs,
|
|
||||||
});
|
|
||||||
return { bouncerNetworks };
|
|
||||||
},
|
|
||||||
deleteBouncerNetwork(state, id) {
|
|
||||||
let bouncerNetworks = new Map(state.bouncerNetworks);
|
|
||||||
bouncerNetworks.delete(id);
|
|
||||||
return { bouncerNetworks };
|
|
||||||
},
|
|
||||||
handleMessage(state, msg, serverID, client) {
|
handleMessage(state, msg, serverID, client) {
|
||||||
function updateServer(updater) {
|
function updateServer(updater) {
|
||||||
return State.updateServer(state, serverID, updater);
|
return State.updateServer(state, serverID, updater);
|
||||||
@ -420,7 +311,7 @@ export const State = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let target, channel, topic, targets, who, update, buffers;
|
let target, channel, topic, targets, who;
|
||||||
switch (msg.command) {
|
switch (msg.command) {
|
||||||
case irc.RPL_MYINFO:
|
case irc.RPL_MYINFO:
|
||||||
// TODO: parse available modes
|
// TODO: parse available modes
|
||||||
@ -430,9 +321,9 @@ export const State = {
|
|||||||
};
|
};
|
||||||
return updateBuffer(SERVER_BUFFER, { serverInfo });
|
return updateBuffer(SERVER_BUFFER, { serverInfo });
|
||||||
case irc.RPL_ISUPPORT:
|
case irc.RPL_ISUPPORT:
|
||||||
buffers = new Map(state.buffers);
|
let buffers = new Map(state.buffers);
|
||||||
state.buffers.forEach((buf) => {
|
state.buffers.forEach((buf) => {
|
||||||
if (buf.server !== serverID) {
|
if (buf.server != serverID) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let members = new irc.CaseMapMap(buf.members, client.cm);
|
let members = new irc.CaseMapMap(buf.members, client.cm);
|
||||||
@ -442,31 +333,11 @@ export const State = {
|
|||||||
buffers,
|
buffers,
|
||||||
...updateServer((server) => {
|
...updateServer((server) => {
|
||||||
return {
|
return {
|
||||||
name: client.isupport.network(),
|
isupport: new Map(client.isupport),
|
||||||
cm: client.cm,
|
|
||||||
users: new irc.CaseMapMap(server.users, client.cm),
|
users: new irc.CaseMapMap(server.users, client.cm),
|
||||||
reliableUserAccounts: client.isupport.monitor() > 0 && client.isupport.whox(),
|
|
||||||
statusMsg: client.isupport.statusMsg(),
|
|
||||||
bouncerNetID: client.isupport.bouncerNetID(),
|
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
case "CAP":
|
|
||||||
return updateServer({
|
|
||||||
supportsSASLPlain: client.supportsSASL("PLAIN"),
|
|
||||||
supportsAccountRegistration: client.caps.enabled.has("draft/account-registration"),
|
|
||||||
isBouncer: client.caps.enabled.has("soju.im/bouncer-networks"),
|
|
||||||
});
|
|
||||||
case irc.RPL_LOGGEDIN:
|
|
||||||
return updateServer({ account: msg.params[2] });
|
|
||||||
case irc.RPL_LOGGEDOUT:
|
|
||||||
return updateServer({ account: null });
|
|
||||||
case "REGISTER":
|
|
||||||
case "VERIFY":
|
|
||||||
if (msg.params[0] === "SUCCESS") {
|
|
||||||
return updateServer({ account: msg.params[1] });
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case irc.RPL_NOTOPIC:
|
case irc.RPL_NOTOPIC:
|
||||||
channel = msg.params[1];
|
channel = msg.params[1];
|
||||||
return updateBuffer(channel, { topic: null });
|
return updateBuffer(channel, { topic: null });
|
||||||
@ -477,64 +348,58 @@ export const State = {
|
|||||||
case irc.RPL_TOPICWHOTIME:
|
case irc.RPL_TOPICWHOTIME:
|
||||||
// Ignore
|
// Ignore
|
||||||
break;
|
break;
|
||||||
case irc.RPL_ENDOFNAMES:
|
case irc.RPL_NAMREPLY:
|
||||||
channel = msg.params[1];
|
channel = msg.params[2];
|
||||||
|
let membersList = msg.params[3].split(" ");
|
||||||
|
|
||||||
return updateBuffer(channel, (buf) => {
|
return updateBuffer(channel, (buf) => {
|
||||||
let members = new irc.CaseMapMap(null, buf.members.caseMap);
|
let members = new irc.CaseMapMap(buf.members);
|
||||||
msg.list.forEach((namreply) => {
|
membersList.forEach((s) => {
|
||||||
let membersList = namreply.params[3].split(" ");
|
let member = irc.parseTargetPrefix(s);
|
||||||
membersList.forEach((s) => {
|
members.set(member.name, member.prefix);
|
||||||
let member = irc.parseTargetPrefix(s);
|
|
||||||
members.set(member.name, member.prefix);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
return { members };
|
return { members };
|
||||||
});
|
});
|
||||||
|
case irc.RPL_ENDOFNAMES:
|
||||||
|
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
|
||||||
|
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 (!client.isChannel(target) && target.indexOf("*") < 0) {
|
||||||
// Not a channel nor a mask, likely a nick
|
// Not a channel nor a mask, likely a nick
|
||||||
return updateUser(target, (user) => {
|
return updateUser(target, (user) => {
|
||||||
|
// TODO: mark user offline if we have old WHO info but this
|
||||||
|
// WHO reply is empty
|
||||||
|
if (user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
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;
|
||||||
case "JOIN":
|
case "JOIN":
|
||||||
channel = msg.params[0];
|
channel = msg.params[0];
|
||||||
|
|
||||||
if (client.isMyNick(msg.prefix.name)) {
|
if (client.isMyNick(msg.prefix.name)) {
|
||||||
let [_id, update] = State.createBuffer(state, channel, serverID, client);
|
let [id, update] = State.createBuffer(state, channel, serverID, client);
|
||||||
state = { ...state, ...update };
|
state = { ...state, ...update };
|
||||||
}
|
}
|
||||||
|
|
||||||
update = updateBuffer(channel, (buf) => {
|
let update = updateBuffer(channel, (buf) => {
|
||||||
let members = new irc.CaseMapMap(buf.members);
|
let members = new irc.CaseMapMap(buf.members);
|
||||||
members.set(msg.prefix.name, "");
|
members.set(msg.prefix.name, "");
|
||||||
|
return { members };
|
||||||
let joined = buf.joined || client.isMyNick(msg.prefix.name);
|
|
||||||
|
|
||||||
return { members, joined };
|
|
||||||
});
|
});
|
||||||
state = { ...state, ...update };
|
state = { ...state, ...update };
|
||||||
|
|
||||||
@ -562,10 +427,7 @@ export const State = {
|
|||||||
return updateBuffer(channel, (buf) => {
|
return updateBuffer(channel, (buf) => {
|
||||||
let members = new irc.CaseMapMap(buf.members);
|
let members = new irc.CaseMapMap(buf.members);
|
||||||
members.delete(msg.prefix.name);
|
members.delete(msg.prefix.name);
|
||||||
|
return { members };
|
||||||
let joined = buf.joined && !client.isMyNick(msg.prefix.name);
|
|
||||||
|
|
||||||
return { members, joined };
|
|
||||||
});
|
});
|
||||||
case "KICK":
|
case "KICK":
|
||||||
channel = msg.params[0];
|
channel = msg.params[0];
|
||||||
@ -574,54 +436,18 @@ export const State = {
|
|||||||
return updateBuffer(channel, (buf) => {
|
return updateBuffer(channel, (buf) => {
|
||||||
let members = new irc.CaseMapMap(buf.members);
|
let members = new irc.CaseMapMap(buf.members);
|
||||||
members.delete(nick);
|
members.delete(nick);
|
||||||
|
return { members };
|
||||||
let joined = buf.joined && !client.isMyNick(nick);
|
|
||||||
|
|
||||||
return { members, joined };
|
|
||||||
});
|
});
|
||||||
case "QUIT":
|
case "QUIT":
|
||||||
buffers = new Map(state.buffers);
|
return updateUser(msg.prefix.name, (user) => {
|
||||||
state.buffers.forEach((buf) => {
|
|
||||||
if (buf.server !== serverID) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!buf.members.has(msg.prefix.name)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let members = new irc.CaseMapMap(buf.members);
|
|
||||||
members.delete(msg.prefix.name);
|
|
||||||
buffers.set(buf.id, { ...buf, members });
|
|
||||||
});
|
|
||||||
state = { ...state, buffers };
|
|
||||||
|
|
||||||
update = updateUser(msg.prefix.name, (user) => {
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
return { offline: true };
|
return { offline: true };
|
||||||
});
|
});
|
||||||
state = { ...state, ...update };
|
|
||||||
|
|
||||||
return state;
|
|
||||||
case "NICK":
|
case "NICK":
|
||||||
let newNick = msg.params[0];
|
let newNick = msg.params[0];
|
||||||
|
return updateServer((server) => {
|
||||||
buffers = new Map(state.buffers);
|
|
||||||
state.buffers.forEach((buf) => {
|
|
||||||
if (buf.server !== serverID) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!buf.members.has(msg.prefix.name)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let members = new irc.CaseMapMap(buf.members);
|
|
||||||
members.set(newNick, members.get(msg.prefix.name));
|
|
||||||
members.delete(msg.prefix.name);
|
|
||||||
buffers.set(buf.id, { ...buf, members });
|
|
||||||
});
|
|
||||||
state = { ...state, buffers };
|
|
||||||
|
|
||||||
update = updateServer((server) => {
|
|
||||||
let users = new irc.CaseMapMap(server.users);
|
let users = new irc.CaseMapMap(server.users);
|
||||||
let user = users.get(msg.prefix.name);
|
let user = users.get(msg.prefix.name);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@ -631,9 +457,6 @@ export const State = {
|
|||||||
users.delete(msg.prefix.name);
|
users.delete(msg.prefix.name);
|
||||||
return { users };
|
return { users };
|
||||||
});
|
});
|
||||||
state = { ...state, ...update };
|
|
||||||
|
|
||||||
return state;
|
|
||||||
case "SETNAME":
|
case "SETNAME":
|
||||||
return updateUser(msg.prefix.name, { realname: msg.params[0] });
|
return updateUser(msg.prefix.name, { realname: msg.params[0] });
|
||||||
case "CHGHOST":
|
case "CHGHOST":
|
||||||
@ -649,7 +472,7 @@ export const State = {
|
|||||||
return updateUser(msg.prefix.name, { account });
|
return updateUser(msg.prefix.name, { account });
|
||||||
case "AWAY":
|
case "AWAY":
|
||||||
let awayMessage = msg.params[0];
|
let awayMessage = msg.params[0];
|
||||||
return updateUser(msg.prefix.name, { away: Boolean(awayMessage) });
|
return updateUser(msg.prefix.name, { away: !!awayMessage });
|
||||||
case "TOPIC":
|
case "TOPIC":
|
||||||
channel = msg.params[0];
|
channel = msg.params[0];
|
||||||
topic = msg.params[1];
|
topic = msg.params[1];
|
||||||
@ -661,7 +484,7 @@ export const State = {
|
|||||||
return; // TODO: handle user mode changes too
|
return; // TODO: handle user mode changes too
|
||||||
}
|
}
|
||||||
|
|
||||||
let prefix = client.isupport.prefix();
|
let prefix = client.isupport.get("PREFIX") || "";
|
||||||
let prefixByMode = new Map(irc.parseMembershipModes(prefix).map((membership) => {
|
let prefixByMode = new Map(irc.parseMembershipModes(prefix).map((membership) => {
|
||||||
return [membership.mode, membership.prefix];
|
return [membership.mode, membership.prefix];
|
||||||
}));
|
}));
|
||||||
@ -680,21 +503,22 @@ export const State = {
|
|||||||
|
|
||||||
return { members };
|
return { members };
|
||||||
});
|
});
|
||||||
case "REDACT":
|
|
||||||
target = msg.params[0];
|
|
||||||
if (client.isMyNick(target)) {
|
|
||||||
target = msg.prefix.name;
|
|
||||||
}
|
|
||||||
return updateBuffer(target, (buf) => {
|
|
||||||
return { redacted: new Set(buf.redacted).add(msg.params[1]) };
|
|
||||||
});
|
|
||||||
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: msg.command === irc.RPL_MONOFFLINE });
|
let update = updateUser(prefix.name, { offline: true });
|
||||||
state = { ...state, ...update };
|
state = { ...state, ...update };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
92
store.js
92
store.js
@ -1,5 +1,3 @@
|
|||||||
import { ReceiptType, Unread } from "./state.js";
|
|
||||||
|
|
||||||
const PREFIX = "gamja_";
|
const PREFIX = "gamja_";
|
||||||
|
|
||||||
class Item {
|
class Item {
|
||||||
@ -25,19 +23,18 @@ class Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const autoconnect = new Item("autoconnect");
|
export const autoconnect = new Item("autoconnect");
|
||||||
export const naggedProtocolHandler = new Item("naggedProtocolHandler");
|
|
||||||
export const settings = new Item("settings");
|
|
||||||
|
|
||||||
function debounce(f, delay) {
|
const rawReceipts = new Item("receipts");
|
||||||
let timeout = null;
|
|
||||||
return (...args) => {
|
export const receipts = {
|
||||||
clearTimeout(timeout);
|
load() {
|
||||||
timeout = setTimeout(() => {
|
let v = rawReceipts.load();
|
||||||
timeout = null;
|
return new Map(Object.entries(v || {}));
|
||||||
f(...args);
|
},
|
||||||
}, delay);
|
put(m) {
|
||||||
};
|
rawReceipts.put(Object.fromEntries(m));
|
||||||
}
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export class Buffer {
|
export class Buffer {
|
||||||
raw = new Item("buffers");
|
raw = new Item("buffers");
|
||||||
@ -46,22 +43,14 @@ export class Buffer {
|
|||||||
constructor() {
|
constructor() {
|
||||||
let obj = this.raw.load();
|
let obj = this.raw.load();
|
||||||
this.m = new Map(Object.entries(obj || {}));
|
this.m = new Map(Object.entries(obj || {}));
|
||||||
|
|
||||||
let saveImmediately = this.save.bind(this);
|
|
||||||
this.save = debounce(saveImmediately, 500);
|
|
||||||
|
|
||||||
document.addEventListener("visibilitychange", () => {
|
|
||||||
if (document.visibilityState === "hidden") {
|
|
||||||
saveImmediately();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
key(buf) {
|
key(buf) {
|
||||||
// TODO: use case-mapping here somehow
|
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
name: buf.name.toLowerCase(),
|
name: buf.name,
|
||||||
server: {
|
server: {
|
||||||
|
url: buf.server.url,
|
||||||
|
nick: buf.server.nick,
|
||||||
bouncerNetwork: buf.server.bouncerNetwork,
|
bouncerNetwork: buf.server.bouncerNetwork,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -82,53 +71,22 @@ export class Buffer {
|
|||||||
put(buf) {
|
put(buf) {
|
||||||
let key = this.key(buf);
|
let key = this.key(buf);
|
||||||
|
|
||||||
let updated = !this.m.has(key);
|
let prev = this.m.get(key);
|
||||||
let prev = this.m.get(key) || {};
|
if (prev && prev.unread === buf.unread) {
|
||||||
|
return;
|
||||||
let unread = prev.unread || Unread.NONE;
|
|
||||||
if (buf.unread !== undefined && buf.unread !== prev.unread) {
|
|
||||||
unread = buf.unread;
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
let receipts = { ...prev.receipts };
|
|
||||||
if (buf.receipts) {
|
|
||||||
Object.keys(buf.receipts).forEach((k) => {
|
|
||||||
// Use a not-equals comparison here so that no-op receipt
|
|
||||||
// changes are correctly handled
|
|
||||||
if (!receipts[k] || receipts[k].time < buf.receipts[k].time) {
|
|
||||||
receipts[k] = buf.receipts[k];
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (receipts[ReceiptType.DELIVERED] < receipts[ReceiptType.READ]) {
|
|
||||||
receipts[ReceiptType.DELIVERED] = receipts[ReceiptType.READ];
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let closed = prev.closed || false;
|
|
||||||
if (buf.closed !== undefined && buf.closed !== prev.closed) {
|
|
||||||
closed = buf.closed;
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!updated) {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.m.set(this.key(buf), {
|
this.m.set(this.key(buf), {
|
||||||
name: buf.name,
|
name: buf.name,
|
||||||
unread,
|
unread: buf.unread,
|
||||||
receipts,
|
|
||||||
closed,
|
|
||||||
server: {
|
server: {
|
||||||
|
url: buf.server.url,
|
||||||
|
nick: buf.server.nick,
|
||||||
bouncerNetwork: buf.server.bouncerNetwork,
|
bouncerNetwork: buf.server.bouncerNetwork,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.save();
|
this.save();
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(buf) {
|
delete(buf) {
|
||||||
@ -137,25 +95,19 @@ 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.bouncerNetwork !== server.bouncerNetwork) {
|
if (buf.server.url !== server.url || buf.server.nick !== server.nick || 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.list(server)) {
|
for (const buf of this.m.values()) {
|
||||||
this.m.delete(this.key(buf));
|
this.m.delete(this.key(buf));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
94
style.css
94
style.css
@ -58,13 +58,6 @@ body {
|
|||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
noscript {
|
|
||||||
display: block;
|
|
||||||
margin: 0 auto;
|
|
||||||
max-width: 600px;
|
|
||||||
grid-column-start: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
button {
|
||||||
background: var(--button-background);
|
background: var(--button-background);
|
||||||
transition: background 0.25s linear;
|
transition: background 0.25s linear;
|
||||||
@ -154,8 +147,9 @@ button.danger:hover {
|
|||||||
padding: 2px 10px;
|
padding: 2px 10px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
#buffer-list li.error a {
|
#buffer-list li.active a {
|
||||||
color: red;
|
color: white;
|
||||||
|
background-color: var(--gray);
|
||||||
}
|
}
|
||||||
#buffer-list li.unread-message a {
|
#buffer-list li.unread-message a {
|
||||||
color: #b37400;
|
color: #b37400;
|
||||||
@ -163,10 +157,6 @@ button.danger:hover {
|
|||||||
#buffer-list li.unread-highlight a {
|
#buffer-list li.unread-highlight a {
|
||||||
color: #22009b;
|
color: #22009b;
|
||||||
}
|
}
|
||||||
#buffer-list li.active a {
|
|
||||||
color: white;
|
|
||||||
background-color: var(--gray);
|
|
||||||
}
|
|
||||||
#buffer-list li:not(.type-server) a {
|
#buffer-list li:not(.type-server) a {
|
||||||
padding-left: 20px;
|
padding-left: 20px;
|
||||||
}
|
}
|
||||||
@ -189,7 +179,7 @@ button.danger:hover {
|
|||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: 1fr auto;
|
grid-template-rows: auto auto;
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -205,9 +195,6 @@ button.danger:hover {
|
|||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
grid-row: 2;
|
grid-row: 2;
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
max-height: 20vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#buffer-header .actions {
|
#buffer-header .actions {
|
||||||
@ -292,9 +279,6 @@ button.danger:hover {
|
|||||||
padding: 2px 10px;
|
padding: 2px 10px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
#member-list li a.away {
|
|
||||||
color: var(--gray);
|
|
||||||
}
|
|
||||||
|
|
||||||
.membership.owner {
|
.membership.owner {
|
||||||
color: red;
|
color: red;
|
||||||
@ -302,7 +286,7 @@ button.danger:hover {
|
|||||||
.membership.admin {
|
.membership.admin {
|
||||||
color: blue;
|
color: blue;
|
||||||
}
|
}
|
||||||
.membership.operator {
|
.membership.op {
|
||||||
color: var(--green);
|
color: var(--green);
|
||||||
}
|
}
|
||||||
.membership.halfop {
|
.membership.halfop {
|
||||||
@ -352,26 +336,17 @@ 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-size: inherit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: var(--green);
|
color: var(--green);
|
||||||
}
|
}
|
||||||
#buffer-list li a, a.timestamp, a.nick {
|
#buffer-list li a, a.timestamp, a.nick {
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
#buffer-list li a,
|
|
||||||
a.nick {
|
|
||||||
color: var(--main-color);
|
|
||||||
}
|
|
||||||
a.timestamp {
|
|
||||||
color: var(--gray);
|
color: var(--gray);
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
#buffer-list li a:hover, #buffer-list li a:active,
|
#buffer-list li a:hover, #buffer-list li a:active,
|
||||||
a.timestamp:hover, a.timestamp:active,
|
a.timestamp:hover, a.timestamp:active,
|
||||||
@ -394,7 +369,6 @@ details summary[role="button"] {
|
|||||||
}
|
}
|
||||||
#buffer .logline {
|
#buffer .logline {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
overflow: auto;
|
|
||||||
}
|
}
|
||||||
#buffer .talk, #buffer .motd {
|
#buffer .talk, #buffer .motd {
|
||||||
color: var(--main-color);
|
color: var(--main-color);
|
||||||
@ -546,14 +520,6 @@ details summary[role="button"] {
|
|||||||
overflow: auto; /* hack to clear floating elements */
|
overflow: auto; /* hack to clear floating elements */
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog .protocol-handler {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
}
|
|
||||||
.dialog .protocol-handler .left {
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
kbd {
|
kbd {
|
||||||
background-color: #f0f0f0;
|
background-color: #f0f0f0;
|
||||||
border: 1px solid #bfbfbf;
|
border: 1px solid #bfbfbf;
|
||||||
@ -569,44 +535,21 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#buffer-list li.active a {
|
||||||
|
color: var(--sidebar-background);
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
#buffer-list li.unread-message a {
|
#buffer-list li.unread-message a {
|
||||||
color: var(--green);
|
color: var(--green);
|
||||||
}
|
}
|
||||||
#buffer-list li.unread-highlight a {
|
#buffer-list li.unread-highlight a {
|
||||||
color: #0062cc;
|
color: #0062cc;
|
||||||
}
|
}
|
||||||
#buffer-list li.active a {
|
|
||||||
color: var(--sidebar-background);
|
|
||||||
background-color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
#buffer-header .status-gone {
|
#buffer-header .status-gone {
|
||||||
color: #fb885b;
|
color: #fb885b;
|
||||||
@ -619,8 +562,7 @@ ul.switcher-list .server {
|
|||||||
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;
|
||||||
@ -630,12 +572,16 @@ ul.switcher-list .server {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#buffer-list li a,
|
||||||
|
a.nick {
|
||||||
|
color: var(--main-color);
|
||||||
|
}
|
||||||
|
|
||||||
#buffer {
|
#buffer {
|
||||||
background: var(--main-background);
|
background: var(--main-background);
|
||||||
}
|
}
|
||||||
@ -705,10 +651,6 @@ ul.switcher-list .server {
|
|||||||
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) {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user