56 Commits

Author SHA1 Message Date
77ccba04ba Actualizar .gitea/workflows/build.yml
All checks were successful
Bun Package / publish (push) Successful in 1m15s
2026-02-27 12:20:59 -03:00
e68534a846 Actualizar .gitea/workflows/build.yml
Some checks failed
Bun Package / publish (push) Has been cancelled
2026-02-27 12:18:54 -03:00
5b96dd0d8b Actualizar .gitea/workflows/build.yml 2026-02-27 12:17:50 -03:00
4c245ad49e master (#1)
Reviewed-on: fede/gamja#1
2026-02-27 12:13:43 -03:00
Simon Ser
bdfde7f1e0 Upgrade dependencies 2026-01-13 23:14:34 +01:00
Simon Ser
223b9b1531 composer: show error bubble on file upload error 2026-01-13 23:11:22 +01:00
Simon Ser
8aae4a2b07 Upgrade dependencies 2025-12-07 10:20:48 +01:00
handlerug
910ce284a2 components/switcher-form: listen to keydown events instead of keyup
This reduces perceived input delay and allows key repeat to occur.

Co-authored-by: handlerug <handlerug@handlerug.me>
Co-committed-by: handlerug <handlerug@handlerug.me>
2025-11-25 16:41:53 +01:00
Simon Ser
1e630aed0d ci: use npm clean-install 2025-11-08 00:08:58 +01:00
Simon Ser
b39b46fa12 Upgrade dependencies 2025-10-20 23:37:04 +02:00
Simon Ser
65a0a34fa1 Update dependencies 2025-09-19 23:10:37 +02:00
Simon Ser
642e90f51c Upgrade dependencies 2025-08-16 23:49:27 +02:00
Simon Ser
64c2325db8 Upgrade @stylistic/eslint-plugin to v5 2025-07-08 01:30:59 +02:00
Simon Ser
700919b5c4 Upgrade dependencies 2025-07-08 01:21:38 +02:00
Simon Ser
e91c246a95 Add more ARIA attributes for accessibility 2025-06-26 19:21:01 +02:00
Simon Ser
5b7459f24d Improve accessibility by adding ARIA attributes 2025-06-26 19:01:29 +02:00
Simon Ser
af3a255824 state: simplify NICK handling 2025-05-23 23:58:49 +02:00
Simon Ser
7e785ed101 state: fix undefined membership when handling MODE
Happens when the server sends a MODE message which refers to a user
who left the channel.
2025-05-23 23:53:59 +02:00
Simon Ser
02cc554df6 Add margin between checkboxes and their label 2025-05-23 21:23:37 +02:00
Simon Ser
6aef3e906b components/buffer: emphasize nag log lines
Don't show them with a muted/gray color. Ideally these shouldn't
be log lines in the first place, but that should at least make them
more readable until we find a better alternative.
2025-05-23 21:19:13 +02:00
Simon Ser
9879dbc722 Upgrade dependencies 2025-05-13 18:50:44 +02:00
Simon Ser
afbd7c0bb3 Use routeMessage() when handling infinite scrolling messages 2025-05-13 18:46:26 +02:00
Umar Getagazov
e09541ad2f Resize content viewport to accommodate for keyboard
As of Chrome 108 and Firefox 133, when the virtual keyboard appears on a
mobile device, the default behavior changed from resizing both visual
and layout viewports to resizing just the visual viewport to match the
behavior of Safari iOS.

https://developer.chrome.com/blog/viewport-resize-behavior/
https://github.com/bramus/viewport-resize-behavior/blob/main/explainer.md

Supported in Chrome 108+ and Firefox 133+, but not in Safari yet:

https://chromestatus.com/feature/6145225857171456
https://bugzilla.mozilla.org/show_bug.cgi?id=1831649
https://bugs.webkit.org/show_bug.cgi?id=259770
2025-05-07 19:59:51 +01:00
Simon Ser
6905b9d768 lib/client: add human-readable messages for WebSocket error codes 2025-05-05 18:35:20 +02:00
Simon Ser
caf6e9978b Upgrade @stylistic/eslint-plugin-js to v4 2025-03-20 20:03:05 +01:00
Simon Ser
fbfa123dca Upgrade globals to v16 2025-03-20 20:02:57 +01:00
Simon Ser
cd45ead256 Upgrade dependencies 2025-03-20 19:59:59 +01:00
Simon Ser
fcc80a85e3 Only send PING to registered connections on focus 2025-02-28 17:43:13 +01:00
Simon Ser
76b6931ebb components/buffer: flatten separators when appending to children
Nested "key" attributes in arrays don't work. "key" attributes
need to all be in the same array. (Alternatively, we could've used
a fragment and set a key on that.)
2025-02-24 00:29:11 +01:00
Simon Ser
7d068fd1fe Enable @stylistic/js/comma-spacing lint 2025-02-20 17:58:24 +01:00
Simon Ser
735dd8fd8c Enable @stylistic/js/block-spacing lint 2025-02-20 17:57:08 +01:00
Simon Ser
c461f4903e Enable @stylistic/js/arrow-spacing lint 2025-02-20 17:56:19 +01:00
Simon Ser
f897e7d11b Enable @stylistic/js/array-bracket-spacing lint 2025-02-20 17:51:46 +01:00
Simon Ser
95749ba516 Enable @stylistic/js/array-bracket-newline lint 2025-02-20 17:50:35 +01:00
Simon Ser
39a2bc4a3d Enable @stylistic/js/object-curly-newline lint 2025-02-20 17:47:52 +01:00
Simon Ser
614ed5c895 Enable @stylistic/js/brace-style lint 2025-02-20 17:43:17 +01:00
Simon Ser
8d96f93fb5 Enable @stylistic/js/object-curly-spacing lint 2025-02-20 17:42:19 +01:00
Simon Ser
9922d11654 Upgrade @stylistic/eslint-plugin-js to v3 2025-02-17 00:18:13 +01:00
Simon Ser
57c5f2b1cc Upgrade dependencies 2025-02-17 00:15:12 +01:00
Simon Ser
0cc1c53fa4 ci: switch to alpine/latest 2025-02-10 14:09:51 +01:00
Simon Ser
93d7d22726 ci: upload build as artifact 2025-02-10 14:08:43 +01:00
Calvin Lee
136353b2b5 Sort servers alphanumerically 2025-02-07 22:43:03 +00:00
delthas
7dd21177bc Add support for incoming REDACT
This does not include support for redacting messages, only reading
incoming REDACT messages.

See: https://github.com/ircv3/ircv3-specifications/pull/524
2025-02-07 00:26:02 +00:00
Simon Ser
ca0cfdcc28 readme: fix screenshot 2025-02-05 22:40:54 +01:00
vyneer
1e3903c014 Fix /help not showing any commands 2025-01-28 17:03:17 +03:00
Calvin Lee
5146b0cad8 eslint: add lint enforcing camelCase
snake_case is needed in one place in the codebase to format URL arguments.
Co-authored-by: Calvin Lee <pounce@integraldoma.in>
Co-committed-by: Calvin Lee <pounce@integraldoma.in>
2025-01-27 16:29:58 +00:00
Markus Unterwaditzer
513cf825a5 Add nick-caret class
I'd like to apply a userstyle to this text, and in order to do that I need a CSS class.
Co-authored-by: Markus Unterwaditzer <markus-tarpit+git@unterwaditzer.net>
Co-committed-by: Markus Unterwaditzer <markus-tarpit+git@unterwaditzer.net>
2025-01-27 12:25:26 +00:00
Simon Ser
9fef11564d Upgrade dependencies 2025-01-20 23:16:11 +01:00
Simon Ser
9dda4ee438 eslint: add a few more rules 2025-01-20 23:02:23 +01:00
Simon Ser
9299f79bab Make debug=0 URL param disable debug logs 2025-01-19 21:11:58 +01:00
Simon Ser
e4088304bf Upgrade preact, once again
Seems to not cause regressions like it did last time. Crossing
fingers now.
2025-01-07 17:06:08 +01:00
Simon Ser
6ea3601718 dev-server: print remote server errors 2025-01-05 21:52:15 +01:00
emersion
bcf3741ab4 readme: fix screenshot link 2024-12-20 21:37:04 +00:00
Simon Ser
ec5e67336f components/buffer: handle TOPIC clear messages 2024-12-03 09:19:53 +01:00
Umar Getagazov
4d988c98d0 Fix tag name typo in the Timestamp component 2024-11-28 12:37:51 +00:00
Simon Ser
62895d59ff Upgrade dependencies 2024-11-23 21:02:08 +01:00
22 changed files with 1510 additions and 1867 deletions

View File

@@ -1,5 +1,4 @@
# TODO switch back to alpine/latest once the "npm install" deadlock is fixed image: alpine/latest
image: alpine/edge
packages: packages:
- npm - npm
- rsync - rsync
@@ -7,13 +6,16 @@ sources:
- https://codeberg.org/emersion/gamja.git - https://codeberg.org/emersion/gamja.git
secrets: secrets:
- 7a146c8e-aeb4-46e7-99bf-05af7486bbe9 # deploy SSH key - 7a146c8e-aeb4-46e7-99bf-05af7486bbe9 # deploy SSH key
artifacts:
- gamja/gamja.tar.gz
tasks: tasks:
- setup: | - setup: |
cd gamja cd gamja
npm install --include=dev npm clean-install --include=dev
- build: | - build: |
cd gamja cd gamja
npm run build npm run build
tar -czf gamja.tar.gz -C dist .
- lint: | - lint: |
cd gamja cd gamja
npm run -- lint --max-warnings 0 npm run -- lint --max-warnings 0

View File

@@ -0,0 +1,39 @@
name: Bun Package
on:
push:
branches: [ master ]
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout código
run: |
git clone https://fedesrv.ddns.net/git/${{ github.repository }}.git .
git checkout ${{ github.sha }}
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Install deps
run: bun install
- name: Build gamja
run: bun build ./index.html --outdir ./dist --minify
- name: Generar fecha
run: echo "BUILD_DATE=$(date +'%Y-%m-%d_%H-%M')" >> $GITHUB_ENV
- name: Upload dist
uses: actions/upload-artifact@v3
with:
name: gamja-dist-${{ env.BUILD_DATE }}
path: dist

View File

@@ -2,7 +2,7 @@
A simple IRC web client. A simple IRC web client.
![screenshot](https://l.sr.ht/7Npm.png) <img src="https://fs.emersion.fr/protected/img/gamja/main.png" alt="Screenshot" width="800">
## Usage ## Usage

View File

@@ -124,7 +124,7 @@ const commands = [
if (args.length) { if (args.length) {
params.push(args.join(" ")); params.push(args.join(" "));
} }
getActiveClient(app).send({command: "AWAY", params}); getActiveClient(app).send({ command: "AWAY", params });
}, },
}, },
ban, ban,
@@ -190,9 +190,10 @@ const commands = [
throw new Error("Missing nick"); throw new Error("Missing nick");
} }
let activeChannel = getActiveChannel(app); let activeChannel = getActiveChannel(app);
getActiveClient(app).send({ command: "INVITE", params: [ getActiveClient(app).send({
nick, activeChannel, command: "INVITE",
]}); params: [nick, activeChannel],
});
}, },
}, },
{ ...join, name: "j" }, { ...join, name: "j" },

