Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/lib/irc/IRCClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,18 @@ export interface EventMap {
effective: "ON" | "OFF";
};
PERSISTENCE_FAIL: EventWithTags & { code: string; message: string };
// draft/read-marker: server reply
// `:server MARKREAD <target> {timestamp=<ts>|*}`. `timestamp` is null
// when the server reports "*" (no marker on file yet).
MARKREAD: BaseIRCEvent & {
target: string;
timestamp: string | null;
};
MARKREAD_FAIL: EventWithTags & {
code: string;
target?: string;
message: string;
};
// obsidianirc/cmdslist: server is reporting an add/remove delta of
// commands the user can invoke right now. Ops are individual
// tokens of the form "+cmd" or "-cmd" (multiple per wire line).
Expand Down Expand Up @@ -503,6 +515,7 @@ export class IRCClient implements IRCClientContext {
"invite-notify",
"monitor",
"extended-monitor",
"draft/read-marker",
"obsidianirc/cmdslist",
// Note: unrealircd.org/link-security is informational only, don't request it
];
Expand Down Expand Up @@ -1382,6 +1395,20 @@ export class IRCClient implements IRCClientContext {
this.sendRaw(serverId, `PERSISTENCE SET ${value}`);
}

// draft/read-marker: ask the server for the stored marker for a
// target. Channels are auto-pushed on JOIN, so this is mostly used
// when a PM buffer is opened for the first time.
markreadGet(serverId: string, target: string): void {
this.sendRaw(serverId, `MARKREAD ${target}`);
}

// draft/read-marker: tell the server the user has read up to
// `timestamp` in `target`. Server clamps to monotonically-increasing
// values and replies with MARKREAD echoing whatever it stored.
markreadSet(serverId: string, target: string, timestamp: string): void {
this.sendRaw(serverId, `MARKREAD ${target} timestamp=${timestamp}`);
}

