22 Commits

Author SHA1 Message Date
Simon Ser
1bcd9d3607 ci: deploy to new server 2022-07-09 12:26:39 +02:00
Simon Ser
e4ebf5eb80 ci: fix deploy host
emersion.fr is now an alias for the new server. gamja hasn't been
migrated yet.
2022-07-08 21:24:09 +02:00
Simon Ser
1428ec4d49 Add support for draft/read-marker
References: https://github.com/ircv3/ircv3-specifications/pull/489
2022-07-01 13:35:27 +02:00
Arik
839e46360e Use monospace on <input> too
It looks like having "font-family: monospace" on <body> doesn't set it
for <input> too.
2022-07-01 13:34:22 +02:00
Simon Ser
d0064dd647 components/buffer: show disclaimer for +draft/channel-context messages 2022-06-28 15:55:35 +02:00
delthas
b9693d53ec Support @+draft/channel-context
See: https://github.com/ircv3/ircv3-specifications/pull/498
2022-06-28 15:33:38 +02:00
Simon Ser
f6ba40046f components/buffer-header: fix duplicate settings button 2022-06-28 15:11:48 +02:00
Simon Ser
54453c5f44 Fix invalid relative import
Worked locally because it's served at the root…
2022-06-27 17:16:33 +02:00
Simon Ser
fa80a56516 Add button to enable protocol handler in settings 2022-06-27 17:01:15 +02:00
Simon Ser
7cabb6f85b Add a setting for seconds in timestamps 2022-06-27 16:34:41 +02:00
Simon Ser
505a6fd5ab Workaround the sad state of base64 web APIs
This is necessary to make usernames/passwords with UTF-8 in them
work correctly.
2022-06-24 23:59:18 +02:00
Simon Ser
8e30806fec Upgrade dependencies 2022-06-14 19:58:50 +02:00
Simon Ser
f0c398a10c components/buffer-header: print bouncer network error if any 2022-06-09 15:54:29 +02:00
Simon Ser
baaf576d82 Add a settings dialog
Add an option to hide chat events or always expand them.

Closes: https://todo.sr.ht/~emersion/gamja/73
2022-06-08 16:57:16 +02:00
Simon Ser
e3c2d85a94 Fix ping config lost in ConnectForm
Reported-by: xse <xse@riseup.net>
References: https://lists.sr.ht/~emersion/public-inbox/patches/32126
2022-06-08 15:14:06 +02:00
Umar Getagazov
576b9d51eb components/app: switch to server buffer on close only if active
If the buffer that's being closed is not the active one, there's no
point in switching the user away to another buffer.
2022-06-08 15:05:26 +02:00
Simon Ser
6b04cb1417 Add support for bot mode
References: https://ircv3.net/specs/extensions/bot-mode
2022-06-08 15:04:27 +02:00
Simon Ser
8507500d74 components/scroll-manager: don't crash when Buffer is empty 2022-04-22 12:32:54 +02:00
Simon Ser
aaef4e1629 store: use lower-case for buffer keys 2022-04-22 12:04:11 +02:00
Simon Ser
cdd2da90a9 Update webpage title when switching buffer 2022-04-22 11:49:23 +02:00
Simon Ser
4a981997f0 Handle CHATHISTORY messages when reaching end of batch
Closes: https://todo.sr.ht/~emersion/gamja/115
2022-04-22 11:25:41 +02:00
Simon Ser
f45b51d981 commands: fix TypeError in kickban
The ban variable was undefined.
2022-04-14 10:53:35 +02:00
14 changed files with 2145 additions and 2084 deletions

View File

@@ -5,7 +5,7 @@ packages:
sources:
- https://git.sr.ht/~emersion/gamja
secrets:
- 5874ac5a-905e-4596-a117-fed1401c60ce # deploy SSH key
- 77c7956b-003e-44f7-bb5c-2944b2047654 # deploy SSH key
tasks:
- setup: |
cd gamja
@@ -16,4 +16,4 @@ tasks:
[ "$(git rev-parse HEAD)" = "$(git rev-parse origin/master)" ] || complete-build
rsync --rsh="ssh -o StrictHostKeyChecking=no" -rP \
--delete --exclude=config.json \
. deploy@emersion.fr:/srv/http/gamja
. deploy@sheeta.emersion.fr:/srv/http/gamja

