Skip to content
Draft
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
54 changes: 54 additions & 0 deletions desktop/scripts/resolve-at-alias.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* ESM resolve hook that maps `@/*` imports to `<projectRoot>/src/*`.
* Also resolves extensionless imports to `.ts` files (TypeScript convention).
*/

import { fileURLToPath, pathToFileURL } from "node:url";
import path from "node:path";
import fs from "node:fs";

const projectRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
);
const srcDir = path.join(projectRoot, "src");

/**
* Try to resolve a file path that may be missing its extension.
* Returns the resolved file URL or null.
*/
function tryResolveFile(filePath) {
// Try exact path first
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
return pathToFileURL(filePath).href;
}
// Try with .ts extension
const withTs = `${filePath}.ts`;
if (fs.existsSync(withTs)) {
return pathToFileURL(withTs).href;
}
// Try with .tsx extension
const withTsx = `${filePath}.tsx`;
if (fs.existsSync(withTsx)) {
return pathToFileURL(withTsx).href;
}
// Try as directory with index.ts
const indexTs = path.join(filePath, "index.ts");
if (fs.existsSync(indexTs)) {
return pathToFileURL(indexTs).href;
}
return null;
}

export function resolve(specifier, context, nextResolve) {
if (specifier.startsWith("@/")) {
const bare = path.join(srcDir, specifier.slice(2));
const resolved = tryResolveFile(bare);
if (resolved) {
return nextResolve(resolved, context);
}
// Fallback — let Node try (will likely fail with a clear error)
return nextResolve(pathToFileURL(bare).href, context);
}
return nextResolve(specifier, context);
}
11 changes: 11 additions & 0 deletions desktop/scripts/test-loader.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Custom Node ESM loader that resolves `@/` path aliases to `./src/`
* relative to the desktop project root.
*
* Usage:
* node --experimental-strip-types --import ./scripts/test-loader.mjs --test src/path/to.test.mjs
*/

import { register } from "node:module";