// MONITOR commands
monitorAdd(serverId: string, targets: string[]): void {
const targetsStr = targets.join(",");
Expand Down
11 changes: 11 additions & 0 deletions src/lib/irc/handlers/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,17 @@ export function handleFail(
// draft/persistence FAIL projection
else if (cmd === "PERSISTENCE")
ctx.triggerEvent("PERSISTENCE_FAIL", { serverId, mtags, code, message });
// draft/read-marker FAIL projection. The MARKREAD FAIL form has
// an optional <target> in parv[2]; the message is whatever's left.
else if (cmd === "MARKREAD") {
ctx.triggerEvent("MARKREAD_FAIL", {
serverId,
mtags,
code,
target,
message,
});
}
}

export function handleWarn(
Expand Down
3 changes: 3 additions & 0 deletions src/lib/irc/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import {
handleMonOffline,
handleMonOnline,
} from "./monitoring";
import { handleMarkread } from "./readMarker";
import {
handleAway,
handleChghost,
Expand Down Expand Up @@ -295,6 +296,8 @@ export const IRC_DISPATCH: Record<string, HandlerFn> = {
handleExtjwt(ctx, serverId, source, parv, mtags),
PERSISTENCE: (ctx, serverId, source, parv, mtags) =>
handlePersistence(ctx, serverId, source, parv, mtags),
MARKREAD: (ctx, serverId, source, parv, mtags) =>
handleMarkread(ctx, serverId, source, parv, mtags),
CMDSLIST: (ctx, serverId, source, parv, mtags) =>
handleCmdslist(ctx, serverId, source, parv, mtags),

Expand Down
28 changes: 28 additions & 0 deletions src/lib/irc/handlers/readMarker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// draft/read-marker: server replies look like
// :server MARKREAD <target> timestamp=YYYY-MM-DDThh:mm:ss.sssZ
// :server MARKREAD <target> *
// where '*' means "no marker on file yet". We project to a typed
// MARKREAD event with `timestamp: string | null`.

import type { IRCClientContext } from "../IRCClientContext";

const TS_PREFIX = "timestamp=";

export function handleMarkread(
ctx: IRCClientContext,
serverId: string,
_source: string,
parv: string[],
_mtags: Record<string, string> | undefined,
): void {
const target = parv[0];
const tsParam = parv[1] ?? "";
if (!target) return;
let timestamp: string | null = null;
if (tsParam && tsParam !== "*") {
timestamp = tsParam.startsWith(TS_PREFIX)
? tsParam.slice(TS_PREFIX.length)
: tsParam;
}
ctx.triggerEvent("MARKREAD", { serverId, target, timestamp });
}
9 changes: 7 additions & 2 deletions src/store/handlers/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -662,17 +662,22 @@ export function registerAuthHandlers(store: StoreApi<AppState>): void {

// After CAP ACK we know whether the server supports draft/persistence.
// Issue an initial PERSISTENCE GET so the settings panel has fresh
// state by the time the user opens it.
// state by the time the user opens it. We only do this once per
// (serverId, account) login -- the spec gates the command on
// IsLoggedIn, so we wait for the SASL success path to mark the
// session complete.
ircClient.on("CAP_ACKNOWLEDGED", ({ serverId, key }) => {
if (key !== "draft/persistence") return;
// Defer the GET until a tick later so SASL has had a chance to
// complete; the server returns ACCOUNT_REQUIRED otherwise and
// we'd just have to retry.
setTimeout(() => {
const state = store.getState();
const server = state.servers.find((s) => s.id === serverId);
if (!server?.isConnected) return;
ircClient.persistenceGet(serverId);
}, 1500);
});

// obsidianirc/cmdslist: maintain a lowercase set of invocable
// commands per server. Additions and removals can arrive in the
// same wire line, so apply both atomically.
Expand Down
2 changes: 2 additions & 0 deletions src/store/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { registerChannelHandlers } from "./channels";
import { registerConnectionHandlers } from "./connection";
import { registerMessageHandlers } from "./messages";
import { registerMetadataHandlers } from "./metadata";
import { registerReadMarkerHandlers } from "./readMarker";
import { registerTicTacToeHandlers } from "./tictactoe";
import { registerUserHandlers } from "./users";
import { registerWhoisHandlers } from "./whois";
Expand All @@ -19,5 +20,6 @@ export function registerAllHandlers(store: StoreApi<AppState>): void {
registerMetadataHandlers(store);
registerBatchHandlers(store);
registerAuthHandlers(store);
registerReadMarkerHandlers(store);
registerTicTacToeHandlers(store);
}
51 changes: 51 additions & 0 deletions src/store/handlers/readMarker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// draft/read-marker: cache the per-target marker on the matching
// Channel / PrivateChat in the Zustand store. The marker is what the
// rest of the UI uses to decide which messages to count as unread,
// and what to clear notifications for.
//
// Channel matches: the target case-insensitively equals the channel
// name.
// PrivateChat matches: the target case-insensitively equals the
// other participant's username.

import type { StoreApi } from "zustand";
import ircClient from "../../lib/ircClient";
import type { AppState } from "../index";

function eqIC(a: string, b: string): boolean {
return a.toLowerCase() === b.toLowerCase();
}

export function registerReadMarkerHandlers(store: StoreApi<AppState>): void {
ircClient.on("MARKREAD", ({ serverId, target, timestamp }) => {
store.setState((state) => {
let touched = false;
const updatedServers = state.servers.map((server) => {
if (server.id !== serverId) return server;
let serverTouched = false;

const channels = server.channels.map((channel) => {
if (!eqIC(channel.name, target)) return channel;
if (channel.readMarker === timestamp) return channel;
serverTouched = true;
return { ...channel, readMarker: timestamp };
});

const privateChats = (server.privateChats || []).map((pc) => {
if (!eqIC(pc.username, target)) return pc;
const same = pc.readMarker === timestamp;
if (same && pc.readMarkerFetched) return pc;
serverTouched = true;
return { ...pc, readMarker: timestamp, readMarkerFetched: true };
});

if (!serverTouched) return server;
touched = true;
return { ...server, channels, privateChats };
});

if (!touched) return {};
return { servers: updatedServers };
});
});
}
61 changes: 58 additions & 3 deletions src/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,35 @@ export const getChannelMessages = (serverId: string, channelId: string) => {
return state.messages[key] || [];
};

// draft/read-marker: find the latest message timestamp (across normal
// chat messages -- system / event rows are ignored so reading a "X
// joined" line doesn't move the marker). Returns an ISO 8601 string
// in the spec's format, or null if there's nothing to mark.
const ISO_DROP_END_RX = /Z?$/;
function _toMarkreadIso(d: Date): string {
// The spec mandates "YYYY-MM-DDThh:mm:ss.sssZ" -- exactly the form
// toISOString() emits.
return d.toISOString().replace(ISO_DROP_END_RX, "Z");
}

export function getLatestMessageTimestampIso(
serverId: string,
bufferId: string,
): string | null {
const state = useStore.getState();
const key = `${serverId}-${bufferId}`;
const messages = state.messages[key];
if (!messages || messages.length === 0) return null;
let latest: number | null = null;
for (const msg of messages) {
if (msg.type !== "message" && msg.type !== undefined) continue;
const t = msg.timestamp ? new Date(msg.timestamp).getTime() : Number.NaN;
if (Number.isNaN(t)) continue;
if (latest === null || t > latest) latest = t;
}
return latest === null ? null : _toMarkreadIso(new Date(latest));
}

export const findChannelMessageById = (
serverId: string,
channelId: string,
Expand Down Expand Up @@ -1903,6 +1932,16 @@ const useStore = create<AppState>((set, get) => ({
const channelName =
server?.channels.find((c) => c.id === channelId)?.name || null;

// draft/read-marker: tell the server how far we've read so
// our other sessions clear their unread state too.
if (
channelName &&
server?.capabilities?.includes("draft/read-marker")
) {
const ts = getLatestMessageTimestampIso(serverId, channelId);
if (ts) ircClient.markreadSet(serverId, channelName, ts);
}

// Update unread state in store
const updatedServers = state.servers.map((server) => {
if (server.id === serverId) {
Expand Down Expand Up @@ -2095,9 +2134,21 @@ const useStore = create<AppState>((set, get) => ({
// Mark private chat as read
if (serverId && privateChatId) {
const server = state.servers.find((s) => s.id === serverId);
const pcUsername =
server?.privateChats?.find((pc) => pc.id === privateChatId)
?.username || null;
const pc = server?.privateChats?.find((pc) => pc.id === privateChatId);
const pcUsername = pc?.username || null;

// draft/read-marker: PMs aren't auto-pushed by the server,
// so the first time we open one ask for its stored marker
// (so the unread badge can clear if another device already
// read past it). After that, push the latest timestamp like
// we do for channels.
if (pcUsername && server?.capabilities?.includes("draft/read-marker")) {
if (!pc?.readMarkerFetched) {
ircClient.markreadGet(serverId, pcUsername);
}
const ts = getLatestMessageTimestampIso(serverId, privateChatId);
if (ts) ircClient.markreadSet(serverId, pcUsername, ts);
}

const updatedServers = state.servers.map((server) => {
if (server.id === serverId) {
Expand All @@ -2109,6 +2160,10 @@ const useStore = create<AppState>((set, get) => ({
unreadCount: 0,
mentionCount: 0,
isMentioned: false,
// Mark as fetched so we don't spam GETs on every
// re-selection of the same PM. The reply will
// overwrite this with the actual marker.
readMarkerFetched: true,
};
}
return privateChat;
Expand Down
10 changes: 10 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,10 @@ export interface Channel {
bans?: Array<{ mask: string; setter: string; timestamp: number }>;
invites?: Array<{ mask: string; setter: string; timestamp: number }>;
exceptions?: Array<{ mask: string; setter: string; timestamp: number }>;
// draft/read-marker: ISO-8601 timestamp of the latest message the
// user has marked as read in this channel (mirrored across all of
// the user's connected sessions). null = no marker on file yet.
readMarker?: string | null;
}

export interface PrivateChat {
Expand All @@ -164,6 +168,12 @@ export interface PrivateChat {
isBot?: boolean; // Bot status from WHO/WHOX or message tags
isIrcOp?: boolean; // IRC operator status from WHO response (* flag)
metadata?: Record<string, { value: string | undefined; visibility: string }>;
// draft/read-marker: see Channel.readMarker.
readMarker?: string | null;
// draft/read-marker: have we issued an initial MARKREAD GET for this
// PM yet? PMs are not auto-pushed by the server, so we need to
// explicitly fetch on first open.
readMarkerFetched?: boolean;
}

export interface Reaction {
Expand Down
Loading