Compare commits
217 Commits
v1.0.0-bet
...
v1.0.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c547a32282 | ||
|
|
081f5743be | ||
|
|
3f059567c5 | ||
|
|
4b306305bf | ||
|
|
a172c810e9 | ||
|
|
ab3569e104 | ||
|
|
dc5e64aaac | ||
|
|
2d27168529 | ||
|
|
24ba3f5189 | ||
|
|
90a2c91651 | ||
|
|
e815295503 | ||
|
|
bbc94c88c0 | ||
|
|
84ca0a4408 | ||
|
|
84b68308b9 | ||
|
|
4964782c30 | ||
|
|
54e1fc93d9 | ||
|
|
34d3bd6df9 | ||
|
|
a13f74d466 | ||
|
|
a603b79e33 | ||
|
|
096fcbf829 | ||
|
|
a2d2a11d44 | ||
|
|
e6618c8a1f | ||
|
|
aa9aa78d71 | ||
|
|
4780b9c709 | ||
|
|
e7b69cec9a | ||
|
|
cfbd91d257 | ||
|
|
7138e43710 | ||
|
|
89647472ae | ||
|
|
e2dc32c0d3 | ||
|
|
1bcd9d3607 | ||
|
|
e4ebf5eb80 | ||
|
|
1428ec4d49 | ||
|
|
839e46360e | ||
|
|
d0064dd647 | ||
|
|
b9693d53ec | ||
|
|
f6ba40046f | ||
|
|
54453c5f44 | ||
|
|
fa80a56516 | ||
|
|
7cabb6f85b | ||
|
|
505a6fd5ab | ||
|
|
8e30806fec | ||
|
|
f0c398a10c | ||
|
|
baaf576d82 | ||
|
|
e3c2d85a94 | ||
|
|
576b9d51eb | ||
|
|
6b04cb1417 | ||
|
|
8507500d74 | ||
|
|
aaef4e1629 | ||
|
|
cdd2da90a9 | ||
|
|
4a981997f0 | ||
|
|
f45b51d981 | ||
|
|
73db1a888e | ||
|
|
3dc98ec797 | ||
|
|
e37d5f363b | ||
|
|
221b1b6356 | ||
|
|
86b1030b7a | ||
|
|
08578c9a21 | ||
|
|
26cc073f41 | ||
|
|
9e703698ca | ||
|
|
37d7f4a1c5 | ||
|
|
962c05c066 | ||
|
|
f2c9fd1d7f | ||
|
|
a3eec9a351 | ||
|
|
2ac7be6218 | ||
|
|
5f8cd976e6 | ||
|
|
fbc42b6dab | ||
|
|
dc398baa3b | ||
|
|
6a9a8e88f1 | ||
|
|
f47d93af8a | ||
|
|
fce0936c20 | ||
|
|
0636544c40 | ||
|
|
7c6f334dbf | ||
|
|
7ddd783150 | ||
|
|
bb42ff6a07 | ||
|
|
db0ef39c6b | ||
|
|
77f54080e7 | ||
|
|
065b3f21fc | ||
|
|
d2bcea8c86 | ||
|
|
3d81466788 | ||
|
|
f2923452c1 | ||
|
|
39c36e7a7b | ||
|
|
e91b044134 | ||
|
|
4cb3abfa72 | ||
|
|
0063a5a372 | ||
|
|
1142145c6d | ||
|
|
f465e24adf | ||
|
|
7f7a7c1aac | ||
|
|
e1bbe34ff2 | ||
|
|
fab42ba2ee | ||
|
|
9f93e200ed | ||
|
|
bd48f36ade | ||
|
|
393fd93253 | ||
|
|
a0f8f1f52f | ||
|
|
5d6de11a4c | ||
|
|
6692ed0035 | ||
|
|
5e34067d38 | ||
|
|
690845c2af | ||
|
|
0b59cf92b9 | ||
|
|
b11f58b975 | ||
|
|
4704e0f12f | ||
|
|
43f1329fb0 | ||
|
|
4cabae89ff | ||
|
|
f6895fed32 | ||
|
|
fc93a8cef5 | ||
|
|
f3d38859d3 | ||
|
|
f81c564d23 | ||
|
|
ab3d4dd661 | ||
|
|
31b293fa03 | ||
|
|
f9ec578fce | ||
|
|
305f510501 | ||
|
|
05f7c6e9fe | ||
|
|
fc8aa30756 | ||
|
|
8c8bd43638 | ||
|
|
30e3ec392f | ||
|
|
ada9ff3b71 | ||
|
|
93ba0e6443 | ||
|
|
07c9cdebb6 | ||
|
|
aef2812348 | ||
|
|
a1ff1be342 | ||
|
|
47f56f06b9 | ||
|
|
1e84412172 | ||
|
|
451bb4c73f | ||
|
|
be08302c1f | ||
|
|
b1d5f1436e | ||
|
|
c4a78283af | ||
|
|
25e69a551e | ||
|
|
86853eb2e5 | ||
|
|
1800b6bea1 | ||
|
|
fcce340846 | ||
|
|
e29ccf7220 | ||
|
|
d8d2cbe0f7 | ||
|
|
0d2067e33e | ||
|
|
3e309e9dfe | ||
|
|
3e2ac307f6 | ||
|
|
24b50a332c | ||
|
|
adefc620de | ||
|
|
bc3abbec32 | ||
|
|
4f927b5536 | ||
|
|
86b08296a0 | ||
|
|
25dd6aabf6 | ||
|
|
0af40a1a8e | ||
|
|
51bf8da3d6 | ||
|
|
723951a07b | ||
|
|
c4c0a77162 | ||
|
|
3f2553291f | ||
|
|
debd50f482 | ||
|
|
a57428002f | ||
|
|
bbfeb5bcbc | ||
|
|
0980983bdc | ||
|
|
e37c2a2cec | ||
|
|
82e5a2795d | ||
|
|
a0b250df3f | ||
|
|
321140327e | ||
|
|
be475026c8 | ||
|
|
55361c5a2b | ||
|
|
c11bf6508a | ||
|
|
195e4ca371 | ||
|
|
1206cfae37 | ||
|
|
df29650b98 | ||
|
|
94901f1662 | ||
|
|
9475ffb8c6 | ||
|
|
f3c48a3748 | ||
|
|
14031c594b | ||
|
|
74fe6ee944 | ||
|
|
a58befd6d7 | ||
|
|
38a3075a2c | ||
|
|
96dd8476ad | ||
|
|
800f5ceb6a | ||
|
|
7b19cf48a4 | ||
|
|
50f10a43dd | ||
|
|
eb66045371 | ||
|
|
a1ab87c71c | ||
|
|
8ebb61cb0e | ||
|
|
8f90613951 | ||
|
|
0888af4a6f | ||
|
|
08cd94d775 | ||
|
|
eec4126562 | ||
|
|
6acf6d544a | ||
|
|
ac7785aa7f | ||
|
|
85e73d0ee8 | ||
|
|
483f0c65b1 | ||
|
|
33c3cf3278 | ||
|
|
40210f8b00 | ||
|
|
a1057092e0 | ||
|
|
bf471abb1b | ||
|
|
c4a1f38b33 | ||
|
|
92043ded2c | ||
|
|
b059e034e2 | ||
|
|
49a59077b7 | ||
|
|
a313363ed7 | ||
|
|
ab2f8092a8 | ||
|
|
4309cf44d3 | ||
|
|
2d032259db | ||
|
|
3d09c43a91 | ||
|
|
e7054eab13 | ||
|
|
d9f36c82ba | ||
|
|
12440691c9 | ||
|
|
34aea84dde | ||
|
|
a31976586c | ||
|
|
8bdde589bb | ||
|
|
bfef13824e | ||
|
|
1a8d539c9e | ||
|
|
a120d79585 | ||
|
|
3562478946 | ||
|
|
405bc51c26 | ||
|
|
631f119061 | ||
|
|
a7d3a3940a | ||
|
|
21a4a71542 | ||
|
|
a890665775 | ||
|
|
a920914b4c | ||
|
|
47b12cc5d9 | ||
|
|
312a3f812e | ||
|
|
e3e3315125 | ||
|
|
d2ac1e152a | ||
|
|
8cc61bf577 | ||
|
|
4577f0a27f | ||
|
|
19ee5553f6 |
19
.build.yml
Normal file
19
.build.yml
Normal file
@@ -0,0 +1,19 @@
|
||||
image: alpine/latest
|
||||
packages:
|
||||
- npm
|
||||
- rsync
|
||||
sources:
|
||||
- https://git.sr.ht/~emersion/gamja
|
||||
secrets:
|
||||
- 77c7956b-003e-44f7-bb5c-2944b2047654 # deploy SSH key
|
||||
tasks:
|
||||
- setup: |
|
||||
cd gamja
|
||||
npm install --include=dev
|
||||
npm run build
|
||||
- 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@sheeta.emersion.fr:/srv/http/gamja
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,2 +1,5 @@
|
||||
node_modules
|
||||
/config.json
|
||||
|
||||
.parcel-cache
|
||||
/dist
|
||||
|
||||
59
README.md
59
README.md
@@ -12,6 +12,9 @@ First install dependencies:
|
||||
|
||||
npm install --production
|
||||
|
||||
Then configure an HTTP server to serve the gamja files. Below are some
|
||||
server-specific instructions.
|
||||
|
||||
### [soju]
|
||||
|
||||
Add a WebSocket listener to soju, e.g. `listen wss://127.0.0.1:8080`.
|
||||
@@ -37,6 +40,10 @@ 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;
|
||||
@@ -48,19 +55,26 @@ location /socket {
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
If you are unable to configure the proxy timeout accordingly, or if your IRC
|
||||
server doesn't send PINGs, you can set the `server.ping` option in
|
||||
`config.json` (see below).
|
||||
|
||||
### Development server
|
||||
|
||||
Start your IRC WebSocket server, e.g. on port 8080. Then run:
|
||||
If you don't have an IRC WebSocket server at hand, gamja's development server
|
||||
can be used. For instance, to run gamja on Libera Chat:
|
||||
|
||||
npm install
|
||||
npm start
|
||||
npm install --include=dev
|
||||
npm start -- irc.libera.chat
|
||||
|
||||
This will start a development HTTP server for gamja. Connect to it and append
|
||||
`?server=ws://localhost:8080` to the URL.
|
||||
See `npm start -- -h` for a list of options.
|
||||
|
||||
### Production build
|
||||
|
||||
Optionally, [Parcel] can be used to build a minified version of gamja.
|
||||
|
||||
npm install --include=dev
|
||||
npm run build
|
||||
|
||||
## Query parameters
|
||||
|
||||
@@ -69,6 +83,8 @@ gamja settings can be overridden using URL query parameters:
|
||||
- `server`: path or URL to the WebSocket server
|
||||
- `nick`: nickname
|
||||
- `channels`: comma-separated list of channels to join (`#` needs to be escaped)
|
||||
- `open`: [IRC URL] to open
|
||||
- `debug`: if set to 1, debug mode is enabled
|
||||
|
||||
Alternatively, the channels can be set with the URL fragment (ie, by just
|
||||
appending the channel name to the gamja URL).
|
||||
@@ -87,13 +103,33 @@ gamja default settings can be set using a `config.json` file at the root:
|
||||
"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
|
||||
// require it, "disabled" to never ask for a password, "external" to
|
||||
// use SASL EXTERNAL, "oauth2" to use SASL OAUTHBEARER. Defaults to
|
||||
// "optional".
|
||||
"auth": "optional",
|
||||
// Default nickname (string). If it contains a "*" character, it will
|
||||
// be replaced with a random string.
|
||||
"nick": "asdf",
|
||||
// Don't display the login UI, immediately connect to the server
|
||||
// (boolean).
|
||||
"autoconnect": true,
|
||||
// Interval in seconds to send PING commands (number). Set to 0 to
|
||||
// disable. Enabling PINGs can have an impact on client power usage and
|
||||
// should only be enabled if necessary.
|
||||
"ping": 60
|
||||
},
|
||||
// OAuth 2.0 settings.
|
||||
"oauth2": {
|
||||
// OAuth 2.0 server URL (string). The server must support OAuth 2.0
|
||||
// Authorization Server Metadata (RFC 8414) or OpenID Connect
|
||||
// Discovery.
|
||||
"url": "https://auth.example.org",
|
||||
// OAuth 2.0 client ID (string).
|
||||
"client_id": "asdf",
|
||||
// OAuth 2.0 client secret (string).
|
||||
"client_secret": "ghjk",
|
||||
// OAuth 2.0 scope (string).
|
||||
"scope": "profile"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -101,7 +137,7 @@ gamja default settings can be set using a `config.json` file at the root:
|
||||
## Contributing
|
||||
|
||||
Send patches on the [mailing list], report bugs on the [issue tracker]. Discuss
|
||||
in #soju on Libera Chat.
|
||||
in [#soju on Libera Chat].
|
||||
|
||||
## License
|
||||
|
||||
@@ -114,3 +150,6 @@ Copyright (C) 2020 The gamja Contributors
|
||||
[webircgateway]: https://github.com/kiwiirc/webircgateway
|
||||
[mailing list]: https://lists.sr.ht/~emersion/public-inbox
|
||||
[issue tracker]: https://todo.sr.ht/~emersion/gamja
|
||||
[Parcel]: https://parceljs.org
|
||||
[IRC URL]: https://datatracker.ietf.org/doc/html/draft-butcher-irc-url-04
|
||||
[#soju on Libera Chat]: ircs://irc.libera.chat/#soju
|
||||
|
||||
58
commands.js
58
commands.js
@@ -54,19 +54,23 @@ function markServerBufferUnread(app) {
|
||||
}
|
||||
|
||||
const join = {
|
||||
usage: "<name>",
|
||||
usage: "<name> [password]",
|
||||
description: "Join a channel",
|
||||
execute: (app, args) => {
|
||||
let channel = args[0];
|
||||
if (!channel) {
|
||||
throw new Error("Missing channel name");
|
||||
}
|
||||
app.open(channel);
|
||||
if (args.length > 1) {
|
||||
app.open(channel, null, args[1]);
|
||||
} else {
|
||||
app.open(channel);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const kick = {
|
||||
usage: "<nick>",
|
||||
usage: "<nick> [comment]",
|
||||
description: "Remove a user from the channel",
|
||||
execute: (app, args) => {
|
||||
let nick = args[0];
|
||||
@@ -79,6 +83,22 @@ const kick = {
|
||||
},
|
||||
};
|
||||
|
||||
const ban = {
|
||||
usage: "[nick]",
|
||||
description: "Ban a user from the channel, or display the current ban list",
|
||||
execute: (app, args) => {
|
||||
if (args.length == 0) {
|
||||
let activeChannel = getActiveChannel(app);
|
||||
getActiveClient(app).send({
|
||||
command: "MODE",
|
||||
params: [activeChannel, "+b"],
|
||||
});
|
||||
} else {
|
||||
return setUserHostMode(app, args, "+b");
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function givemode(app, args, mode) {
|
||||
// TODO: Handle several users at once
|
||||
let nick = args[0];
|
||||
@@ -93,21 +113,18 @@ function givemode(app, args, mode) {
|
||||
}
|
||||
|
||||
export default {
|
||||
"ban": {
|
||||
usage: "[nick]",
|
||||
description: "Ban a user from the channel, or display the current ban list",
|
||||
"away": {
|
||||
usage: "[message]",
|
||||
description: "Set away message",
|
||||
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");
|
||||
const params = []
|
||||
if (args.length) {
|
||||
params.push(args.join(" "));
|
||||
}
|
||||
getActiveClient(app).send({command: "AWAY", params});
|
||||
},
|
||||
},
|
||||
"ban": ban,
|
||||
"buffer": {
|
||||
usage: "<name>",
|
||||
description: "Switch to a buffer",
|
||||
@@ -322,10 +339,11 @@ export default {
|
||||
execute: (app, args) => {
|
||||
let newRealname = args.join(" ");
|
||||
let client = getActiveClient(app);
|
||||
if (!client.enabledCaps["setname"]) {
|
||||
if (!client.caps.enabled.has("setname")) {
|
||||
throw new Error("Server doesn't support changing the realname");
|
||||
}
|
||||
client.send({ command: "SETNAME", params: [newRealname] });
|
||||
// TODO: save to local storage
|
||||
},
|
||||
},
|
||||
"stats": {
|
||||
@@ -381,7 +399,7 @@ export default {
|
||||
execute: (app, args) => givemode(app, args, "+v"),
|
||||
},
|
||||
"who": {
|
||||
usage: "[<mask> [o]]",
|
||||
usage: "<mask>",
|
||||
description: "Retrieve a list of users",
|
||||
execute: (app, args) => {
|
||||
getActiveClient(app).send({ command: "WHO", params: args });
|
||||
@@ -411,4 +429,12 @@ export default {
|
||||
markServerBufferUnread(app);
|
||||
},
|
||||
},
|
||||
"list": {
|
||||
usage: "[filter]",
|
||||
description: "Retrieve a list of channels from a network",
|
||||
execute: (app, args) => {
|
||||
getActiveClient(app).send({ command: "LIST", params: args });
|
||||
markServerBufferUnread(app);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
1512
components/app.js
1512
components/app.js
File diff suppressed because it is too large
Load Diff
51
components/auth-form.js
Normal file
51
components/auth-form.js
Normal file
@@ -0,0 +1,51 @@
|
||||
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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -21,24 +21,12 @@ function NickStatus(props) {
|
||||
}
|
||||
|
||||
export default function BufferHeader(props) {
|
||||
function handleCloseClick(event) {
|
||||
event.preventDefault();
|
||||
props.onClose();
|
||||
}
|
||||
function handleJoinClick(event) {
|
||||
event.preventDefault();
|
||||
props.onJoin();
|
||||
}
|
||||
function handleAddNetworkClick(event) {
|
||||
event.preventDefault();
|
||||
props.onAddNetwork();
|
||||
}
|
||||
function handleManageNetworkClick(event) {
|
||||
event.preventDefault();
|
||||
props.onManageNetwork();
|
||||
let fullyConnected = props.server.status === ServerStatus.REGISTERED;
|
||||
if (props.bouncerNetwork) {
|
||||
fullyConnected = fullyConnected && props.bouncerNetwork.state === "connected";
|
||||
}
|
||||
|
||||
let description = null, actions = null;
|
||||
let description = null, actions = [];
|
||||
switch (props.buffer.type) {
|
||||
case BufferType.SERVER:
|
||||
switch (props.server.status) {
|
||||
@@ -56,6 +44,9 @@ export default function BufferHeader(props) {
|
||||
switch (props.bouncerNetwork.state) {
|
||||
case "disconnected":
|
||||
description = "Bouncer disconnected from network";
|
||||
if (props.bouncerNetwork.error) {
|
||||
description += ": " + props.bouncerNetwork.error;
|
||||
}
|
||||
break;
|
||||
case "connecting":
|
||||
description = "Bouncer connecting to network...";
|
||||
@@ -74,56 +65,89 @@ export default function BufferHeader(props) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (props.isBouncer) {
|
||||
if (props.server.isupport.get("BOUNCER_NETID")) {
|
||||
actions = html`
|
||||
<button
|
||||
key="join"
|
||||
onClick=${handleJoinClick}
|
||||
>Join channel</button>
|
||||
<button
|
||||
key="manage"
|
||||
onClick=${handleManageNetworkClick}
|
||||
>Manage network</button>
|
||||
`;
|
||||
let joinButton = html`
|
||||
<button
|
||||
key="join"
|
||||
onClick=${props.onJoin}
|
||||
>Join channel</button>
|
||||
`;
|
||||
let reconnectButton = html`
|
||||
<button
|
||||
key="reconect"
|
||||
onClick=${props.onReconnect}
|
||||
>Reconnect</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 {
|
||||
actions = html`
|
||||
<button
|
||||
key="add"
|
||||
onClick=${handleAddNetworkClick}
|
||||
>Add network</button>
|
||||
<button
|
||||
key="disconnect"
|
||||
class="danger"
|
||||
onClick=${handleCloseClick}
|
||||
>Disconnect</button>
|
||||
`;
|
||||
if (fullyConnected) {
|
||||
actions.push(html`
|
||||
<button
|
||||
key="add"
|
||||
onClick=${props.onAddNetwork}
|
||||
>Add network</button>
|
||||
`);
|
||||
} else if (props.server.status === ServerStatus.DISCONNECTED) {
|
||||
actions.push(reconnectButton);
|
||||
}
|
||||
actions.push(settingsButton);
|
||||
}
|
||||
} else {
|
||||
actions = html`
|
||||
<button
|
||||
key="join"
|
||||
onClick=${handleJoinClick}
|
||||
>Join channel</button>
|
||||
<button
|
||||
key="disconnect"
|
||||
class="danger"
|
||||
onClick=${handleCloseClick}
|
||||
>Disconnect</button>
|
||||
`;
|
||||
if (fullyConnected) {
|
||||
actions.push(joinButton);
|
||||
} else if (props.server.status === ServerStatus.DISCONNECTED) {
|
||||
actions.push(reconnectButton);
|
||||
}
|
||||
actions.push(settingsButton);
|
||||
}
|
||||
break;
|
||||
case BufferType.CHANNEL:
|
||||
if (props.buffer.topic) {
|
||||
description = linkify(stripANSI(props.buffer.topic), props.onChannelClick);
|
||||
}
|
||||
actions = html`
|
||||
<button
|
||||
key="part"
|
||||
class="danger"
|
||||
onClick=${handleCloseClick}
|
||||
>Leave</button>
|
||||
`;
|
||||
if (props.buffer.joined) {
|
||||
actions.push(html`
|
||||
<button
|
||||
key="part"
|
||||
class="danger"
|
||||
onClick=${props.onClose}
|
||||
>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;
|
||||
case BufferType.NICK:
|
||||
if (props.user) {
|
||||
@@ -144,9 +168,37 @@ export default function BufferHeader(props) {
|
||||
details.push(`${props.user.username}@${props.user.hostname}`);
|
||||
}
|
||||
if (props.user.account) {
|
||||
details.push(`authenticated as ${props.user.account}`);
|
||||
let desc = `This user is verified and has logged in to the server with the account ${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}`;
|
||||
}
|
||||
@@ -155,7 +207,7 @@ export default function BufferHeader(props) {
|
||||
<button
|
||||
key="close"
|
||||
class="danger"
|
||||
onClick=${handleCloseClick}
|
||||
onClick=${props.onClose}
|
||||
>Close</button>
|
||||
`;
|
||||
break;
|
||||
@@ -163,7 +215,7 @@ export default function BufferHeader(props) {
|
||||
|
||||
let name = props.buffer.name;
|
||||
if (props.buffer.type == BufferType.SERVER) {
|
||||
name = getServerName(props.server, props.bouncerNetwork, props.isBouncer);
|
||||
name = getServerName(props.server, props.bouncerNetwork);
|
||||
}
|
||||
|
||||
return html`
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import * as irc from "../lib/irc.js";
|
||||
import { html, Component } from "../lib/index.js";
|
||||
import { BufferType, Unread, getBufferURL, getServerName } from "../state.js";
|
||||
import { BufferType, Unread, ServerStatus, getBufferURL, getServerName } from "../state.js";
|
||||
|
||||
function BufferItem(props) {
|
||||
function handleClick(event) {
|
||||
event.preventDefault();
|
||||
props.onClick();
|
||||
}
|
||||
function handleMouseDown(event) {
|
||||
if (event.button === 1) { // middle click
|
||||
event.preventDefault();
|
||||
props.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
let name = props.buffer.name;
|
||||
if (props.buffer.type == BufferType.SERVER) {
|
||||
name = getServerName(props.server, props.bouncerNetwork, props.isBouncer);
|
||||
name = getServerName(props.server, props.bouncerNetwork);
|
||||
}
|
||||
|
||||
let classes = ["type-" + props.buffer.type];
|
||||
@@ -20,10 +26,23 @@ function BufferItem(props) {
|
||||
if (props.buffer.unread != Unread.NONE) {
|
||||
classes.push("unread-" + props.buffer.unread);
|
||||
}
|
||||
if (props.buffer.type === BufferType.SERVER) {
|
||||
let isError = props.server.status === ServerStatus.DISCONNECTED;
|
||||
if (props.bouncerNetwork && props.bouncerNetwork.error) {
|
||||
isError = true;
|
||||
}
|
||||
if (isError) {
|
||||
classes.push("error");
|
||||
}
|
||||
}
|
||||
|
||||
return html`
|
||||
<li class="${classes.join(" ")}">
|
||||
<a href=${getBufferURL(props.buffer)} onClick=${handleClick}>${name}</a>
|
||||
<a
|
||||
href=${getBufferURL(props.buffer)}
|
||||
onClick=${handleClick}
|
||||
onMouseDown=${handleMouseDown}
|
||||
>${name}</a>
|
||||
</li>
|
||||
`;
|
||||
}
|
||||
@@ -34,9 +53,8 @@ export default function BufferList(props) {
|
||||
let server = props.servers.get(buf.server);
|
||||
|
||||
let bouncerNetwork = null;
|
||||
let bouncerNetID = server.isupport.get("BOUNCER_NETID");
|
||||
if (bouncerNetID) {
|
||||
bouncerNetwork = props.bouncerNetworks.get(bouncerNetID);
|
||||
if (server.bouncerNetID) {
|
||||
bouncerNetwork = props.bouncerNetworks.get(server.bouncerNetID);
|
||||
}
|
||||
|
||||
return html`
|
||||
@@ -44,9 +62,9 @@ export default function BufferList(props) {
|
||||
key=${buf.id}
|
||||
buffer=${buf}
|
||||
server=${server}
|
||||
isBouncer=${props.isBouncer}
|
||||
bouncerNetwork=${bouncerNetwork}
|
||||
onClick=${() => props.onBufferClick(buf)}
|
||||
onClose=${() => props.onBufferClose(buf)}
|
||||
active=${props.activeBuffer == buf.id}
|
||||
/>
|
||||
`;
|
||||
|
||||
@@ -2,7 +2,8 @@ import { html, Component } from "../lib/index.js";
|
||||
import linkify from "../lib/linkify.js";
|
||||
import * as irc from "../lib/irc.js";
|
||||
import { strip as stripANSI } from "../lib/ansi.js";
|
||||
import { BufferType, getNickURL, getChannelURL, getMessageURL } from "../state.js";
|
||||
import { BufferType, ServerStatus, BufferEventsDisplayMode, getNickURL, getChannelURL, getMessageURL, isMessageBeforeReceipt, SettingsContext } from "../state.js";
|
||||
import * as store from "../store.js";
|
||||
import Membership from "./membership.js";
|
||||
|
||||
function djb2(s) {
|
||||
@@ -26,15 +27,22 @@ function Nick(props) {
|
||||
`;
|
||||
}
|
||||
|
||||
function Timestamp({ date, url }) {
|
||||
function _Timestamp({ date, url, showSeconds }) {
|
||||
if (!date) {
|
||||
return html`<spam class="timestamp">--:--:--</span>`;
|
||||
let timestamp = "--:--";
|
||||
if (showSeconds) {
|
||||
timestamp += ":--";
|
||||
}
|
||||
return html`<spam class="timestamp">${timestamp}</span>`;
|
||||
}
|
||||
|
||||
let hh = date.getHours().toString().padStart(2, "0");
|
||||
let mm = date.getMinutes().toString().padStart(2, "0");
|
||||
let ss = date.getSeconds().toString().padStart(2, "0");
|
||||
let timestamp = `${hh}:${mm}:${ss}`;
|
||||
let timestamp = `${hh}:${mm}`;
|
||||
if (showSeconds) {
|
||||
let ss = date.getSeconds().toString().padStart(2, "0");
|
||||
timestamp += ":" + ss;
|
||||
}
|
||||
return html`
|
||||
<a
|
||||
href=${url}
|
||||
@@ -47,6 +55,16 @@ function Timestamp({ date, url }) {
|
||||
`;
|
||||
}
|
||||
|
||||
function Timestamp(props) {
|
||||
return html`
|
||||
<${SettingsContext.Consumer}>
|
||||
${(settings) => html`
|
||||
<${_Timestamp} ...${props} showSeconds=${settings.secondsInTimestamps}/>
|
||||
`}
|
||||
</>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a message can be folded.
|
||||
*
|
||||
@@ -76,18 +94,16 @@ class LogLine extends Component {
|
||||
|
||||
let onNickClick = this.props.onNickClick;
|
||||
let onChannelClick = this.props.onChannelClick;
|
||||
let onVerifyClick = this.props.onVerifyClick;
|
||||
|
||||
function createNick(nick) {
|
||||
return html`
|
||||
<${Nick} nick=${nick} onClick=${() => onNickClick(nick)}/>
|
||||
`;
|
||||
}
|
||||
function createChannel(channel) {
|
||||
function onClick(event) {
|
||||
event.preventDefault();
|
||||
onChannelClick(channel);
|
||||
}
|
||||
return html`
|
||||
<a href=${getChannelURL(channel)} onClick=${onClick}>
|
||||
<a href=${getChannelURL(channel)} onClick=${onChannelClick}>
|
||||
${channel}
|
||||
</a>
|
||||
`;
|
||||
@@ -95,11 +111,11 @@ class LogLine extends Component {
|
||||
|
||||
let lineClass = "";
|
||||
let content;
|
||||
let invitee;
|
||||
let invitee, target, account;
|
||||
switch (msg.command) {
|
||||
case "NOTICE":
|
||||
case "PRIVMSG":
|
||||
let target = msg.params[0];
|
||||
target = msg.params[0];
|
||||
let text = msg.params[1];
|
||||
|
||||
let ctcp = irc.parseCTCP(msg);
|
||||
@@ -122,7 +138,7 @@ class LogLine extends Component {
|
||||
}
|
||||
|
||||
let status = null;
|
||||
let allowedPrefixes = server.isupport.get("STATUSMSG");
|
||||
let allowedPrefixes = server.statusMsg;
|
||||
if (target !== buf.name && allowedPrefixes) {
|
||||
let parts = irc.parseTargetPrefix(target, allowedPrefixes);
|
||||
if (parts.name === buf.name) {
|
||||
@@ -130,6 +146,10 @@ class LogLine extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.tags["+draft/channel-context"]) {
|
||||
content = html`<em>(only visible to you)</em> ${content}`;
|
||||
}
|
||||
|
||||
if (msg.isHighlight) {
|
||||
lineClass += " highlight";
|
||||
}
|
||||
@@ -161,9 +181,14 @@ class LogLine extends Component {
|
||||
`;
|
||||
break;
|
||||
case "MODE":
|
||||
target = msg.params[0];
|
||||
content = html`
|
||||
* ${createNick(msg.prefix.name)} sets mode ${msg.params.slice(1).join(" ")}
|
||||
`;
|
||||
// TODO: case-mapping
|
||||
if (buf.name !== target) {
|
||||
content = html`${content} on ${target}`;
|
||||
}
|
||||
break;
|
||||
case "TOPIC":
|
||||
let topic = msg.params[1];
|
||||
@@ -186,6 +211,10 @@ class LogLine extends Component {
|
||||
`;
|
||||
}
|
||||
break;
|
||||
case irc.RPL_WELCOME:
|
||||
let nick = msg.params[0];
|
||||
content = html`Connected to server, your nickname is ${nick}`;
|
||||
break;
|
||||
case irc.RPL_INVITING:
|
||||
invitee = msg.params[1];
|
||||
content = html`${createNick(invitee)} has been invited to the channel`;
|
||||
@@ -194,11 +223,59 @@ class LogLine extends Component {
|
||||
lineClass = "motd";
|
||||
content = linkify(stripANSI(msg.params[1]), onChannelClick);
|
||||
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;
|
||||
default:
|
||||
if (irc.isError(msg.command) && msg.command != irc.ERR_NOMOTD) {
|
||||
lineClass = "error";
|
||||
}
|
||||
content = html`${msg.command} ${msg.params.join(" ")}`;
|
||||
content = html`${msg.command} ${linkify(msg.params.join(" "))}`;
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return html`
|
||||
@@ -266,7 +343,9 @@ class FoldGroup extends Component {
|
||||
return;
|
||||
}
|
||||
|
||||
let plural = byCommand[cmd].length > 1;
|
||||
let nicks = new Set(byCommand[cmd].map((msg) => msg.prefix.name));
|
||||
|
||||
let plural = nicks.size > 1;
|
||||
let action;
|
||||
switch (cmd) {
|
||||
case "JOIN":
|
||||
@@ -286,9 +365,7 @@ class FoldGroup extends Component {
|
||||
content.push(", ");
|
||||
}
|
||||
|
||||
let nicks = byCommand[cmd].map((msg) => msg.prefix.name);
|
||||
|
||||
content.push(createNickList(nicks, createNick));
|
||||
content.push(createNickList([...nicks], createNick));
|
||||
content.push(" " + action);
|
||||
});
|
||||
|
||||
@@ -384,6 +461,82 @@ 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 {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
@@ -395,10 +548,7 @@ class DateSeparator extends Component {
|
||||
|
||||
render() {
|
||||
let date = this.props.date;
|
||||
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}`;
|
||||
let text = date.toLocaleDateString([], { year: "numeric", month: "2-digit", day: "2-digit" });
|
||||
return html`
|
||||
<div class="separator date-separator">
|
||||
${text}
|
||||
@@ -417,23 +567,42 @@ function sameDate(d1, d2) {
|
||||
|
||||
export default class Buffer extends Component {
|
||||
shouldComponentUpdate(nextProps) {
|
||||
return this.props.buffer !== nextProps.buffer;
|
||||
return this.props.buffer !== nextProps.buffer ||
|
||||
this.props.settings !== nextProps.settings;
|
||||
}
|
||||
|
||||
render() {
|
||||
let buf = this.props.buffer;
|
||||
let server = this.props.server;
|
||||
if (!buf) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let server = this.props.server;
|
||||
let bouncerNetwork = this.props.bouncerNetwork;
|
||||
let settings = this.props.settings;
|
||||
let serverName = server.name;
|
||||
|
||||
let children = [];
|
||||
if (buf.type == BufferType.SERVER) {
|
||||
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 onNickClick = this.props.onNickClick;
|
||||
let onVerifyClick = this.props.onVerifyClick;
|
||||
|
||||
function createLogLine(msg) {
|
||||
return html`
|
||||
<${LogLine}
|
||||
@@ -443,6 +612,7 @@ export default class Buffer extends Component {
|
||||
server=${server}
|
||||
onChannelClick=${onChannelClick}
|
||||
onNickClick=${onNickClick}
|
||||
onVerifyClick=${onVerifyClick}
|
||||
/>
|
||||
`;
|
||||
}
|
||||
@@ -486,7 +656,11 @@ export default class Buffer extends Component {
|
||||
buf.messages.forEach((msg) => {
|
||||
let sep = [];
|
||||
|
||||
if (!hasUnreadSeparator && buf.type != BufferType.SERVER && buf.prevReadReceipt && msg.tags.time > buf.prevReadReceipt.time) {
|
||||
if (settings.bufferEvents === BufferEventsDisplayMode.HIDE && canFoldMessage(msg)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasUnreadSeparator && buf.type != BufferType.SERVER && !isMessageBeforeReceipt(msg, buf.prevReadReceipt)) {
|
||||
sep.push(html`<${UnreadSeparator} key="unread"/>`);
|
||||
hasUnreadSeparator = true;
|
||||
}
|
||||
@@ -504,7 +678,7 @@ export default class Buffer extends Component {
|
||||
}
|
||||
|
||||
// TODO: consider checking the time difference too
|
||||
if (canFoldMessage(msg)) {
|
||||
if (settings.bufferEvents === BufferEventsDisplayMode.FOLD && canFoldMessage(msg)) {
|
||||
foldMessages.push(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ export default class Composer extends Component {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.props.readOnly && event.key !== "/") {
|
||||
if (this.props.readOnly || (this.props.commandOnly && event.key !== "/")) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -167,7 +167,7 @@ export default class Composer extends Component {
|
||||
return;
|
||||
}
|
||||
|
||||
let text = event.clipboardData.getData('text');
|
||||
let text = event.clipboardData.getData("text");
|
||||
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
@@ -201,6 +201,11 @@ export default class Composer extends Component {
|
||||
className = "read-only";
|
||||
}
|
||||
|
||||
let placeholder = "Type a message";
|
||||
if (this.props.commandOnly) {
|
||||
placeholder = "Type a command (see /help)";
|
||||
}
|
||||
|
||||
return html`
|
||||
<form
|
||||
id="composer"
|
||||
@@ -214,9 +219,10 @@ export default class Composer extends Component {
|
||||
ref=${this.textInput}
|
||||
value=${this.state.text}
|
||||
autocomplete="off"
|
||||
placeholder="Type a message"
|
||||
placeholder=${placeholder}
|
||||
enterkeyhint="send"
|
||||
onKeyDown=${this.handleInputKeyDown}
|
||||
maxlength=${this.props.maxLen}
|
||||
/>
|
||||
</form>
|
||||
`;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { html, Component, createRef } from "../lib/index.js";
|
||||
import linkify from "../lib/linkify.js";
|
||||
|
||||
export default class ConnectForm extends Component {
|
||||
state = {
|
||||
@@ -9,14 +10,14 @@ export default class ConnectForm extends Component {
|
||||
rememberMe: false,
|
||||
username: "",
|
||||
realname: "",
|
||||
autojoin: "",
|
||||
autojoin: true,
|
||||
};
|
||||
nickInput = createRef();
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
this.handleInput = this.handleInput.bind(this);
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
|
||||
if (props.params) {
|
||||
@@ -27,12 +28,11 @@ export default class ConnectForm extends Component {
|
||||
rememberMe: props.params.autoconnect || false,
|
||||
username: props.params.username || "",
|
||||
realname: props.params.realname || "",
|
||||
autojoin: (props.params.autojoin || []).join(","),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
handleChange(event) {
|
||||
handleInput(event) {
|
||||
let target = event.target;
|
||||
let value = target.type == "checkbox" ? target.checked : target.value;
|
||||
this.setState({ [target.name]: value });
|
||||
@@ -61,15 +61,15 @@ export default class ConnectForm extends Component {
|
||||
username: params.username || params.nick,
|
||||
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;
|
||||
}
|
||||
|
||||
this.state.autojoin.split(",").forEach(function(ch) {
|
||||
ch = ch.trim();
|
||||
if (!ch) {
|
||||
return;
|
||||
}
|
||||
params.autojoin.push(ch);
|
||||
});
|
||||
if (this.state.autojoin) {
|
||||
params.autojoin = this.props.params.autojoin || [];
|
||||
}
|
||||
|
||||
this.props.onSubmit(params);
|
||||
}
|
||||
@@ -107,12 +107,12 @@ export default class ConnectForm extends Component {
|
||||
`;
|
||||
} else if (this.props.error) {
|
||||
status = html`
|
||||
<p class="error-text">${this.props.error}</p>
|
||||
<p class="error-text">${linkify(this.props.error)}</p>
|
||||
`;
|
||||
}
|
||||
|
||||
let auth = null;
|
||||
if (this.props.auth !== "disabled") {
|
||||
if (this.props.auth !== "disabled" && this.props.auth !== "external" && this.props.auth !== "oauth2") {
|
||||
auth = html`
|
||||
<label>
|
||||
Password:<br/>
|
||||
@@ -129,25 +129,25 @@ export default class ConnectForm extends Component {
|
||||
`;
|
||||
}
|
||||
|
||||
let autojoin = html`
|
||||
<label>
|
||||
Auto-join channels:<br/>
|
||||
<input
|
||||
type="text"
|
||||
name="autojoin"
|
||||
value=${this.state.autojoin}
|
||||
disabled=${disabled}
|
||||
placeholder="Comma-separated list of channels"
|
||||
/>
|
||||
</label>
|
||||
<br/>
|
||||
`;
|
||||
|
||||
// Show autojoin field in advanced options, except if it's pre-filled
|
||||
let isAutojoinAdvanced = (this.props.params.autojoin || []).length === 0;
|
||||
let autojoin = null;
|
||||
let channels = this.props.params.autojoin || [];
|
||||
if (channels.length > 0) {
|
||||
let s = channels.length > 1 ? "s" : "";
|
||||
autojoin = html`
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="autojoin"
|
||||
checked=${this.state.autojoin}
|
||||
/>
|
||||
Auto-join channel${s} <strong>${channels.join(", ")}</strong>
|
||||
</label>
|
||||
<br/><br/>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<form onChange=${this.handleChange} onSubmit=${this.handleSubmit}>
|
||||
<form onInput=${this.handleInput} onSubmit=${this.handleSubmit}>
|
||||
<h2>Connect to IRC</h2>
|
||||
|
||||
<label>
|
||||
@@ -159,13 +159,14 @@ export default class ConnectForm extends Component {
|
||||
disabled=${disabled}
|
||||
ref=${this.nickInput}
|
||||
required
|
||||
autofocus
|
||||
/>
|
||||
</label>
|
||||
<br/><br/>
|
||||
|
||||
${auth}
|
||||
|
||||
${!isAutojoinAdvanced ? [autojoin, html`<br/>`] : null}
|
||||
${autojoin}
|
||||
|
||||
<label>
|
||||
<input
|
||||
@@ -212,7 +213,7 @@ export default class ConnectForm extends Component {
|
||||
<label>
|
||||
Server password:<br/>
|
||||
<input
|
||||
type="text"
|
||||
type="password"
|
||||
name="pass"
|
||||
value=${this.state.pass}
|
||||
disabled=${disabled}
|
||||
@@ -220,8 +221,6 @@ export default class ConnectForm extends Component {
|
||||
/>
|
||||
</label>
|
||||
<br/><br/>
|
||||
|
||||
${isAutojoinAdvanced ? autojoin : null}
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -26,27 +26,27 @@ function KeyBindingsHelp() {
|
||||
`;
|
||||
});
|
||||
|
||||
return html`
|
||||
<dl>
|
||||
<dt><kbd>/</kbd></dt>
|
||||
<dd>Start writing a command</dd>
|
||||
if (!window.matchMedia("(pointer: none)").matches) {
|
||||
l.push(html`
|
||||
<dt><strong>Middle mouse click</strong></dt>
|
||||
<dd>Close buffer</dd>
|
||||
`);
|
||||
}
|
||||
|
||||
${l}
|
||||
</dl>
|
||||
`;
|
||||
return html`<dl>${l}</dl>`;
|
||||
}
|
||||
|
||||
function CommandsHelp() {
|
||||
let l = Object.keys(commands).map((name) => {
|
||||
let cmd = commands[name];
|
||||
|
||||
let usage = "/" + name;
|
||||
let usage = [html`<strong>/${name}</strong>`];
|
||||
if (cmd.usage) {
|
||||
usage += " " + cmd.usage;
|
||||
usage.push(" " + cmd.usage);
|
||||
}
|
||||
|
||||
return html`
|
||||
<dt><strong><code>${usage}</code></strong></dt>
|
||||
<dt><code>${usage}</code></dt>
|
||||
<dd>${cmd.description}</dd>
|
||||
`;
|
||||
});
|
||||
|
||||
@@ -8,11 +8,15 @@ export default class JoinForm extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
this.handleInput = this.handleInput.bind(this);
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
|
||||
if (props.channel) {
|
||||
this.state.channel = props.channel;
|
||||
}
|
||||
}
|
||||
|
||||
handleChange(event) {
|
||||
handleInput(event) {
|
||||
let target = event.target;
|
||||
let value = target.type == "checkbox" ? target.checked : target.value;
|
||||
this.setState({ [target.name]: value });
|
||||
@@ -30,7 +34,7 @@ export default class JoinForm extends Component {
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<form onChange=${this.handleChange} onSubmit=${this.handleSubmit}>
|
||||
<form onInput=${this.handleInput} onSubmit=${this.handleSubmit}>
|
||||
<label>
|
||||
Channel:<br/>
|
||||
<input type="text" name="channel" value=${this.state.channel} autofocus required/>
|
||||
|
||||
@@ -13,7 +13,8 @@ class MemberItem extends Component {
|
||||
|
||||
shouldComponentUpdate(nextProps) {
|
||||
return this.props.nick !== nextProps.nick
|
||||
|| this.props.membership !== nextProps.membership;
|
||||
|| this.props.membership !== nextProps.membership
|
||||
|| this.props.user !== nextProps.user;
|
||||
}
|
||||
|
||||
handleClick(event) {
|
||||
@@ -43,6 +44,7 @@ class MemberItem extends Component {
|
||||
|
||||
let title = null;
|
||||
let user = this.props.user;
|
||||
let classes = ["nick"];
|
||||
if (user) {
|
||||
let mask = "";
|
||||
if (user.username && user.hostname) {
|
||||
@@ -61,13 +63,18 @@ class MemberItem extends Component {
|
||||
if (user.account) {
|
||||
title += `\nAuthenticated as ${user.account}`;
|
||||
}
|
||||
|
||||
if (user.away) {
|
||||
classes.push("away");
|
||||
title += "\nAway";
|
||||
}
|
||||
}
|
||||
|
||||
return html`
|
||||
<li>
|
||||
<a
|
||||
href=${getNickURL(this.props.nick)}
|
||||
class="nick"
|
||||
class=${classes.join(" ")}
|
||||
title=${title}
|
||||
onClick=${this.handleClick}
|
||||
>
|
||||
@@ -94,12 +101,13 @@ function sortMembers(a, b) {
|
||||
return i - j;
|
||||
}
|
||||
|
||||
return nickA < nickB ? -1 : 1;
|
||||
return nickA.localeCompare(nickB);
|
||||
}
|
||||
|
||||
export default class MemberList extends Component {
|
||||
shouldComponentUpdate(nextProps) {
|
||||
return this.props.members !== nextProps.members;
|
||||
return this.props.members !== nextProps.members
|
||||
|| this.props.users !== nextProps.users;
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
@@ -14,7 +14,7 @@ export default class NetworkForm extends Component {
|
||||
prevParams = null;
|
||||
state = {
|
||||
...defaultParams,
|
||||
isNew: true,
|
||||
autojoin: true,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
@@ -22,11 +22,9 @@ export default class NetworkForm extends Component {
|
||||
|
||||
this.prevParams = { ...defaultParams };
|
||||
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
this.handleInput = this.handleInput.bind(this);
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
|
||||
this.state.isNew = !props.params;
|
||||
|
||||
if (props.params) {
|
||||
Object.keys(defaultParams).forEach((k) => {
|
||||
if (props.params[k] !== undefined) {
|
||||
@@ -37,7 +35,7 @@ export default class NetworkForm extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
handleChange(event) {
|
||||
handleInput(event) {
|
||||
let target = event.target;
|
||||
let value = target.type == "checkbox" ? target.checked : target.value;
|
||||
this.setState({ [target.name]: value });
|
||||
@@ -48,18 +46,22 @@ export default class NetworkForm extends Component {
|
||||
|
||||
let params = {};
|
||||
Object.keys(defaultParams).forEach((k) => {
|
||||
if (this.prevParams[k] == this.state[k]) {
|
||||
if (!this.props.isNew && this.prevParams[k] == this.state[k]) {
|
||||
return;
|
||||
}
|
||||
if (this.props.isNew && defaultParams[k] == this.state[k]) {
|
||||
return;
|
||||
}
|
||||
params[k] = this.state[k];
|
||||
});
|
||||
|
||||
this.props.onSubmit(params);
|
||||
let autojoin = this.state.autojoin ? this.props.autojoin : null;
|
||||
this.props.onSubmit(params, autojoin);
|
||||
}
|
||||
|
||||
render() {
|
||||
let removeNetwork = null;
|
||||
if (!this.state.isNew) {
|
||||
if (!this.props.isNew) {
|
||||
removeNetwork = html`
|
||||
<button type="button" class="danger" onClick=${() => this.props.onRemove()}>
|
||||
Remove network
|
||||
@@ -67,14 +69,31 @@ 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`
|
||||
<form onChange=${this.handleChange} onSubmit=${this.handleSubmit}>
|
||||
<form onInput=${this.handleInput} onSubmit=${this.handleSubmit}>
|
||||
<label>
|
||||
Hostname:<br/>
|
||||
<input type="text" name="host" value=${this.state.host} autofocus required/>
|
||||
</label>
|
||||
<br/><br/>
|
||||
|
||||
${autojoin}
|
||||
|
||||
<details>
|
||||
<summary role="button">Advanced options</summary>
|
||||
|
||||
@@ -121,7 +140,7 @@ export default class NetworkForm extends Component {
|
||||
${removeNetwork}
|
||||
${" "}
|
||||
<button>
|
||||
${this.state.isNew ? "Add network" : "Save network"}
|
||||
${this.props.isNew ? "Add network" : "Save network"}
|
||||
</button>
|
||||
</form>
|
||||
`;
|
||||
|
||||
54
components/register-form.js
Normal file
54
components/register-form.js
Normal file
@@ -0,0 +1,54 @@
|
||||
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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,9 @@ export default class ScrollManager extends Component {
|
||||
|
||||
restoreScrollPosition() {
|
||||
let target = this.props.target.current;
|
||||
if (!target.firstChild) {
|
||||
return;
|
||||
}
|
||||
|
||||
let stickToKey = store.get(this.props.scrollKey);
|
||||
if (!stickToKey) {
|
||||
|
||||
112
components/settings-form.js
Normal file
112
components/settings-form.js
Normal file
@@ -0,0 +1,112 @@
|
||||
import { html, Component } from "../lib/index.js";
|
||||
|
||||
export default class SettingsForm extends Component {
|
||||
state = {};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state.secondsInTimestamps = props.settings.secondsInTimestamps;
|
||||
this.state.bufferEvents = props.settings.bufferEvents;
|
||||
|
||||
this.handleInput = this.handleInput.bind(this);
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
}
|
||||
|
||||
handleInput(event) {
|
||||
let target = event.target;
|
||||
let value = target.type == "checkbox" ? target.checked : target.value;
|
||||
this.setState({ [target.name]: value }, () => {
|
||||
this.props.onChange(this.state);
|
||||
});
|
||||
}
|
||||
|
||||
handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
this.props.onClose();
|
||||
}
|
||||
|
||||
registerProtocol() {
|
||||
let url = window.location.origin + window.location.pathname + "?open=%s";
|
||||
try {
|
||||
navigator.registerProtocolHandler("irc", url);
|
||||
navigator.registerProtocolHandler("ircs", url);
|
||||
} catch (err) {
|
||||
console.error("Failed to register protocol handler: ", err);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
let protocolHandler = null;
|
||||
if (this.props.showProtocolHandler) {
|
||||
protocolHandler = html`
|
||||
<div class="protocol-handler">
|
||||
<div class="left">
|
||||
Set gamja as your default IRC client for this browser.
|
||||
IRC links will be automatically opened here.
|
||||
</div>
|
||||
<div class="right">
|
||||
<button type="button" onClick=${() => this.registerProtocol()}>
|
||||
Enable
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<br/><br/>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<form onInput=${this.handleInput} onSubmit=${this.handleSubmit}>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="secondsInTimestamps"
|
||||
checked=${this.state.secondsInTimestamps}
|
||||
/>
|
||||
Show seconds in time indicator
|
||||
</label>
|
||||
<br/><br/>
|
||||
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="bufferEvents"
|
||||
value="fold"
|
||||
checked=${this.state.bufferEvents === "fold"}
|
||||
/>
|
||||
Show and fold chat events
|
||||
</label>
|
||||
<br/>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="bufferEvents"
|
||||
value="expand"
|
||||
checked=${this.state.bufferEvents === "expand"}
|
||||
/>
|
||||
Show and expand chat events
|
||||
</label>
|
||||
<br/>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="bufferEvents"
|
||||
value="hide"
|
||||
checked=${this.state.bufferEvents === "hide"}
|
||||
/>
|
||||
Hide chat events
|
||||
</label>
|
||||
<br/><br/>
|
||||
|
||||
${protocolHandler}
|
||||
|
||||
<button type="button" class="danger" onClick=${() => this.props.onDisconnect()}>
|
||||
Disconnect
|
||||
</button>
|
||||
<button>
|
||||
Close
|
||||
</button>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
}
|
||||
45
components/verify-form.js
Normal file
45
components/verify-form.js
Normal file
@@ -0,0 +1,45 @@
|
||||
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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
78
dev-server.js
Normal file
78
dev-server.js
Normal file
@@ -0,0 +1,78 @@
|
||||
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", () => {
|
||||
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);
|
||||
10
index.html
10
index.html
@@ -2,20 +2,16 @@
|
||||
<html>
|
||||
<head>
|
||||
<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>
|
||||
<link rel="stylesheet" href="./style.css">
|
||||
<script type="module" src="./main.js"></script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="manifest" href="manifest.json">
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<p>Unfortunately gamja requires JavaScript. Please enable it!</p>
|
||||
<p>This application requires JavaScript. Please enable it!</p>
|
||||
</noscript>
|
||||
<script type="module">
|
||||
import { html, render } from "./lib/index.js";
|
||||
import App from "./components/app.js";
|
||||
|
||||
render(html`<${App}/>`, document.body);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReceiptType, Unread, BufferType, SERVER_BUFFER } from "./state.js";
|
||||
import { ReceiptType, Unread, BufferType, SERVER_BUFFER, receiptFromMessage } from "./state.js";
|
||||
|
||||
function getSiblingBuffer(buffers, bufID, delta) {
|
||||
let bufList = Array.from(buffers.values());
|
||||
@@ -19,22 +19,24 @@ export const keybindings = [
|
||||
app.setState((state) => {
|
||||
let buffers = new Map();
|
||||
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, {
|
||||
...buf,
|
||||
unread: Unread.NONE,
|
||||
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);
|
||||
app.bufferStore.put({
|
||||
name: buf.name,
|
||||
server: client.params,
|
||||
unread: Unread.NONE,
|
||||
receipts,
|
||||
});
|
||||
});
|
||||
return { buffers };
|
||||
|
||||
24
lib/ansi.js
24
lib/ansi.js
@@ -10,10 +10,26 @@ const COLOR_HEX = "\x04";
|
||||
const REVERSE_COLOR = "\x16";
|
||||
const RESET = "\x0F";
|
||||
|
||||
const HEX_COLOR_LENGTH = 6;
|
||||
|
||||
function isDigit(ch) {
|
||||
return ch >= "0" && ch <= "9";
|
||||
}
|
||||
|
||||
function isHexColor(text) {
|
||||
if (text.length < HEX_COLOR_LENGTH) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < HEX_COLOR_LENGTH; i++) {
|
||||
let ch = text[i].toUpperCase();
|
||||
let ok = (ch >= "0" && ch <= "9") || (ch >= "A" && ch <= "F");
|
||||
if (!ok) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function strip(text) {
|
||||
let out = "";
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
@@ -43,7 +59,13 @@ export function strip(text) {
|
||||
}
|
||||
break;
|
||||
case COLOR_HEX:
|
||||
i += 6;
|
||||
if (!isHexColor(text.slice(i + 1))) {
|
||||
break;
|
||||
}
|
||||
i += HEX_COLOR_LENGTH;
|
||||
if (text[i + 1] == "," && isHexColor(text.slice(i + 2))) {
|
||||
i += 1 + HEX_COLOR_LENGTH;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
out += ch;
|
||||
|
||||
42
lib/base64.js
Normal file
42
lib/base64.js
Normal file
@@ -0,0 +1,42 @@
|
||||
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
/* The JS world is still in the stone age. We're in 2022 and we still don't
|
||||
* have the technology to correctly base64-encode a UTF-8 string. Can't wait
|
||||
* the next industrial revolution.
|
||||
*
|
||||
* For more info, see:
|
||||
* https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem
|
||||
*/
|
||||
export function encode(data) {
|
||||
if (!window.TextEncoder) {
|
||||
return btoa(data);
|
||||
}
|
||||
|
||||
var encoder = new TextEncoder();
|
||||
var bytes = encoder.encode(data);
|
||||
|
||||
var trailing = bytes.length % 3;
|
||||
var out = "";
|
||||
for (var i = 0; i < bytes.length - trailing; i += 3) {
|
||||
var u24 = (bytes[i] << 16) + (bytes[i + 1] << 8) + bytes[i + 2];
|
||||
out += alphabet[(u24 >> 18) & 0x3F];
|
||||
out += alphabet[(u24 >> 12) & 0x3F];
|
||||
out += alphabet[(u24 >> 6) & 0x3F];
|
||||
out += alphabet[u24 & 0x3F];
|
||||
}
|
||||
|
||||
if (trailing == 1) {
|
||||
var u8 = bytes[bytes.length - 1];
|
||||
out += alphabet[u8 >> 2];
|
||||
out += alphabet[(u8 << 4) & 0x3F];
|
||||
out += "==";
|
||||
} else if (trailing == 2) {
|
||||
var u16 = (bytes[bytes.length - 2] << 8) + bytes[bytes.length - 1];
|
||||
out += alphabet[u16 >> 10];
|
||||
out += alphabet[(u16 >> 4) & 0x3F];
|
||||
out += alphabet[(u16 << 2) & 0x3F];
|
||||
out += "=";
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
632
lib/client.js
632
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";
|
||||
export const html = htm.bind(h);
|
||||
|
||||
import "../node_modules/anchorme/dist/browser/anchorme.min.js";
|
||||
export const anchorme = window.anchorme;
|
||||
import * as linkifyjs from "../node_modules/linkifyjs/dist/linkify.module.js";
|
||||
export { linkifyjs };
|
||||
|
||||
340
lib/irc.js
340
lib/irc.js
@@ -4,6 +4,9 @@ export const RPL_YOURHOST = "002";
|
||||
export const RPL_CREATED = "003";
|
||||
export const RPL_MYINFO = "004";
|
||||
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_WHOISSERVER = "312";
|
||||
export const RPL_WHOISOPERATOR = "313";
|
||||
@@ -12,7 +15,6 @@ export const RPL_ENDOFWHOIS = "318";
|
||||
export const RPL_WHOISCHANNELS = "319";
|
||||
export const RPL_ENDOFWHO = "315";
|
||||
export const RPL_CHANNELMODEIS = "324";
|
||||
export const RPL_CREATIONTIME = "329";
|
||||
export const RPL_NOTOPIC = "331";
|
||||
export const RPL_TOPIC = "332";
|
||||
export const RPL_TOPICWHOTIME = "333";
|
||||
@@ -30,17 +32,28 @@ export const RPL_ENDOFBANLIST = "368";
|
||||
export const RPL_MOTD = "372";
|
||||
export const RPL_MOTDSTART = "375";
|
||||
export const RPL_ENDOFMOTD = "376";
|
||||
export const ERR_UNKNOWNERROR = "400";
|
||||
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_ERRONEUSNICKNAME = "432";
|
||||
export const ERR_NICKNAMEINUSE = "433";
|
||||
export const ERR_NICKCOLLISION = "436";
|
||||
export const ERR_NEEDMOREPARAMS = "461";
|
||||
export const ERR_NOPERMFORHOST = "463";
|
||||
export const ERR_PASSWDMISMATCH = "464";
|
||||
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
|
||||
export const ERR_UNAVAILRESOURCE = "437";
|
||||
// Other
|
||||
export const RPL_CHANNEL_URL = "328";
|
||||
export const RPL_CREATIONTIME = "329";
|
||||
export const RPL_QUIETLIST = "728";
|
||||
export const RPL_ENDOFQUIETLIST = "729";
|
||||
// IRCv3 MONITOR: https://ircv3.net/specs/extensions/monitor
|
||||
@@ -61,7 +74,6 @@ export const ERR_SASLALREADY = "907";
|
||||
|
||||
export const STD_MEMBERSHIPS = "~&@%+";
|
||||
export const STD_CHANTYPES = "#&+!";
|
||||
export const STD_CHANMODES = "beI,k,l,imnst";
|
||||
|
||||
const tagEscapeMap = {
|
||||
";": "\\:",
|
||||
@@ -121,32 +133,32 @@ export function parsePrefix(s) {
|
||||
host: null,
|
||||
};
|
||||
|
||||
let host = null;
|
||||
let i = s.indexOf("@");
|
||||
if (i < 0) {
|
||||
prefix.name = s;
|
||||
return prefix;
|
||||
if (i > 0) {
|
||||
host = s.slice(i + 1);
|
||||
s = s.slice(0, i);
|
||||
}
|
||||
prefix.host = s.slice(i + 1);
|
||||
s = s.slice(0, i);
|
||||
|
||||
let user = null;
|
||||
i = s.indexOf("!");
|
||||
if (i < 0) {
|
||||
prefix.name = s;
|
||||
return prefix;
|
||||
if (i > 0) {
|
||||
user = s.slice(i + 1);
|
||||
s = s.slice(0, i);
|
||||
}
|
||||
prefix.name = s.slice(0, i);
|
||||
prefix.user = s.slice(i + 1);
|
||||
return prefix;
|
||||
|
||||
return { name: s, user, host };
|
||||
}
|
||||
|
||||
function formatPrefix(prefix) {
|
||||
if (!prefix.host) {
|
||||
return prefix.name;
|
||||
let s = prefix.name;
|
||||
if (prefix.user) {
|
||||
s += "!" + prefix.user;
|
||||
}
|
||||
if (!prefix.user) {
|
||||
return prefix.name + "@" + prefix.host;
|
||||
if (prefix.host) {
|
||||
s += "@" + prefix.host;
|
||||
}
|
||||
return prefix.name + "!" + prefix.user + "@" + prefix.host;
|
||||
return s;
|
||||
}
|
||||
|
||||
export function parseMessage(s) {
|
||||
@@ -247,7 +259,7 @@ export function parseTargetPrefix(s, allowedPrefixes = STD_MEMBERSHIPS) {
|
||||
|
||||
const alphaNum = (() => {
|
||||
try {
|
||||
return new RegExp(/^\p{L}$/, "u");
|
||||
return new RegExp(/^[\p{L}0-9]$/, "u");
|
||||
} catch (e) {
|
||||
return new RegExp(/^[a-zA-Z0-9]$/, "u");
|
||||
}
|
||||
@@ -259,8 +271,6 @@ function isWordBoundary(ch) {
|
||||
case "_":
|
||||
case "|":
|
||||
return false;
|
||||
case "\u00A0":
|
||||
return true;
|
||||
default:
|
||||
return !alphaNum.test(ch);
|
||||
}
|
||||
@@ -289,7 +299,7 @@ export function isHighlight(msg, nick, cm) {
|
||||
if (i > 0) {
|
||||
left = text[i - 1];
|
||||
}
|
||||
if (i < text.length) {
|
||||
if (i + nick.length < text.length) {
|
||||
right = text[i + nick.length];
|
||||
}
|
||||
if (isWordBoundary(left) && isWordBoundary(right)) {
|
||||
@@ -363,28 +373,142 @@ export function parseCTCP(msg) {
|
||||
return ctcp;
|
||||
}
|
||||
|
||||
export function parseISUPPORT(tokens, params) {
|
||||
let changed = [];
|
||||
tokens.forEach((tok) => {
|
||||
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);
|
||||
function unescapeISUPPORTValue(s) {
|
||||
return s.replace(/\\x[0-9A-Z]{2}/gi, (esc) => {
|
||||
let hex = esc.slice(2);
|
||||
return String.fromCharCode(parseInt(hex, 16));
|
||||
});
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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 = {
|
||||
@@ -593,11 +717,10 @@ export function getMessageLabel(msg) {
|
||||
}
|
||||
|
||||
export function forEachChannelModeUpdate(msg, isupport, callback) {
|
||||
let chanmodes = isupport.get("CHANMODES") || STD_CHANMODES;
|
||||
let prefix = isupport.get("PREFIX") || "";
|
||||
let [a, b, c, d] = isupport.chanModes();
|
||||
let prefix = isupport.prefix();
|
||||
|
||||
let typeByMode = new Map();
|
||||
let [a, b, c, d] = chanmodes.split(",");
|
||||
Array.from(a).forEach((mode) => typeByMode.set(mode, "A"));
|
||||
Array.from(b).forEach((mode) => typeByMode.set(mode, "B"));
|
||||
Array.from(c).forEach((mode) => typeByMode.set(mode, "C"));
|
||||
@@ -640,6 +763,8 @@ export function forEachChannelModeUpdate(msg, isupport, callback) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a realname is worth displaying.
|
||||
*
|
||||
* Since the realname is mandatory, many clients set a meaningless realname.
|
||||
*/
|
||||
export function isMeaningfulRealname(realname, nick) {
|
||||
@@ -647,7 +772,7 @@ export function isMeaningfulRealname(realname, nick) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (realname.toLowerCase() === "realname") {
|
||||
if (realname.toLowerCase() === "realname" || realname.toLowerCase() === "unknown" || realname.toLowerCase() === "fullname") {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -655,3 +780,124 @@ export function isMeaningfulRealname(realname, nick) {
|
||||
|
||||
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 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(" ")] };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,65 +1,68 @@
|
||||
import { anchorme, html } from "./index.js";
|
||||
import { linkifyjs, html } from "./index.js";
|
||||
|
||||
function linkifyChannel(text, transformChannel) {
|
||||
// Don't match punctuation at the end of the channel name
|
||||
const channelRegex = /(^|\s)(#[^\s]+[^\s.?!…():;,])/gi;
|
||||
linkifyjs.options.defaults.defaultProtocol = "https";
|
||||
|
||||
let children = [];
|
||||
let match;
|
||||
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;
|
||||
linkifyjs.registerCustomProtocol("irc");
|
||||
linkifyjs.registerCustomProtocol("ircs");
|
||||
|
||||
children.push(text.substring(last, start));
|
||||
children.push(transformChannel(channel));
|
||||
linkifyjs.registerPlugin("ircChannel", ({ scanner, parser, utils }) => {
|
||||
const { POUND, DOMAIN, TLD, LOCALHOST, UNDERSCORE, DOT, HYPHEN } = scanner.tokens;
|
||||
const START_STATE = parser.start;
|
||||
|
||||
last = end;
|
||||
}
|
||||
children.push(text.substring(last));
|
||||
const Channel = utils.createTokenClass("ircChannel", {
|
||||
isLink: true,
|
||||
toHref() {
|
||||
return "irc:///" + this.toString();
|
||||
},
|
||||
});
|
||||
|
||||
return children;
|
||||
}
|
||||
const HASH_STATE = START_STATE.tt(POUND);
|
||||
|
||||
export default function linkify(text, onChannelClick) {
|
||||
function transformChannel(channel) {
|
||||
function onClick(event) {
|
||||
event.preventDefault();
|
||||
onChannelClick(channel);
|
||||
}
|
||||
return html`
|
||||
<a
|
||||
href="irc:///${encodeURIComponent(channel)}"
|
||||
onClick=${onClick}
|
||||
>${channel}</a>`;
|
||||
}
|
||||
const CHAN_STATE = HASH_STATE.tt(DOMAIN, Channel);
|
||||
HASH_STATE.tt(TLD, CHAN_STATE);
|
||||
HASH_STATE.tt(LOCALHOST, CHAN_STATE);
|
||||
HASH_STATE.tt(POUND, CHAN_STATE);
|
||||
|
||||
let links = anchorme.list(text);
|
||||
CHAN_STATE.tt(UNDERSCORE, CHAN_STATE);
|
||||
CHAN_STATE.tt(DOMAIN, CHAN_STATE);
|
||||
CHAN_STATE.tt(TLD, CHAN_STATE);
|
||||
CHAN_STATE.tt(LOCALHOST, CHAN_STATE);
|
||||
|
||||
const CHAN_DIVIDER_STATE = CHAN_STATE.tt(DOT);
|
||||
|
||||
CHAN_DIVIDER_STATE.tt(UNDERSCORE, CHAN_STATE);
|
||||
CHAN_DIVIDER_STATE.tt(DOMAIN, CHAN_STATE);
|
||||
CHAN_DIVIDER_STATE.tt(TLD, CHAN_STATE);
|
||||
CHAN_DIVIDER_STATE.tt(LOCALHOST, CHAN_STATE);
|
||||
});
|
||||
|
||||
export default function linkify(text, onClick) {
|
||||
let links = linkifyjs.find(text);
|
||||
|
||||
let children = [];
|
||||
let last = 0;
|
||||
links.forEach((match) => {
|
||||
if (!match.isLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prefix = text.substring(last, match.start)
|
||||
children.push(...linkifyChannel(prefix, transformChannel));
|
||||
children.push(prefix);
|
||||
|
||||
let proto = match.protocol || "https://";
|
||||
if (match.isEmail) {
|
||||
proto = "mailto:";
|
||||
}
|
||||
|
||||
let url = match.string;
|
||||
if (!url.startsWith(proto)) {
|
||||
url = proto + url;
|
||||
}
|
||||
|
||||
children.push(html`<a href=${url} target="_blank" rel="noreferrer noopener">${match.string}</a>`);
|
||||
children.push(html`
|
||||
<a
|
||||
href=${match.href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
onClick=${onClick}
|
||||
>${match.value}</a>
|
||||
`);
|
||||
|
||||
last = match.end;
|
||||
});
|
||||
|
||||
const suffix = text.substring(last)
|
||||
children.push(...linkifyChannel(suffix, transformChannel));
|
||||
children.push(suffix);
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
109
lib/oauth2.js
Normal file
109
lib/oauth2.js
Normal file
@@ -0,0 +1,109 @@
|
||||
function formatQueryString(params) {
|
||||
let l = [];
|
||||
for (let k in params) {
|
||||
l.push(encodeURIComponent(k) + "=" + encodeURIComponent(params[k]));
|
||||
}
|
||||
return l.join("&");
|
||||
}
|
||||
|
||||
export async function fetchServerMetadata(url) {
|
||||
// TODO: handle path in config.oauth2.url
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(url + "/.well-known/oauth-authorization-server");
|
||||
if (!resp.ok) {
|
||||
throw new Error(`HTTP error: ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("OAuth 2.0 server doesn't support Authorization Server Metadata (retrying with OpenID Connect Discovery): ", err);
|
||||
resp = await fetch(url + "/.well-known/openid-configuration");
|
||||
if (!resp.ok) {
|
||||
throw new Error(`HTTP error: ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
let data = await resp.json();
|
||||
if (!data.issuer) {
|
||||
throw new Error("Missing issuer in response");
|
||||
}
|
||||
if (!data.authorization_endpoint) {
|
||||
throw new Error("Missing authorization_endpoint in response");
|
||||
}
|
||||
if (!data.token_endpoint) {
|
||||
throw new Error("Missing authorization_endpoint in response");
|
||||
}
|
||||
if (!data.response_types_supported.includes("code")) {
|
||||
throw new Error("Server doesn't support authorization code response type");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export function redirectAuthorize({ serverMetadata, clientId, redirectUri, scope }) {
|
||||
// TODO: move fragment to query string in redirect_uri
|
||||
// TODO: use the state param to prevent cross-site request
|
||||
// forgery
|
||||
let params = {
|
||||
response_type: "code",
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
};
|
||||
if (scope) {
|
||||
params.scope = scope;
|
||||
}
|
||||
window.location.assign(serverMetadata.authorization_endpoint + "?" + formatQueryString(params));
|
||||
}
|
||||
|
||||
function buildPostHeaders(clientId, clientSecret) {
|
||||
let headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json",
|
||||
};
|
||||
if (clientSecret) {
|
||||
headers["Authorization"] = "Basic " + btoa(encodeURIComponent(clientId) + ":" + encodeURIComponent(clientSecret));
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
export async function exchangeCode({ serverMetadata, redirectUri, code, clientId, clientSecret }) {
|
||||
let data = {
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
};
|
||||
if (!clientSecret) {
|
||||
data.client_id = clientId;
|
||||
}
|
||||
|
||||
let resp = await fetch(serverMetadata.token_endpoint, {
|
||||
method: "POST",
|
||||
headers: buildPostHeaders(clientId, clientSecret),
|
||||
body: formatQueryString(data),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error(`HTTP error: ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
data = await resp.json();
|
||||
|
||||
if (data.error) {
|
||||
throw new Error("Authentication failed: " + (data.error_description || data.error));
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function introspectToken({ serverMetadata, token, clientId, clientSecret }) {
|
||||
let resp = await fetch(serverMetadata.introspection_endpoint, {
|
||||
method: "POST",
|
||||
headers: buildPostHeaders(clientId, clientSecret),
|
||||
body: formatQueryString({ token }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`HTTP error: ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
let data = await resp.json();
|
||||
if (!data.active) {
|
||||
throw new Error("Expired token");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
4
main.js
Normal file
4
main.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import { html, render } from "./lib/index.js";
|
||||
import App from "./components/app.js";
|
||||
|
||||
render(html`<${App}/>`, document.body);
|
||||
4820
package-lock.json
generated
4820
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
24
package.json
24
package.json
@@ -1,15 +1,31 @@
|
||||
{
|
||||
"name": "gamja",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"anchorme": "^2.1.2",
|
||||
"htm": "^3.0.4",
|
||||
"linkifyjs": "^3.0.2",
|
||||
"preact": "^10.5.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"http-server": "^13.0.2"
|
||||
"@parcel/packager-raw-url": "^2.0.0",
|
||||
"@parcel/transformer-webmanifest": "^2.0.0",
|
||||
"node-static": "^0.7.11",
|
||||
"parcel": "^2.0.0",
|
||||
"split": "^1.0.1",
|
||||
"ws": "^8.3.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "http-server ."
|
||||
"start": "node ./dev-server.js",
|
||||
"build": "parcel build"
|
||||
},
|
||||
"private": true
|
||||
"private": true,
|
||||
"targets": {
|
||||
"default": {
|
||||
"source": "index.html",
|
||||
"publicUrl": "."
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.13.0"
|
||||
}
|
||||
}
|
||||
|
||||
218
state.js
218
state.js
@@ -1,5 +1,6 @@
|
||||
import * as irc from "./lib/irc.js";
|
||||
import Client from "./lib/client.js";
|
||||
import { createContext } from "./lib/index.js";
|
||||
|
||||
export const SERVER_BUFFER = "*";
|
||||
|
||||
@@ -34,6 +35,14 @@ export const ReceiptType = {
|
||||
READ: "read",
|
||||
};
|
||||
|
||||
export const BufferEventsDisplayMode = {
|
||||
FOLD: "fold",
|
||||
EXPAND: "expand",
|
||||
HIDE: "hide",
|
||||
};
|
||||
|
||||
export const SettingsContext = createContext("settings");
|
||||
|
||||
export function getNickURL(nick) {
|
||||
return "irc:///" + encodeURIComponent(nick) + ",isuser";
|
||||
}
|
||||
@@ -63,20 +72,62 @@ export function getMessageURL(buf, msg) {
|
||||
}
|
||||
}
|
||||
|
||||
export function getServerName(server, bouncerNetwork, isBouncer) {
|
||||
if (bouncerNetwork && bouncerNetwork.name) {
|
||||
export function getServerName(server, bouncerNetwork) {
|
||||
let netName = server.name;
|
||||
|
||||
if (bouncerNetwork && bouncerNetwork.name && bouncerNetwork.name !== bouncerNetwork.host) {
|
||||
// User has picked a custom name for the network, use that
|
||||
return bouncerNetwork.name;
|
||||
}
|
||||
if (isBouncer) {
|
||||
return "bouncer";
|
||||
}
|
||||
|
||||
let netName = server.isupport.get("NETWORK");
|
||||
if (netName) {
|
||||
// Server has specified a name
|
||||
return netName;
|
||||
}
|
||||
|
||||
return "server";
|
||||
if (bouncerNetwork) {
|
||||
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) {
|
||||
@@ -106,13 +157,13 @@ function compareBuffers(a, b) {
|
||||
return isServerBuffer(b) ? 1 : -1;
|
||||
}
|
||||
if (a.name != b.name) {
|
||||
return a.name > b.name ? 1 : -1;
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function updateMembership(membership, letter, add, client) {
|
||||
let prefix = client.isupport.get("PREFIX") || "";
|
||||
let prefix = client.isupport.prefix();
|
||||
|
||||
let prefixPrivs = new Map(irc.parseMembershipModes(prefix).map((membership, i) => {
|
||||
return [membership.prefix, i];
|
||||
@@ -137,7 +188,7 @@ function updateMembership(membership, letter, add, client) {
|
||||
function insertMessage(list, msg) {
|
||||
if (list.length == 0) {
|
||||
return [msg];
|
||||
} else if (list[list.length - 1].tags.time <= msg.tags.time) {
|
||||
} else if (!irc.findBatchByType(msg, "chathistory") || list[list.length - 1].tags.time <= msg.tags.time) {
|
||||
return list.concat(msg);
|
||||
}
|
||||
|
||||
@@ -166,6 +217,11 @@ export const State = {
|
||||
servers: new Map(),
|
||||
buffers: new Map(),
|
||||
activeBuffer: null,
|
||||
bouncerNetworks: new Map(),
|
||||
settings: {
|
||||
secondsInTimestamps: true,
|
||||
bufferEvents: BufferEventsDisplayMode.FOLD,
|
||||
},
|
||||
};
|
||||
},
|
||||
updateServer(state, id, updater) {
|
||||
@@ -225,7 +281,7 @@ export const State = {
|
||||
let cm = irc.CaseMapping.RFC1459;
|
||||
let server = state.servers.get(serverID);
|
||||
if (server) {
|
||||
cm = irc.CaseMapping.byName(server.isupport.get("CASEMAPPING")) || cm;
|
||||
cm = server.cm;
|
||||
}
|
||||
|
||||
let nameCM = cm(name);
|
||||
@@ -246,9 +302,17 @@ export const State = {
|
||||
let servers = new Map(state.servers);
|
||||
servers.set(id, {
|
||||
id,
|
||||
name: null, // from ISUPPORT NETWORK
|
||||
status: ServerStatus.DISCONNECTED,
|
||||
isupport: new Map(),
|
||||
cm: 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 }];
|
||||
},
|
||||
@@ -277,7 +341,9 @@ export const State = {
|
||||
type,
|
||||
server: serverID,
|
||||
serverInfo: null, // if server
|
||||
joined: false, // if channel
|
||||
topic: null, // if channel
|
||||
hasInitialWho: false, // if channel
|
||||
members: new irc.CaseMapMap(null, client.cm), // if channel
|
||||
messages: [],
|
||||
unread: Unread.NONE,
|
||||
@@ -287,6 +353,19 @@ export const State = {
|
||||
let buffers = new Map(bufferList.map((buf) => [buf.id, buf]));
|
||||
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) {
|
||||
function updateServer(updater) {
|
||||
return State.updateServer(state, serverID, updater);
|
||||
@@ -311,7 +390,7 @@ export const State = {
|
||||
return;
|
||||
}
|
||||
|
||||
let target, channel, topic, targets, who;
|
||||
let target, channel, topic, targets, who, update, buffers;
|
||||
switch (msg.command) {
|
||||
case irc.RPL_MYINFO:
|
||||
// TODO: parse available modes
|
||||
@@ -321,7 +400,7 @@ export const State = {
|
||||
};
|
||||
return updateBuffer(SERVER_BUFFER, { serverInfo });
|
||||
case irc.RPL_ISUPPORT:
|
||||
let buffers = new Map(state.buffers);
|
||||
buffers = new Map(state.buffers);
|
||||
state.buffers.forEach((buf) => {
|
||||
if (buf.server != serverID) {
|
||||
return;
|
||||
@@ -333,11 +412,31 @@ export const State = {
|
||||
buffers,
|
||||
...updateServer((server) => {
|
||||
return {
|
||||
isupport: new Map(client.isupport),
|
||||
name: client.isupport.network(),
|
||||
cm: 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:
|
||||
channel = msg.params[1];
|
||||
return updateBuffer(channel, { topic: null });
|
||||
@@ -348,19 +447,19 @@ export const State = {
|
||||
case irc.RPL_TOPICWHOTIME:
|
||||
// Ignore
|
||||
break;
|
||||
case irc.RPL_NAMREPLY:
|
||||
channel = msg.params[2];
|
||||
let membersList = msg.params[3].split(" ");
|
||||
|
||||
case irc.RPL_ENDOFNAMES:
|
||||
channel = msg.params[1];
|
||||
return updateBuffer(channel, (buf) => {
|
||||
let members = new irc.CaseMapMap(buf.members);
|
||||
membersList.forEach((s) => {
|
||||
let member = irc.parseTargetPrefix(s);
|
||||
members.set(member.name, member.prefix);
|
||||
let members = new irc.CaseMapMap(null, buf.members.caseMap);
|
||||
msg.list.forEach((namreply) => {
|
||||
let membersList = namreply.params[3].split(" ");
|
||||
membersList.forEach((s) => {
|
||||
let member = irc.parseTargetPrefix(s);
|
||||
members.set(member.name, member.prefix);
|
||||
});
|
||||
});
|
||||
return { members };
|
||||
});
|
||||
case irc.RPL_ENDOFNAMES:
|
||||
break;
|
||||
case irc.RPL_WHOREPLY:
|
||||
case irc.RPL_WHOSPCRPL:
|
||||
@@ -368,6 +467,11 @@ export const State = {
|
||||
|
||||
if (who.flags !== undefined) {
|
||||
who.away = who.flags.indexOf("G") >= 0; // H for here, G for gone
|
||||
who.operator = who.flags.indexOf("*") >= 0;
|
||||
let botFlag = client.isupport.bot();
|
||||
if (botFlag) {
|
||||
who.bot = who.flags.indexOf(botFlag) >= 0;
|
||||
}
|
||||
delete who.flags;
|
||||
}
|
||||
|
||||
@@ -376,14 +480,9 @@ export const State = {
|
||||
return updateUser(who.nick, who);
|
||||
case irc.RPL_ENDOFWHO:
|
||||
target = msg.params[1];
|
||||
if (!client.isChannel(target) && target.indexOf("*") < 0) {
|
||||
if (msg.list.length == 0 && !client.isChannel(target) && target.indexOf("*") < 0) {
|
||||
// Not a channel nor a mask, likely a nick
|
||||
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 };
|
||||
});
|
||||
}
|
||||
@@ -396,10 +495,13 @@ export const State = {
|
||||
state = { ...state, ...update };
|
||||
}
|
||||
|
||||
let update = updateBuffer(channel, (buf) => {
|
||||
update = updateBuffer(channel, (buf) => {
|
||||
let members = new irc.CaseMapMap(buf.members);
|
||||
members.set(msg.prefix.name, "");
|
||||
return { members };
|
||||
|
||||
let joined = buf.joined || client.isMyNick(msg.prefix.name);
|
||||
|
||||
return { members, joined };
|
||||
});
|
||||
state = { ...state, ...update };
|
||||
|
||||
@@ -427,7 +529,10 @@ export const State = {
|
||||
return updateBuffer(channel, (buf) => {
|
||||
let members = new irc.CaseMapMap(buf.members);
|
||||
members.delete(msg.prefix.name);
|
||||
return { members };
|
||||
|
||||
let joined = buf.joined && !client.isMyNick(msg.prefix.name);
|
||||
|
||||
return { members, joined };
|
||||
});
|
||||
case "KICK":
|
||||
channel = msg.params[0];
|
||||
@@ -436,18 +541,54 @@ export const State = {
|
||||
return updateBuffer(channel, (buf) => {
|
||||
let members = new irc.CaseMapMap(buf.members);
|
||||
members.delete(nick);
|
||||
return { members };
|
||||
|
||||
let joined = buf.joined && !client.isMyNick(nick);
|
||||
|
||||
return { members, joined };
|
||||
});
|
||||
case "QUIT":
|
||||
return updateUser(msg.prefix.name, (user) => {
|
||||
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.delete(msg.prefix.name);
|
||||
buffers.set(buf.id, { ...buf, members });
|
||||
});
|
||||
state = { ...state, buffers };
|
||||
|
||||
update = updateUser(msg.prefix.name, (user) => {
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
return { offline: true };
|
||||
});
|
||||
state = { ...state, ...update };
|
||||
|
||||
return state;
|
||||
case "NICK":
|
||||
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 user = users.get(msg.prefix.name);
|
||||
if (!user) {
|
||||
@@ -457,6 +598,9 @@ export const State = {
|
||||
users.delete(msg.prefix.name);
|
||||
return { users };
|
||||
});
|
||||
state = { ...state, ...update };
|
||||
|
||||
return state;
|
||||
case "SETNAME":
|
||||
return updateUser(msg.prefix.name, { realname: msg.params[0] });
|
||||
case "CHGHOST":
|
||||
@@ -484,7 +628,7 @@ export const State = {
|
||||
return; // TODO: handle user mode changes too
|
||||
}
|
||||
|
||||
let prefix = client.isupport.get("PREFIX") || "";
|
||||
let prefix = client.isupport.prefix();
|
||||
let prefixByMode = new Map(irc.parseMembershipModes(prefix).map((membership) => {
|
||||
return [membership.mode, membership.prefix];
|
||||
}));
|
||||
|
||||
73
store.js
73
store.js
@@ -1,3 +1,5 @@
|
||||
import { ReceiptType } from "./state.js";
|
||||
|
||||
const PREFIX = "gamja_";
|
||||
|
||||
class Item {
|
||||
@@ -23,18 +25,19 @@ class Item {
|
||||
}
|
||||
|
||||
export const autoconnect = new Item("autoconnect");
|
||||
export const naggedProtocolHandler = new Item("naggedProtocolHandler");
|
||||
export const settings = new Item("settings");
|
||||
|
||||
const rawReceipts = new Item("receipts");
|
||||
|
||||
export const receipts = {
|
||||
load() {
|
||||
let v = rawReceipts.load();
|
||||
return new Map(Object.entries(v || {}));
|
||||
},
|
||||
put(m) {
|
||||
rawReceipts.put(Object.fromEntries(m));
|
||||
},
|
||||
};
|
||||
function debounce(f, delay) {
|
||||
let timeout = null;
|
||||
return (...args) => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
timeout = null;
|
||||
f(...args);
|
||||
}, delay);
|
||||
};
|
||||
}
|
||||
|
||||
export class Buffer {
|
||||
raw = new Item("buffers");
|
||||
@@ -43,14 +46,24 @@ export class Buffer {
|
||||
constructor() {
|
||||
let obj = this.raw.load();
|
||||
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) {
|
||||
// TODO: use case-mapping here somehow
|
||||
return JSON.stringify({
|
||||
name: buf.name,
|
||||
name: buf.name.toLowerCase(),
|
||||
server: {
|
||||
url: buf.server.url,
|
||||
nick: buf.server.nick,
|
||||
nick: buf.server.nick.toLowerCase(),
|
||||
bouncerNetwork: buf.server.bouncerNetwork,
|
||||
},
|
||||
});
|
||||
@@ -71,14 +84,39 @@ export class Buffer {
|
||||
put(buf) {
|
||||
let key = this.key(buf);
|
||||
|
||||
let prev = this.m.get(key);
|
||||
if (prev && prev.unread === buf.unread) {
|
||||
return;
|
||||
let updated = !this.m.has(key);
|
||||
let prev = this.m.get(key) || {};
|
||||
|
||||
let unread = prev.unread;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
if (!updated) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.m.set(this.key(buf), {
|
||||
name: buf.name,
|
||||
unread: buf.unread,
|
||||
unread,
|
||||
receipts,
|
||||
server: {
|
||||
url: buf.server.url,
|
||||
nick: buf.server.nick,
|
||||
@@ -87,6 +125,7 @@ export class Buffer {
|
||||
});
|
||||
|
||||
this.save();
|
||||
return true;
|
||||
}
|
||||
|
||||
delete(buf) {
|
||||
|
||||
28
style.css
28
style.css
@@ -58,6 +58,13 @@ body {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
noscript {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
max-width: 600px;
|
||||
grid-column-start: 2;
|
||||
}
|
||||
|
||||
button {
|
||||
background: var(--button-background);
|
||||
transition: background 0.25s linear;
|
||||
@@ -151,6 +158,9 @@ button.danger:hover {
|
||||
color: white;
|
||||
background-color: var(--gray);
|
||||
}
|
||||
#buffer-list li.error a {
|
||||
color: red;
|
||||
}
|
||||
#buffer-list li.unread-message a {
|
||||
color: #b37400;
|
||||
}
|
||||
@@ -179,7 +189,7 @@ button.danger:hover {
|
||||
grid-column: 2;
|
||||
|
||||
display: grid;
|
||||
grid-template-rows: auto auto;
|
||||
grid-template-rows: 1fr auto;
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
@@ -195,6 +205,9 @@ button.danger:hover {
|
||||
padding: 5px 10px;
|
||||
grid-row: 2;
|
||||
grid-column: 1;
|
||||
max-height: 20vh;
|
||||
overflow-y: auto;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
#buffer-header .actions {
|
||||
@@ -279,6 +292,9 @@ button.danger:hover {
|
||||
padding: 2px 10px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
#member-list li a.away {
|
||||
color: var(--gray);
|
||||
}
|
||||
|
||||
.membership.owner {
|
||||
color: red;
|
||||
@@ -339,6 +355,8 @@ form input[type="url"],
|
||||
form input[type="email"] {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
a {
|
||||
@@ -520,6 +538,14 @@ details summary[role="button"] {
|
||||
overflow: auto; /* hack to clear floating elements */
|
||||
}
|
||||
|
||||
.dialog .protocol-handler {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
.dialog .protocol-handler .left {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
kbd {
|
||||
background-color: #f0f0f0;
|
||||
border: 1px solid #bfbfbf;
|
||||
|
||||
Reference in New Issue
Block a user