register("./resolve-at-alias.mjs", import.meta.url);
35 changes: 35 additions & 0 deletions desktop/scripts/test-resolve-hook.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Node.js customization hooks — resolves @/ path alias and .ts extensions.
// Loaded via: node --import ./scripts/test-loader.mjs
import { existsSync } from "node:fs";
import { resolve as pathResolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";

const srcDir = pathResolve(fileURLToPath(import.meta.url), "../../src");

export function resolve(specifier, context, nextResolve) {
let mapped = specifier;

// Resolve @/ alias to src/
if (mapped.startsWith("@/")) {
mapped = pathToFileURL(pathResolve(srcDir, mapped.slice(2))).href;
}

// If the specifier (after alias resolution) looks like a local/file path
// without an extension, try appending .ts
if (
mapped.startsWith("file://") &&
!mapped.endsWith(".ts") &&
!mapped.endsWith(".mjs") &&
!mapped.endsWith(".js")
) {
const filePath = fileURLToPath(mapped);
if (!existsSync(filePath) && existsSync(`${filePath}.ts`)) {
mapped = `${mapped}.ts`;
}
}

if (mapped !== specifier) {
return nextResolve(mapped, context);
}
return nextResolve(specifier, context);
}
136 changes: 136 additions & 0 deletions desktop/src/features/messages/lib/describeSystemEvent.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import assert from "node:assert/strict";
import { describe, test } from "node:test";

import {
describeSystemEvent,
parseSystemMessagePayload,
} from "./describeSystemEvent.ts";

// ── parseSystemMessagePayload ─────────────────────────────────────────

describe("parseSystemMessagePayload", () => {
test("returns payload for valid JSON", () => {
const result = parseSystemMessagePayload(
'{"type":"member_joined","actor":"abc","target":"def"}',
);
assert.deepEqual(result, {
type: "member_joined",
actor: "abc",
target: "def",
});
});

test("returns null for invalid JSON", () => {
assert.equal(parseSystemMessagePayload("not json"), null);
});

test("returns null for empty string", () => {
assert.equal(parseSystemMessagePayload(""), null);
});
});

// ── describeSystemEvent ───────────────────────────────────────────────

describe("describeSystemEvent", () => {
test("member_joined self-join shows 'joined the channel'", () => {
const result = describeSystemEvent(
{ type: "member_joined", actor: "aaa", target: "aaa" },
undefined,
undefined,
);
assert.match(result, /joined the channel/);
});

test("member_joined with different target shows 'added ... to the channel'", () => {
const result = describeSystemEvent(
{ type: "member_joined", actor: "aaa", target: "bbb" },
undefined,
undefined,
);
assert.match(result, /added .* to the channel/);
});

test("member_left shows 'left the channel'", () => {
const result = describeSystemEvent(
{ type: "member_left", actor: "aaa" },
undefined,
undefined,
);
assert.match(result, /left the channel/);
});

test("member_removed shows 'removed ... from the channel'", () => {
const result = describeSystemEvent(
{ type: "member_removed", actor: "aaa", target: "bbb" },
undefined,
undefined,
);
assert.match(result, /removed .* from the channel/);
});

test("topic_changed includes the topic text", () => {
const result = describeSystemEvent(
{ type: "topic_changed", actor: "aaa", topic: "New Topic" },
undefined,
undefined,
);
assert.match(result, /changed the topic to "New Topic"/);
});

test("purpose_changed includes the purpose text", () => {
const result = describeSystemEvent(
{ type: "purpose_changed", actor: "aaa", purpose: "Ship stuff" },
undefined,
undefined,
);
assert.match(result, /changed the purpose to "Ship stuff"/);
});

test("channel_created shows 'created this channel'", () => {
const result = describeSystemEvent(
{ type: "channel_created", actor: "aaa" },
undefined,
undefined,
);
assert.match(result, /created this channel/);
});

test("unknown type returns null", () => {
const result = describeSystemEvent(
{ type: "unknown_type", actor: "aaa" },
undefined,
undefined,
);
assert.equal(result, null);
});

test("currentPubkey resolves to 'You'", () => {
const result = describeSystemEvent(
{ type: "member_left", actor: "aaa" },
"aaa",
undefined,
);
assert.equal(result, "You left the channel");
});

test("profiles resolve display names", () => {
const profiles = {
bbb: { displayName: "Wes", avatarUrl: null, nip05Handle: null },
};
const result = describeSystemEvent(
{ type: "member_joined", actor: "aaa", target: "bbb" },
undefined,
profiles,
);
assert.match(result, /added Wes to the channel/);
});

test("missing actor shows 'Someone'", () => {
const result = describeSystemEvent(
{ type: "channel_created" },
undefined,
undefined,
);
assert.equal(result, "Someone created this channel");
});
});
82 changes: 82 additions & 0 deletions desktop/src/features/messages/lib/describeSystemEvent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { resolveUserLabel } from "@/features/profile/lib/identity";

export type SystemMessagePayload = {
type: string;
actor?: string;
target?: string;
topic?: string;
purpose?: string;
};

function resolveLabel(
pubkey: string | undefined,
currentPubkey: string | undefined,
profiles: UserProfileLookup | undefined,
): string {
if (!pubkey) {
return "Someone";
}
return resolveUserLabel({ pubkey, currentPubkey, profiles });
}

function resolvePersonaSuffix(
pubkey: string | undefined,
personaLookup: Map<string, string> | undefined,
): string {
if (!pubkey || !personaLookup) return "";
const personaName = personaLookup.get(pubkey.toLowerCase());
return personaName ? ` (${personaName})` : "";
}

/**
* Produce a human-readable description for a single system event payload.
* Returns `null` for unknown event types.
*/
export function describeSystemEvent(
payload: SystemMessagePayload,
currentPubkey: string | undefined,
profiles: UserProfileLookup | undefined,
personaLookup?: Map<string, string>,
): string | null {
const actor = resolveLabel(payload.actor, currentPubkey, profiles);

switch (payload.type) {
case "member_joined": {
const target = resolveLabel(payload.target, currentPubkey, profiles);
const personaSuffix = resolvePersonaSuffix(payload.target, personaLookup);
if (payload.actor === payload.target) {
return `${actor}${personaSuffix} joined the channel`;
}
return `${actor} added ${target}${personaSuffix} to the channel`;
}
case "member_left": {
return `${actor} left the channel`;
}
case "member_removed": {
const target = resolveLabel(payload.target, currentPubkey, profiles);
return `${actor} removed ${target} from the channel`;
}
case "topic_changed":
return `${actor} changed the topic to "${payload.topic}"`;
case "purpose_changed":
return `${actor} changed the purpose to "${payload.purpose}"`;
case "channel_created":
return `${actor} created this channel`;
default:
return null;
}
}

/**
* Try to parse a system message body into a payload. Returns `null` on failure.
*/
export function parseSystemMessagePayload(
body: string,
): SystemMessagePayload | null {
try {
return JSON.parse(body) as SystemMessagePayload;
} catch {
return null;
}
}
Loading
Loading