View File

@@ -83,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];
@@ -108,21 +124,7 @@ export default {
getActiveClient(app).send({command: "AWAY", params});
},
},
"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");
}
},
},
"ban": ban,
"buffer": {
usage: "<name>",
description: "Switch to a buffer",

View File

@@ -11,12 +11,13 @@ import NetworkForm from "./network-form.js";
import AuthForm from "./auth-form.js";
import RegisterForm from "./register-form.js";
import VerifyForm from "./verify-form.js";
import SettingsForm from "./settings-form.js";
import Composer from "./composer.js";
import ScrollManager from "./scroll-manager.js";
import Dialog from "./dialog.js";
import { html, Component, createRef } from "../lib/index.js";
import { strip as stripANSI } from "../lib/ansi.js";
import { SERVER_BUFFER, BufferType, ReceiptType, ServerStatus, Unread, State, getServerName, receiptFromMessage, isReceiptBefore, isMessageBeforeReceipt } from "../state.js";
import { SERVER_BUFFER, BufferType, ReceiptType, ServerStatus, Unread, BufferEventsDisplayMode, State, getServerName, receiptFromMessage, isReceiptBefore, isMessageBeforeReceipt, SettingsContext } from "../state.js";
import commands from "../commands.js";
import { setup as setupKeybindings } from "../keybindings.js";
import * as store from "../store.js";
@@ -196,6 +197,7 @@ export default class App extends Component {
*/
autoOpenURL = null;
messageNotifications = new Set();
baseTitle = null;
constructor(props) {
super(props);
@@ -220,6 +222,13 @@ export default class App extends Component {
this.handleRegisterSubmit = this.handleRegisterSubmit.bind(this);
this.handleVerifyClick = this.handleVerifyClick.bind(this);
this.handleVerifySubmit = this.handleVerifySubmit.bind(this);
this.handleOpenSettingsClick = this.handleOpenSettingsClick.bind(this);
this.handleSettingsChange = this.handleSettingsChange.bind(this);
this.state.settings = {
...this.state.settings,
...store.settings.load(),
};
this.bufferStore = new store.Buffer();
@@ -405,17 +414,14 @@ export default class App extends Component {
}
sendReadReceipt(client, storedBuffer) {
if (!client.caps.enabled.has("soju.im/read")) {
if (!client.supportsReadMarker()) {
return;
}
let readReceipt = storedBuffer.receipts[ReceiptType.READ];
if (storedBuffer.name === "*" || !readReceipt) {
return;
}
client.send({
command: "READ",
params: [storedBuffer.name, "timestamp="+readReceipt.time],
});
client.setReadMarker(storedBuffer.name, readReceipt.time);
}
switchBuffer(id) {
@@ -470,6 +476,12 @@ export default class App extends Component {
if (buf.type === BufferType.NICK && !server.users.has(buf.name)) {
this.whoUserBuffer(buf.name, buf.server);
}
if (buf.type !== BufferType.SERVER) {
document.title = buf.name + ' · ' + this.baseTitle;
} else {
document.title = this.baseTitle;
}
});
}
@@ -610,6 +622,13 @@ export default class App extends Component {
}
connect(params) {
// Merge our previous connection params so that config options such as
// the ping interval are applied
params = {
...this.state.connectParams,
...params,
};
let serverID = null;
this.setState((state) => {
let update;
@@ -618,7 +637,10 @@ export default class App extends Component {
});
this.setState({ connectParams: params });
let client = new Client(fillConnectParams(params));
let client = new Client({
...fillConnectParams(params),
eventPlayback: this.state.settings.bufferEvents !== BufferEventsDisplayMode.HIDE,
});
client.debug = this.debug;
this.clients.set(serverID, client);
@@ -720,7 +742,12 @@ export default class App extends Component {
if (client.cm(msg.prefix.name) === client.cm(client.serverPrefix.name)) {
target = SERVER_BUFFER;
} else {
target = msg.prefix.name;
let context = msg.tags['+draft/channel-context'];
if (context && client.isChannel(context) && State.getBuffer(this.state, { server: serverID, name: context })) {
target = context;
} else {
target = msg.prefix.name;
}
}
}
if (msg.command === "NOTICE" && !State.getBuffer(this.state, { server: serverID, name: target })) {
@@ -840,6 +867,7 @@ export default class App extends Component {
case "CHATHISTORY":
case "ACK":
case "BOUNCER":
case "MARKREAD":
case "READ":
// Ignore these
return [];
@@ -851,15 +879,12 @@ export default class App extends Component {
handleMessage(serverID, msg) {
let client = this.clients.get(serverID);
let destBuffers = this.routeMessage(serverID, msg);
if (irc.findBatchByType(msg, "chathistory")) {
destBuffers.forEach((bufName) => {
this.addMessage(serverID, bufName, msg);
});
return;
return; // Handled by the caller
}
let destBuffers = this.routeMessage(serverID, msg);
this.setState((state) => State.handleMessage(state, msg, serverID, client));
let target, channel;
@@ -993,6 +1018,7 @@ export default class App extends Component {
this.autoOpenURL = null;
}
break;
case "MARKREAD":
case "READ":
target = msg.params[0];
let bound = msg.params[1];
@@ -1090,7 +1116,14 @@ export default class App extends Component {
from = receiptFromMessage(lastMsg);
}
client.fetchHistoryBetween(target.name, from, to, CHATHISTORY_MAX_SIZE).catch((err) => {
client.fetchHistoryBetween(target.name, from, to, CHATHISTORY_MAX_SIZE).then((result) => {
for (let msg of result.messages) {
let destBuffers = this.routeMessage(serverID, msg);
for (let bufName of destBuffers) {
this.addMessage(serverID, target.name, msg);
}
}
}).catch((err) => {
console.error("Failed to fetch backlog for '" + target.name + "': ", err);
this.showError("Failed to fetch backlog for '" + target.name + "'");
});
@@ -1194,11 +1227,8 @@ export default class App extends Component {
});
client.monitor(target);
if (client.caps.enabled.has("soju.im/read")) {
client.send({
command: "READ",
params: [target],
});
if (client.supportsReadMarker()) {
client.fetchReadMarker(target);
}
}
@@ -1287,7 +1317,9 @@ export default class App extends Component {
}
// fallthrough
case BufferType.NICK:
this.switchBuffer({ name: SERVER_BUFFER });
if (this.state.activeBuffer === buf.id) {
this.switchBuffer({ name: SERVER_BUFFER });
}
this.setState((state) => {
let buffers = new Map(state.buffers);
buffers.delete(buf.id);
@@ -1301,6 +1333,10 @@ export default class App extends Component {
}
}
disconnectAll() {
this.close(this.state.buffers.keys().next().value);
}
executeCommand(s) {
let parts = s.split(" ");
let name = parts[0].toLowerCase().slice(1);
@@ -1496,6 +1532,9 @@ export default class App extends Component {
client.fetchHistoryBefore(buf.name, before, limit).then((result) => {
this.endOfHistory.set(buf.id, !result.more);
for (let msg of result.messages) {
this.addMessage(buf.server, buf.name, msg);
}
});
}
@@ -1658,10 +1697,32 @@ export default class App extends Component {
this.dismissDialog();
}
handleOpenSettingsClick() {
let showProtocolHandler = false;
for (let [id, client] of this.clients) {
if (client.caps.enabled.has("soju.im/bouncer-networks")) {
showProtocolHandler = true;
break;
}
}
this.openDialog("settings", { showProtocolHandler });
}
handleSettingsChange(settings) {
store.settings.put(settings);
this.setState({ settings });
}
componentDidMount() {
this.baseTitle = document.title;
setupKeybindings(this);
}
componentWillUnmount() {
document.title = this.baseTitle;
}
render() {
if (this.state.loading) {
return html`<section id="connect"></section>`;
@@ -1714,6 +1775,7 @@ export default class App extends Component {
onReconnect=${() => this.reconnect()}
onAddNetwork=${this.handleAddNetworkClick}
onManageNetwork=${() => this.handleManageNetworkClick(activeBuffer.server)}
onOpenSettings=${this.handleOpenSettingsClick}
/>
</section>
`;
@@ -1822,6 +1884,19 @@ export default class App extends Component {
</>
`;
break;
case "settings":
dialog = html`
<${Dialog} title="Settings" onDismiss=${this.dismissDialog}>
<${SettingsForm}
settings=${this.state.settings}
showProtocolHandler=${dialogData.showProtocolHandler}
onChange=${this.handleSettingsChange}
onDisconnect=${() => this.disconnectAll()}
onClose=${() => this.dismissDialog()}
/>
</>
`;
break;
}
let error = null;
@@ -1845,7 +1920,7 @@ export default class App extends Component {
commandOnly = true;
}
return html`
let app = html`
<section
id="buffer-list"
class=${this.state.openPanels.bufferList ? "expand" : ""}
@@ -1878,6 +1953,7 @@ export default class App extends Component {
buffer=${activeBuffer}
server=${activeServer}
bouncerNetwork=${activeBouncerNetwork}
settings=${this.state.settings}
onChannelClick=${this.handleChannelClick}
onNickClick=${this.handleNickClick}
onAuthClick=${() => this.handleAuthClick(activeBuffer.server)}
@@ -1897,5 +1973,11 @@ export default class App extends Component {
${dialog}
${error}
`;
return html`
<${SettingsContext.Provider} value=${this.state.settings}>
${app}
</>
`;
}
}

View File

@@ -44,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,6 +77,12 @@ export default function BufferHeader(props) {
onClick=${props.onReconnect}
>Reconnect</button>
`;
let settingsButton = html`
<button
key="settings"
onClick="${props.onOpenSettings}"
>Settings</button>
`;
if (props.server.isBouncer) {
if (props.server.bouncerNetID) {
@@ -99,13 +108,7 @@ export default function BufferHeader(props) {
} else if (props.server.status === ServerStatus.DISCONNECTED) {
actions.push(reconnectButton);
}
actions.push(html`
<button
key="disconnect"
class="danger"
onClick=${props.onClose}
>Disconnect</button>
`);
actions.push(settingsButton);
}
} else {
if (fullyConnected) {
@@ -113,13 +116,7 @@ export default function BufferHeader(props) {
} else if (props.server.status === ServerStatus.DISCONNECTED) {
actions.push(reconnectButton);
}
actions.push(html`
<button
key="disconnect"
class="danger"
onClick=${props.onClose}
>Disconnect</button>
`);
actions.push(settingsButton);
}
break;
case BufferType.CHANNEL:
@@ -189,6 +186,10 @@ export default function BufferHeader(props) {
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;

View File

@@ -2,7 +2,7 @@ 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, ServerStatus, getNickURL, getChannelURL, getMessageURL, isMessageBeforeReceipt } 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";
@@ -27,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}
@@ -48,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.
*
@@ -129,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";
}
@@ -546,7 +567,8 @@ 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() {
@@ -557,6 +579,7 @@ export default class Buffer extends Component {
let server = this.props.server;
let bouncerNetwork = this.props.bouncerNetwork;
let settings = this.props.settings;
let serverName = server.name;
let children = [];
@@ -633,6 +656,10 @@ export default class Buffer extends Component {
buf.messages.forEach((msg) => {
let sep = [];
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;
@@ -651,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;
}

View File

@@ -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
View 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.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleChange(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 onChange=${this.handleChange} 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>
`;
}
}

42
lib/base64.js Normal file
View 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;
}

View File

@@ -1,4 +1,5 @@
import * as irc from "./irc.js";
import * as base64 from "./base64.js";
// Static list of capabilities that are always requested when supported by the
// server
@@ -19,7 +20,6 @@ const permanentCaps = [
"draft/account-registration",
"draft/chathistory",
"draft/event-playback",
"draft/extended-monitor",
"soju.im/bouncer-networks",
@@ -124,6 +124,7 @@ export default class Client extends EventTarget {
saslExternal: false,
bouncerNetwork: null,
ping: 0,
eventPlayback: true,
};
debug = false;
batches = new Map();
@@ -460,11 +461,11 @@ export default class Client extends EventTarget {
let initialResp = null;
switch (mechanism) {
case "PLAIN":
let respStr = btoa("\0" + params.username + "\0" + params.password);
let respStr = base64.encode("\0" + params.username + "\0" + params.password);
initialResp = { command: "AUTHENTICATE", params: [respStr] };
break;
case "EXTERNAL":
initialResp = { command: "AUTHENTICATE", params: [btoa("")] };
initialResp = { command: "AUTHENTICATE", params: [base64.encode("")] };
break;
default:
throw new Error(`Unknown authentication mechanism '${mechanism}'`);
@@ -624,6 +625,9 @@ export default class Client extends EventTarget {
if (!this.params.bouncerNetwork) {
wantCaps.push("soju.im/bouncer-networks-notify");
}
if (this.params.eventPlayback) {
wantCaps.push("draft/event-playback");
}
let msg = this.caps.requestAvailable(wantCaps);
if (msg) {
@@ -886,7 +890,7 @@ export default class Client extends EventTarget {
let max = Math.min(limit, this.isupport.chatHistory());
let params = ["BEFORE", target, "timestamp=" + before, max];
return this.roundtripChatHistory(params).then((messages) => {
return { more: messages.length >= max };
return { messages, more: messages.length >= max };
});
}
@@ -904,7 +908,7 @@ export default class Client extends EventTarget {
after.time = messages[messages.length - 1].tags.time;
return this.fetchHistoryBetween(target, after, before, limit);
}
return null;
return { messages };
});
}
@@ -1011,4 +1015,32 @@ export default class Client extends EventTarget {
return { message: msg.params[2] };
});
}
supportsReadMarker() {
return this.caps.enabled.has("draft/read-marker") || this.caps.enabled.has("soju.im/read");
}
_markReadCmd() {
if (this.caps.enabled.has("draft/read-marker")) {
return "MARKREAD";
} else if (this.caps.enabled.has("soju.im/read")) {
return "READ";
} else {
return null;
}
}
fetchReadMarker(target) {
this.send({
command: this._markReadCmd(),
params: [target],
});
}
setReadMarker(target, t) {
this.send({
command: this._markReadCmd(),
params: [target, "timestamp="+t],
});
}
}

View File

@@ -474,6 +474,10 @@ export class Isupport {
}
return chanModes;
}
bot() {
return this.raw.get("BOT");
}
}
export const CaseMapping = {

3761
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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";
}
@@ -209,6 +218,10 @@ export const State = {
buffers: new Map(),
activeBuffer: null,
bouncerNetworks: new Map(),
settings: {
secondsInTimestamps: true,
bufferEvents: BufferEventsDisplayMode.FOLD,
},
};
},
updateServer(state, id, updater) {
@@ -454,6 +467,10 @@ 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;
}

View File

@@ -26,6 +26,7 @@ class Item {
export const autoconnect = new Item("autoconnect");
export const naggedProtocolHandler = new Item("naggedProtocolHandler");
export const settings = new Item("settings");
function debounce(f, delay) {
let timeout = null;
@@ -57,11 +58,12 @@ export class Buffer {
}
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,
},
});

View File

@@ -352,6 +352,8 @@ form input[type="url"],
form input[type="email"] {
box-sizing: border-box;
width: 100%;
font-family: inherit;
font-size: inherit;
}
a {
@@ -533,6 +535,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;