View File

@@ -220,6 +220,7 @@ export default class App extends Component {
this.handleAddNetworkClick = this.handleAddNetworkClick.bind(this); this.handleAddNetworkClick = this.handleAddNetworkClick.bind(this);
this.handleNetworkSubmit = this.handleNetworkSubmit.bind(this); this.handleNetworkSubmit = this.handleNetworkSubmit.bind(this);
this.handleNetworkRemove = this.handleNetworkRemove.bind(this); this.handleNetworkRemove = this.handleNetworkRemove.bind(this);
this.showError = this.showError.bind(this);
this.handleDismissError = this.handleDismissError.bind(this); this.handleDismissError = this.handleDismissError.bind(this);
this.handleAuthSubmit = this.handleAuthSubmit.bind(this); this.handleAuthSubmit = this.handleAuthSubmit.bind(this);
this.handleRegisterSubmit = this.handleRegisterSubmit.bind(this); this.handleRegisterSubmit = this.handleRegisterSubmit.bind(this);
@@ -323,6 +324,8 @@ export default class App extends Component {
} }
if (queryParams.debug === "1") { if (queryParams.debug === "1") {
this.debug = true; this.debug = true;
} else if (queryParams.debug === "0") {
this.debug = false;
} }
if (window.location.hash) { if (window.location.hash) {
@@ -665,12 +668,6 @@ export default class App extends Component {
} }
} }
addChatMessage(serverID, bufName, msg) {
this.prepareChatMessage(serverID, msg);
let bufID = { server: serverID, name: bufName };
this.setState((state) => State.addMessage(state, msg, bufID));
}
handleChatMessage(serverID, bufName, msg) { handleChatMessage(serverID, bufName, msg) {
let client = this.clients.get(serverID); let client = this.clients.get(serverID);
@@ -760,7 +757,7 @@ export default class App extends Component {
// Open a new buffer if the message doesn't come from me or is a // Open a new buffer if the message doesn't come from me or is a
// self-message // self-message
if ((!client.isMyNick(msg.prefix.name) || client.isMyNick(bufName)) && (msg.command !== "PART" && msg.comand !== "QUIT" && msg.command !== irc.RPL_MONONLINE && msg.command !== irc.RPL_MONOFFLINE)) { if ((!client.isMyNick(msg.prefix.name) || client.isMyNick(bufName)) && (msg.command !== "PART" && msg.command !== "QUIT" && msg.command !== irc.RPL_MONONLINE && msg.command !== irc.RPL_MONOFFLINE)) {
this.createBuffer(serverID, bufName); this.createBuffer(serverID, bufName);
} }
@@ -1073,6 +1070,7 @@ export default class App extends Component {
case "ACK": case "ACK":
case "BOUNCER": case "BOUNCER":
case "MARKREAD": case "MARKREAD":
case "REDACT":
// Ignore these // Ignore these
return []; return [];
default: default:
@@ -1797,7 +1795,12 @@ export default class App extends Component {
} }
for (let msg of result.messages) { for (let msg of result.messages) {
this.addChatMessage(buf.server, buf.name, msg); this.prepareChatMessage(buf.server, msg);
let destBuffers = this.routeMessage(buf.server, msg);
for (let bufName of destBuffers) {
let bufID = { server: buf.server, name: bufName };
this.setState((state) => State.addMessage(state, msg, bufID));
}
} }
} }
@@ -2003,7 +2006,9 @@ export default class App extends Component {
this.lastFocusPingDate = now; this.lastFocusPingDate = now;
for (let client of this.clients.values()) { for (let client of this.clients.values()) {
client.send({ command: "PING", params: ["gamja"] }); if (client.status === Client.Status.REGISTERED) {
client.send({ command: "PING", params: ["gamja"] });
}
} }
} }
@@ -2067,7 +2072,7 @@ export default class App extends Component {
} }
bufferHeader = html` bufferHeader = html`
<section id="buffer-header"> <section id="buffer-header" role="banner">
<${BufferHeader} <${BufferHeader}
buffer=${activeBuffer} buffer=${activeBuffer}
server=${activeServer} server=${activeServer}
@@ -2089,8 +2094,10 @@ export default class App extends Component {
if (activeBuffer && activeBuffer.type === BufferType.CHANNEL) { if (activeBuffer && activeBuffer.type === BufferType.CHANNEL) {
memberList = html` memberList = html`
<section <section
id="member-list" id="member-list"
class=${this.state.openPanels.memberList ? "expand" : ""} class=${this.state.openPanels.memberList ? "expand" : ""}
role="complementary"
aria-label="Members list"
> >
<button <button
class="expander" class="expander"
@@ -2217,7 +2224,7 @@ export default class App extends Component {
let error = null; let error = null;
if (this.state.error) { if (this.state.error) {
error = html` error = html`
<div id="error-msg"> <div id="error-msg" role="alert">
${this.state.error} ${this.state.error}
${" "} ${" "}
<button onClick=${this.handleDismissError}>×</button> <button onClick=${this.handleDismissError}>×</button>
@@ -2241,8 +2248,8 @@ export default class App extends Component {
let app = html` let app = html`
<section <section
id="buffer-list" id="buffer-list"
class=${this.state.openPanels.bufferList ? "expand" : ""} class=${this.state.openPanels.bufferList ? "expand" : ""}
> >
<${BufferList} <${BufferList}
buffers=${this.state.buffers} buffers=${this.state.buffers}
@@ -2267,7 +2274,7 @@ export default class App extends Component {
scrollKey=${this.state.activeBuffer} scrollKey=${this.state.activeBuffer}
onScrollTop=${this.handleBufferScrollTop} onScrollTop=${this.handleBufferScrollTop}
> >
<section id="buffer" ref=${this.buffer} tabindex="-1"> <section id="buffer" ref=${this.buffer} tabindex="-1" role="log">
<${Buffer} <${Buffer}
buffer=${activeBuffer} buffer=${activeBuffer}
server=${activeServer} server=${activeServer}
@@ -2286,6 +2293,7 @@ export default class App extends Component {
client=${activeClient} client=${activeClient}
readOnly=${composerReadOnly} readOnly=${composerReadOnly}
onSubmit=${this.handleComposerSubmit} onSubmit=${this.handleComposerSubmit}
onError=${this.showError}
autocomplete=${this.autocomplete} autocomplete=${this.autocomplete}
commandOnly=${commandOnly} commandOnly=${commandOnly}
maxLen=${privmsgMaxLen} maxLen=${privmsgMaxLen}

View File

@@ -47,7 +47,7 @@ function BufferItem(props) {
} }
return html` return html`
<li class="${classes.join(" ")}"> <li class="${classes.join(" ")}" role="tab" aria-selected="${props.active}">
<a <a
href=${getBufferURL(props.buffer)} href=${getBufferURL(props.buffer)}
title=${title} title=${title}
@@ -80,5 +80,9 @@ export default function BufferList(props) {
`; `;
}); });
return html`<ul>${items}</ul>`; return html`
<ul role="tablist" aria-label="Buffer list">
${items}
</ul>
`;
} }

View File

@@ -43,7 +43,7 @@ function _Timestamp({ date, url, showSeconds }) {
if (showSeconds) { if (showSeconds) {
timestamp += ":--"; timestamp += ":--";
} }
return html`<spam class="timestamp">${timestamp}</span>`; return html`<span class="timestamp" aria-hidden="true">${timestamp}</span>`;
} }
let hh = date.getHours().toString().padStart(2, "0"); let hh = date.getHours().toString().padStart(2, "0");
@@ -94,7 +94,7 @@ function canFoldMessage(msg) {
class LogLine extends Component { class LogLine extends Component {
shouldComponentUpdate(nextProps) { shouldComponentUpdate(nextProps) {
return this.props.message !== nextProps.message; return this.props.message !== nextProps.message || this.props.redacted !== nextProps.redacted;
} }
render() { render() {
@@ -143,12 +143,24 @@ class LogLine extends Component {
`; `;
} }
} else { } else {
lineClass = "talk";
let prefix = "<", suffix = ">"; let prefix = "<", suffix = ">";
if (msg.command === "NOTICE") { if (msg.command === "NOTICE") {
lineClass += " notice";
prefix = suffix = "-"; prefix = suffix = "-";
} }
content = html`${prefix}${createNick(msg.prefix.name)}${suffix} ${linkify(stripANSI(text), onChannelClick)}`; if (this.props.redacted) {
content = html`<i>This message has been deleted.</i>`;
} else {
content = html`${linkify(stripANSI(text), onChannelClick)}`;
lineClass += " talk";
}
content = html`
<span class="nick-caret" aria-hidden="true">${prefix}</span>
${createNick(msg.prefix.name)}
<span class="nick-caret" aria-hidden="true">${suffix}</span>
${" "}
${content}
`;
} }
let allowedPrefixes = server.statusMsg; let allowedPrefixes = server.statusMsg;
@@ -274,9 +286,15 @@ class LogLine extends Component {
break; break;
case "TOPIC": case "TOPIC":
let topic = msg.params[1]; let topic = msg.params[1];
content = html` if (topic) {
${createNick(msg.prefix.name)} changed the topic to: ${linkify(stripANSI(topic), onChannelClick)} content = html`
`; ${createNick(msg.prefix.name)} changed the topic to: ${linkify(stripANSI(topic), onChannelClick)}
`;
} else {
content = html`
${createNick(msg.prefix.name)} cleared the topic
`;
}
break; break;
case "INVITE": case "INVITE":
invitee = msg.params[0]; invitee = msg.params[0];
@@ -368,7 +386,7 @@ class LogLine extends Component {
} }
return html` return html`
<div class="logline ${lineClass}" data-key=${msg.key}> <div class="logline ${lineClass}" data-key=${msg.key} role="listitem">
<${Timestamp} date=${new Date(msg.tags.time)} url=${getMessageURL(buf, msg)}/> <${Timestamp} date=${new Date(msg.tags.time)} url=${getMessageURL(buf, msg)}/>
${" "} ${" "}
${content} ${content}
@@ -493,7 +511,7 @@ class FoldGroup extends Component {
} }
return html` return html`
<div class="logline" data-key=${msgs[0].key}> <div class="logline" data-key=${msgs[0].key} role="listitem">
${timestamp} ${timestamp}
${" "} ${" "}
${content} ${content}
@@ -546,7 +564,7 @@ class NotificationNagger extends Component {
} }
return html` return html`
<div class="logline"> <div class="logline nag" role="listitem">
<${Timestamp}/> <${Timestamp}/>
${" "} ${" "}
<a href="#" onClick=${this.handleClick}>Turn on desktop notifications</a> to get notified about new messages <a href="#" onClick=${this.handleClick}>Turn on desktop notifications</a> to get notified about new messages
@@ -587,7 +605,7 @@ class ProtocolHandlerNagger extends Component {
} }
let name = this.props.bouncerName || "this bouncer"; let name = this.props.bouncerName || "this bouncer";
return html` return html`
<div class="logline"> <div class="logline nag" role="listitem">
<${Timestamp}/> <${Timestamp}/>
${" "} ${" "}
<a href="#" onClick=${this.handleClick}>Register our protocol handler</a> to open IRC links with ${name} <a href="#" onClick=${this.handleClick}>Register our protocol handler</a> to open IRC links with ${name}
@@ -625,7 +643,7 @@ function AccountNagger({ server, onAuthClick, onRegisterClick }) {
} }
return html` return html`
<div class="logline"> <div class="logline nag" role="listitem">
<${Timestamp}/> ${msg} <${Timestamp}/> ${msg}
</div> </div>
`; `;
@@ -644,7 +662,7 @@ class DateSeparator extends Component {
let date = this.props.date; let date = this.props.date;
let text = date.toLocaleDateString([], { year: "numeric", month: "2-digit", day: "2-digit" }); let text = date.toLocaleDateString([], { year: "numeric", month: "2-digit", day: "2-digit" });
return html` return html`
<div class="separator date-separator"> <div class="separator date-separator" role="separator">
${text} ${text}
</div> </div>
`; `;
@@ -652,7 +670,7 @@ class DateSeparator extends Component {
} }
function UnreadSeparator(props) { function UnreadSeparator(props) {
return html`<div class="separator unread-separator">New messages</div>`; return html`<div class="separator unread-separator" role="separator">New messages</div>`;
} }
function sameDate(d1, d2) { function sameDate(d1, d2) {
@@ -703,6 +721,7 @@ export default class Buffer extends Component {
message=${msg} message=${msg}
buffer=${buf} buffer=${buf}
server=${server} server=${server}
redacted=${buf.redacted.has(msg.tags.msgid)}
onChannelClick=${onChannelClick} onChannelClick=${onChannelClick}
onNickClick=${onNickClick} onNickClick=${onNickClick}
onVerifyClick=${onVerifyClick} onVerifyClick=${onVerifyClick}
@@ -808,7 +827,7 @@ export default class Buffer extends Component {
if (sep.length > 0) { if (sep.length > 0) {
children.push(createFoldGroup(foldMessages)); children.push(createFoldGroup(foldMessages));
children.push(sep); children.push(...sep);
foldMessages = []; foldMessages = [];
} }
@@ -828,7 +847,7 @@ export default class Buffer extends Component {
children.push(createFoldGroup(foldMessages)); children.push(createFoldGroup(foldMessages));
return html` return html`
<div class="logline-list"> <div class="logline-list" role="list">
${children} ${children}
</div> </div>
`; `;

View File

@@ -185,7 +185,13 @@ export default class Composer extends Component {
promises.push(this.uploadFile(file)); promises.push(this.uploadFile(file));
} }
let urls = await Promise.all(promises); let urls;
try {
urls = await Promise.all(promises);
} catch (err) {
this.props.onError(new Error("Failed to upload files", { cause: err }));
return;
}
this.setState((state) => { this.setState((state) => {
if (state.text) { if (state.text) {

View File

@@ -47,11 +47,11 @@ export default class Dialog extends Component {
render() { render() {
return html` return html`
<div class="dialog" onClick=${this.handleBackdropClick}> <div class="dialog" onClick=${this.handleBackdropClick} role="dialog" aria-modal="true">
<div class="dialog-body" ref=${this.body}> <div class="dialog-body" ref=${this.body}>
<div class="dialog-header"> <div class="dialog-header">
<h2>${this.props.title}</h2> <h2>${this.props.title}</h2>
<button class="dialog-close" onClick=${this.handleCloseClick}>×</button> <button class="dialog-close" onClick=${this.handleCloseClick} title="Close">×</button>
</div> </div>
${this.props.children} ${this.props.children}
</div> </div>

View File

@@ -42,8 +42,8 @@ function KeyBindingsHelp() {
} }
function CommandsHelp() { function CommandsHelp() {
let l = Object.keys(commands).map((name) => { let l = [...commands.keys()].map((name) => {
let cmd = commands[name]; let cmd = commands.get(name);
let usage = [html`<strong>/${name}</strong>`]; let usage = [html`<strong>/${name}</strong>`];
if (cmd.usage) { if (cmd.usage) {

View File

@@ -65,7 +65,7 @@ export default class SwitcherForm extends Component {
this.handleInput = this.handleInput.bind(this); this.handleInput = this.handleInput.bind(this);
this.handleSubmit = this.handleSubmit.bind(this); this.handleSubmit = this.handleSubmit.bind(this);
this.handleKeyUp = this.handleKeyUp.bind(this); this.handleKeyDown = this.handleKeyDown.bind(this);
} }
getSuggestions() { getSuggestions() {
@@ -106,7 +106,7 @@ export default class SwitcherForm extends Component {
this.props.onSubmit(this.getSuggestions()[this.state.selected]); this.props.onSubmit(this.getSuggestions()[this.state.selected]);
} }
handleKeyUp(event) { handleKeyDown(event) {
switch (event.key) { switch (event.key) {
case "ArrowUp": case "ArrowUp":
event.stopPropagation(); event.stopPropagation();
@@ -152,7 +152,7 @@ export default class SwitcherForm extends Component {
<form <form
onInput=${this.handleInput} onInput=${this.handleInput}
onSubmit=${this.handleSubmit} onSubmit=${this.handleSubmit}
onKeyUp=${this.handleKeyUp} onKeyDown=${this.handleKeyDown}
> >
<input <input
type="search" type="search"

View File

@@ -63,7 +63,8 @@ if (remoteHost) {
ws.close(); ws.close();
}); });
client.on("error", () => { client.on("error", (err) => {
console.log(err);
ws.close(WS_BAD_GATEWAY); ws.close(WS_BAD_GATEWAY);
}); });
}); });

View File

@@ -7,7 +7,7 @@ gamja settings can be overridden using URL query parameters:
replaced with a randomly generated value) replaced with a randomly generated value)
- `channels`: comma-separated list of channels to join (`#` needs to be escaped) - `channels`: comma-separated list of channels to join (`#` needs to be escaped)
- `open`: [IRC URL] to open - `open`: [IRC URL] to open
- `debug`: if set to 1, debug mode is enabled - `debug`: enable debug logs if set to `1`, disable debug logs if set to `0`
Alternatively, the channels can be set with the URL fragment (ie, by just Alternatively, the channels can be set with the URL fragment (ie, by just
appending the channel name to the gamja URL). appending the channel name to the gamja URL).

View File

@@ -1,6 +1,6 @@
import globals from "globals"; import globals from "globals";
import js from "@eslint/js"; import js from "@eslint/js";
import stylisticJs from "@stylistic/eslint-plugin-js"; import stylistic from "@stylistic/eslint-plugin";
export default [ export default [
{ {
@@ -14,7 +14,7 @@ export default [
"process": "readonly", "process": "readonly",
}, },
}, },
plugins: { "@stylistic/js": stylisticJs }, plugins: { "@stylistic": stylistic },
rules: { rules: {
"no-case-declarations": "off", "no-case-declarations": "off",
"no-unused-vars": ["error", { "no-unused-vars": ["error", {
@@ -23,16 +23,34 @@ export default [
destructuredArrayIgnorePattern: "^_", destructuredArrayIgnorePattern: "^_",
}], }],
"no-var": "error", "no-var": "error",
"no-eval": "error",
"no-implied-eval": "error",
"eqeqeq": "error", "eqeqeq": "error",
"no-invalid-this": "error", "no-invalid-this": "error",
"no-extend-native": "error",
"prefer-arrow-callback": "error", "prefer-arrow-callback": "error",
"no-implicit-globals": "error",
"no-throw-literal": "error",
"no-implicit-coercion": "warn", "no-implicit-coercion": "warn",
"object-shorthand": "warn", "object-shorthand": "warn",
"@stylistic/js/indent": ["warn", "tab"], "curly": "warn",
"@stylistic/js/quotes": ["warn", "double"], "camelcase": "warn",
"@stylistic/js/semi": "warn", "@stylistic/indent": ["warn", "tab", { SwitchCase: 0 }],
"@stylistic/js/comma-dangle": ["warn", "always-multiline"], "@stylistic/quotes": ["warn", "double"],
"@stylistic/js/arrow-parens": "warn", "@stylistic/semi": "warn",
"@stylistic/brace-style": ["warn", "1tbs"],
"@stylistic/comma-dangle": ["warn", "always-multiline"],
"@stylistic/comma-spacing": "warn",
"@stylistic/arrow-parens": "warn",
"@stylistic/arrow-spacing": "warn",
"@stylistic/block-spacing": "warn",
"@stylistic/object-curly-spacing": ["warn", "always"],
"@stylistic/object-curly-newline": ["warn", {
multiline: true,
consistent: true,
}],
"@stylistic/array-bracket-spacing": ["warn", "never"],
"@stylistic/array-bracket-newline": ["warn", "consistent"],
}, },
}, },
]; ];

View File

@@ -6,7 +6,7 @@
<title>gamja IRC client</title> <title>gamja IRC client</title>
<link rel="stylesheet" href="./style.css"> <link rel="stylesheet" href="./style.css">
<script type="module" src="./main.js"></script> <script type="module" src="./main.js"></script>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content">
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
</head> </head>
<body> <body>

View File

@@ -21,6 +21,7 @@ const permanentCaps = [
"draft/account-registration", "draft/account-registration",
"draft/chathistory", "draft/chathistory",
"draft/extended-monitor", "draft/extended-monitor",
"draft/message-redaction",
"draft/read-marker", "draft/read-marker",
"soju.im/bouncer-networks", "soju.im/bouncer-networks",
@@ -31,9 +32,33 @@ const RECONNECT_MAX_DELAY_MSEC = 10 * 60 * 1000; // 10min
// WebSocket status codes // WebSocket status codes
// https://www.rfc-editor.org/rfc/rfc6455.html#section-7.4.1 // https://www.rfc-editor.org/rfc/rfc6455.html#section-7.4.1
const NORMAL_CLOSURE = 1000; const WEBSOCKET_CLOSE_CODES = {
const GOING_AWAY = 1001; NORMAL_CLOSURE: 1000,
const UNSUPPORTED_DATA = 1003; GOING_AWAY: 1001,
PROTOCOL_ERROR: 1002,
UNSUPPORTED_DATA: 1003,
NO_STATUS_CODE: 1005,
ABNORMAL_CLOSURE: 1006,
INVALID_FRAME_PAYLOAD_DATA: 1007,
POLICY_VIOLATION: 1008,
MESSAGE_TOO_BIG: 1009,
MISSING_MANDATORY_EXT: 1010,
INTERNAL_SERVER_ERROR: 1011,
TLS_HANDSHAKE_FAILED: 1015,
};
const WEBSOCKET_CLOSE_CODE_NAMES = {
[WEBSOCKET_CLOSE_CODES.GOING_AWAY]: "going away",
[WEBSOCKET_CLOSE_CODES.PROTOCOL_ERROR]: "protocol error",
[WEBSOCKET_CLOSE_CODES.UNSUPPORTED_DATA]: "unsupported data",
[WEBSOCKET_CLOSE_CODES.NO_STATUS_CODE]: "no status code received",
[WEBSOCKET_CLOSE_CODES.ABNORMAL_CLOSURE]: "abnormal closure",
[WEBSOCKET_CLOSE_CODES.INVALID_FRAME_PAYLOAD_DATA]: "invalid frame payload data",
[WEBSOCKET_CLOSE_CODES.POLICY_VIOLATION]: "policy violation",
[WEBSOCKET_CLOSE_CODES.MESSAGE_TOO_BIG]: "message too big",
[WEBSOCKET_CLOSE_CODES.MISSING_MANDATORY_EXT]: "missing mandatory extension",
[WEBSOCKET_CLOSE_CODES.INTERNAL_SERVER_ERROR]: "internal server error",
[WEBSOCKET_CLOSE_CODES.TLS_HANDSHAKE_FAILED]: "TLS handshake failed",
};
// See https://github.com/quakenet/snircd/blob/master/doc/readme.who // See https://github.com/quakenet/snircd/blob/master/doc/readme.who
// Sorted by order of appearance in RPL_WHOSPCRPL // Sorted by order of appearance in RPL_WHOSPCRPL
@@ -68,6 +93,18 @@ class IRCError extends Error {
} }
} }
class WebSocketError extends Error {
constructor(code) {
let text = "Connection error";
let name = WEBSOCKET_CLOSE_CODE_NAMES[code];
if (name) {
text += " (" + name + ")";
}
super(text);
}
}
/** /**
* Implements a simple exponential backoff. * Implements a simple exponential backoff.
*/ */
@@ -188,8 +225,8 @@ export default class Client extends EventTarget {
this.ws.addEventListener("close", (event) => { this.ws.addEventListener("close", (event) => {
console.log("Connection closed (code: " + event.code + ")"); console.log("Connection closed (code: " + event.code + ")");
if (event.code !== NORMAL_CLOSURE && event.code !== GOING_AWAY) { if (event.code !== WEBSOCKET_CLOSE_CODES.NORMAL_CLOSURE && event.code !== WEBSOCKET_CLOSE_CODES.GOING_AWAY) {
this.dispatchError(new Error("Connection error")); this.dispatchError(new WebSocketError(event.code));
} }
this.ws = null; this.ws = null;
@@ -236,7 +273,7 @@ export default class Client extends EventTarget {
this.setPingInterval(0); this.setPingInterval(0);
if (this.ws) { if (this.ws) {
this.ws.close(NORMAL_CLOSURE); this.ws.close(WEBSOCKET_CLOSE_CODES.NORMAL_CLOSURE);
} }
} }
@@ -296,7 +333,7 @@ export default class Client extends EventTarget {
handleMessage(event) { handleMessage(event) {
if (typeof event.data !== "string") { if (typeof event.data !== "string") {
console.error("Received unsupported data type:", event.data); console.error("Received unsupported data type:", event.data);
this.ws.close(UNSUPPORTED_DATA); this.ws.close(WEBSOCKET_CLOSE_CODES.UNSUPPORTED_DATA);
return; return;
} }

View File

@@ -4,5 +4,5 @@ import { h } from "../node_modules/preact/dist/preact.module.js";
import htm from "../node_modules/htm/dist/htm.module.js"; import htm from "../node_modules/htm/dist/htm.module.js";
export const html = htm.bind(h); export const html = htm.bind(h);
import * as linkifyjs from "../node_modules/linkifyjs/dist/linkify.es.js"; import * as linkifyjs from "../node_modules/linkifyjs/dist/linkify.mjs";
export { linkifyjs }; export { linkifyjs };

View File

@@ -43,9 +43,9 @@ export function redirectAuthorize({ serverMetadata, clientId, redirectUri, scope
// TODO: use the state param to prevent cross-site request // TODO: use the state param to prevent cross-site request
// forgery // forgery
let params = { let params = {
response_type: "code", "response_type": "code",
client_id: clientId, "client_id": clientId,
redirect_uri: redirectUri, "redirect_uri": redirectUri,
}; };
if (scope) { if (scope) {
params.scope = scope; params.scope = scope;
@@ -66,12 +66,12 @@ function buildPostHeaders(clientId, clientSecret) {
export async function exchangeCode({ serverMetadata, redirectUri, code, clientId, clientSecret }) { export async function exchangeCode({ serverMetadata, redirectUri, code, clientId, clientSecret }) {
let data = { let data = {
grant_type: "authorization_code", "grant_type": "authorization_code",
code, code,
redirect_uri: redirectUri, "redirect_uri": redirectUri,
}; };
if (!clientSecret) { if (!clientSecret) {
data.client_id = clientId; data["client_id"] = clientId;
} }
let resp = await fetch(serverMetadata.token_endpoint, { let resp = await fetch(serverMetadata.token_endpoint, {

3035
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,15 +4,15 @@
"dependencies": { "dependencies": {
"htm": "^3.0.4", "htm": "^3.0.4",
"linkifyjs": "^4.1.3", "linkifyjs": "^4.1.3",
"preact": "10.17.1" "preact": "^10.17.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.11.1", "@eslint/js": "^9.11.1",
"@parcel/packager-raw-url": "^2.0.0", "@parcel/packager-raw-url": "^2.0.0",
"@parcel/transformer-webmanifest": "^2.0.0", "@parcel/transformer-webmanifest": "^2.0.0",
"@stylistic/eslint-plugin-js": "^2.8.0", "@stylistic/eslint-plugin": "^5.1.0",
"eslint": "^9.11.1", "eslint": "^9.11.1",
"globals": "^15.9.0", "globals": "^17.0.0",
"node-static": "^0.7.11", "node-static": "^0.7.11",
"parcel": "^2.0.0", "parcel": "^2.0.0",
"split": "^1.0.1", "split": "^1.0.1",

View File

@@ -153,10 +153,24 @@ function trimStartCharacter(s, c) {
return s.substring(i); return s.substring(i);
} }
function getBouncerNetworkNameFromBuffer(state, buffer) {
let server = state.servers.get(buffer.server);
let network = state.bouncerNetworks.get(server.bouncerNetID);
if (!network) {
return null;
}
return getServerName(server, network);
}
/* Returns 1 if a should appear after b, -1 if a should appear before b, or /* Returns 1 if a should appear after b, -1 if a should appear before b, or
* 0 otherwise. */ * 0 otherwise. */
function compareBuffers(a, b) { function compareBuffers(state, a, b) {
if (a.server !== b.server) { if (a.server !== b.server) {
let aServerName = getBouncerNetworkNameFromBuffer(state, a);
let bServerName = getBouncerNetworkNameFromBuffer(state, b);
if (aServerName && bServerName && aServerName !== bServerName) {
return aServerName.localeCompare(bServerName);
}
return a.server > b.server ? 1 : -1; return a.server > b.server ? 1 : -1;
} }
if (isServerBuffer(a) !== isServerBuffer(b)) { if (isServerBuffer(a) !== isServerBuffer(b)) {
@@ -217,7 +231,7 @@ function insertMessage(list, msg) {
} }
console.assert(insertBefore >= 0, ""); console.assert(insertBefore >= 0, "");
list = [ ...list ]; list = [...list];
list.splice(insertBefore, 0, msg); list.splice(insertBefore, 0, msg);
return list; return list;
} }
@@ -361,10 +375,11 @@ export const State = {
hasInitialWho: false, // if channel hasInitialWho: false, // if channel
members: new irc.CaseMapMap(null, client.cm), // if channel members: new irc.CaseMapMap(null, client.cm), // if channel
messages: [], messages: [],
redacted: new Set(),
unread: Unread.NONE, unread: Unread.NONE,
prevReadReceipt: null, prevReadReceipt: null,
}); });
bufferList = bufferList.sort(compareBuffers); bufferList = bufferList.sort((a, b) => compareBuffers(state, a, b));
let buffers = new Map(bufferList.map((buf) => [buf.id, buf])); let buffers = new Map(bufferList.map((buf) => [buf.id, buf]));
return [id, { buffers }]; return [id, { buffers }];
}, },
@@ -596,11 +611,12 @@ export const State = {
if (buf.server !== serverID) { if (buf.server !== serverID) {
return; return;
} }
if (!buf.members.has(msg.prefix.name)) { let membership = members.get(msg.prefix.name);
if (membership === undefined) {
return; return;
} }
let members = new irc.CaseMapMap(buf.members); let members = new irc.CaseMapMap(buf.members);
members.set(newNick, members.get(msg.prefix.name)); members.set(newNick, membership);
members.delete(msg.prefix.name); members.delete(msg.prefix.name);
buffers.set(buf.id, { ...buf, members }); buffers.set(buf.id, { ...buf, members });
}); });
@@ -655,16 +671,28 @@ export const State = {
let members = new irc.CaseMapMap(buf.members); let members = new irc.CaseMapMap(buf.members);
irc.forEachChannelModeUpdate(msg, client.isupport, (mode, add, arg) => { irc.forEachChannelModeUpdate(msg, client.isupport, (mode, add, arg) => {
if (prefixByMode.has(mode)) { if (!prefixByMode.has(mode)) {
let nick = arg; return;
let membership = members.get(nick);
let letter = prefixByMode.get(mode);
members.set(nick, updateMembership(membership, letter, add, client));
} }
let nick = arg;
let membership = members.get(nick);
if (membership === undefined) {
return;
}
let letter = prefixByMode.get(mode);
members.set(nick, updateMembership(membership, letter, add, client));
}); });
return { members }; return { members };
}); });
case "REDACT":
target = msg.params[0];
if (client.isMyNick(target)) {
target = msg.prefix.name;
}
return updateBuffer(target, (buf) => {
return { redacted: new Set(buf.redacted).add(msg.params[1]) };
});
case irc.RPL_MONONLINE: case irc.RPL_MONONLINE:
case irc.RPL_MONOFFLINE: case irc.RPL_MONOFFLINE:
targets = msg.params[1].split(","); targets = msg.params[1].split(",");

View File

@@ -359,6 +359,9 @@ form input[type="search"] {
font-family: inherit; font-family: inherit;
font-size: inherit; font-size: inherit;
} }
form label input[type="checkbox"], form label input[type="radio"] {
margin-right: 0.5em;
}
a { a {
color: var(--green); color: var(--green);
@@ -396,7 +399,7 @@ details summary[role="button"] {
white-space: pre-wrap; white-space: pre-wrap;
overflow: auto; overflow: auto;
} }
#buffer .talk, #buffer .motd { #buffer .talk, #buffer .motd, #buffer .nag {
color: var(--main-color); color: var(--main-color);
} }
#buffer .error { #buffer .error {