50 Commits

Author SHA1 Message Date
fede 77ccba04ba Actualizar .gitea/workflows/build.yml
Bun Package / publish (push) Successful in 1m15s
2026-02-27 12:20:59 -03:00
fede e68534a846 Actualizar .gitea/workflows/build.yml
Bun Package / publish (push) Has been cancelled
2026-02-27 12:18:54 -03:00
fede 5b96dd0d8b Actualizar .gitea/workflows/build.yml 2026-02-27 12:17:50 -03:00
fede 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
21 changed files with 1370 additions and 1383 deletions
+5 -3
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
+39
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
+1 -1
View File
@@ -2,7 +2,7 @@
A simple IRC web client. A simple IRC web client.
<img src="https://codeberg.org/attachments/117ff232-7e73-46c7-a21d-2b59ffa3765a" alt="Screenshot" width="800"> <img src="https://fs.emersion.fr/protected/img/gamja/main.png" alt="Screenshot" width="800">
## Usage ## Usage
+4 -3
View File
@@ -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" },
+19 -11
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,9 +2006,11 @@ 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()) {
if (client.status === Client.Status.REGISTERED) {
client.send({ command: "PING", params: ["gamja"] }); client.send({ command: "PING", params: ["gamja"] });
} }
} }
}
componentDidMount() { componentDidMount() {
this.baseTitle = document.title; this.baseTitle = document.title;
@@ -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}
@@ -2091,6 +2096,8 @@ export default class App extends Component {
<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>
@@ -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}
+6 -2
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>
`;
} }
+26 -13
View File
@@ -43,7 +43,7 @@ function _Timestamp({ date, url, showSeconds }) {
if (showSeconds) { if (showSeconds) {
timestamp += ":--"; timestamp += ":--";
} }
return html`<span 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;
@@ -374,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}
@@ -499,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}
@@ -552,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
@@ -593,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}
@@ -631,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>
`; `;
@@ -650,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>
`; `;
@@ -658,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) {
@@ -709,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}
@@ -814,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 = [];
} }
@@ -834,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>
`; `;
+7 -1
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) {
+2 -2
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>
+2 -2
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) {
+3 -3
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"
+1 -1
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).
+25 -7
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"],
}, },
}, },
]; ];
+1 -1
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>
+44 -7
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;
} }
+1 -1
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 };
+6 -6
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, {
+1128 -1300
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -10,9 +10,9 @@
"@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",
+34 -6
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)) {
@@ -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)) {
return;
}
let nick = arg; let nick = arg;
let membership = members.get(nick); let membership = members.get(nick);
if (membership === undefined) {
return;
}
let letter = prefixByMode.get(mode); let letter = prefixByMode.get(mode);
members.set(nick, updateMembership(membership, letter, add, client)); 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(",");
+4 -1
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 {