Skip to content
Open
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
39 changes: 26 additions & 13 deletions desktop/src-tauri/src/commands/workflows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,21 @@ pub async fn get_channel_workflows(
///
/// The Workflows overview screen previously issued one `get_channel_workflows`
/// query per member channel (`Promise.all` fanout in `WorkflowsView`), i.e. N
/// relay POSTs. A nostr `#h` filter matches ANY of its listed values, so one
/// query with all channel ids returns the same set. Each `WorkflowWire` carries
/// its own `channel_id` (from the event's `h` tag), so the frontend can still
/// group results by channel. Neither this nor the per-channel command sets a
/// `limit`, so batching does not change result completeness.
/// relay POSTs. One HTTP `/query` can carry multiple NIP-01 filters; we send
/// one filter object per channel id (single-value `#h`) instead of one filter
/// with a multi-value `#h`.
///
/// Buzz's relay historically collapsed multi-value `#h` to the first UUID when
/// building the SQL predicate (`extract_channel_id_from_filter`), so a batched
/// multi-value query silently dropped workflows on every channel after the
/// first — Desktop showed "No workflows yet" while per-channel CLI queries
/// still worked. One filter per id keeps the single round-trip and matches
/// NIP-01 OR semantics even against relays that still collapse multi-value `#h`.
///
/// Each `WorkflowWire` carries its own `channel_id` (from the event's `h` tag),
/// so the frontend can still group results by channel. Neither this nor the
/// per-channel command sets a `limit`, so batching does not change result
/// completeness.
#[tauri::command]
pub async fn get_channels_workflows(
channel_ids: Vec<String>,
Expand All @@ -84,14 +94,17 @@ pub async fn get_channels_workflows(
return Ok(Vec::new());
}

let events = query_relay(
&state,
&[serde_json::json!({
"kinds": [30620],
"#h": channel_ids,
})],
)
.await?;
let filters: Vec<serde_json::Value> = channel_ids
.into_iter()
.map(|channel_id| {
serde_json::json!({
"kinds": [30620],
"#h": [channel_id],
})
})
.collect();

let events = query_relay(&state, &filters).await?;

Ok(events.iter().map(workflow_from_event).collect())
}
Expand Down
45 changes: 45 additions & 0 deletions desktop/src/features/communities/hostedCommunityApi.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
HOSTED_COMMUNITY_SUFFIX,
hostedCommunityRelayUrl,
} from "./hostedCommunityApi.ts";

test("hostedCommunityRelayUrl: uses normalized_host when present", () => {
assert.equal(
hostedCommunityRelayUrl({
normalized_host: "acme.communities.buzz.xyz",
name: "other",
}),
"wss://acme.communities.buzz.xyz",
);
});

test("hostedCommunityRelayUrl: strips accidental scheme on host", () => {
assert.equal(
hostedCommunityRelayUrl({
normalized_host: "wss://acme.communities.buzz.xyz",
}),
"wss://acme.communities.buzz.xyz",
);
});

test("hostedCommunityRelayUrl: synthesizes from slug when host missing", () => {
assert.equal(
hostedCommunityRelayUrl({ slug: "acme", name: "ignored" }),
`wss://acme.${HOSTED_COMMUNITY_SUFFIX}`,
);
});

test("hostedCommunityRelayUrl: synthesizes from name when slug missing", () => {
assert.equal(
hostedCommunityRelayUrl({ name: "my-team" }),
`wss://my-team.${HOSTED_COMMUNITY_SUFFIX}`,
);
});

test("hostedCommunityRelayUrl: null when no usable label", () => {
assert.equal(hostedCommunityRelayUrl({}), null);
assert.equal(hostedCommunityRelayUrl({ name: "Bad Name" }), null);
});
24 changes: 22 additions & 2 deletions desktop/src/features/communities/hostedCommunityApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,29 @@ export function hostedCommunityErrorMessage(
: message;
}

/**
* Prefer Builderlab's `normalized_host`. When create responses omit it (or
* only return name/slug), synthesize the hosted community hostname so the
* post-create connect handoff still has a relay URL.
*/
export function hostedCommunityRelayUrl(community: HostedCommunity) {
const host = community.normalized_host?.trim();
return host ? `wss://${host.replace(/^wss?:\/\//, "")}` : null;
const rawHost =
community.normalized_host?.trim() ||
synthesizeHostedCommunityHost(community);
if (!rawHost) {
return null;
}
return `wss://${rawHost.replace(/^wss?:\/\//, "")}`;
}

function synthesizeHostedCommunityHost(
community: HostedCommunity,
): string | null {
const label = (community.slug ?? community.name)?.trim().toLowerCase();
if (!label || !VALID_HOSTED_COMMUNITY_NAME.test(label)) {
return null;
}
return `${label}.${HOSTED_COMMUNITY_SUFFIX}`;
}

export function getBuilderlabAuth() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export function HostedCommunityOnboarding({
const account = await loadHostedCommunityAccount();
setIdentity(account.identity);
setCommunities(account.communities);
return account;
}, []);

React.useEffect(() => {
Expand Down Expand Up @@ -172,13 +173,6 @@ export function HostedCommunityOnboarding({
setAvailability(null);
});

const goBack = () => {
void run("Signing out…", async () => {
await clearBuilderlabAuth();
onBack();
});
};

const connectIdentity = () =>
run("Connecting identity…", async () => {
const response = await bindBuilderlabIdentity();
Expand All @@ -204,6 +198,20 @@ export function HostedCommunityOnboarding({
);
const localNpub = localPubkey ? safeNpub(localPubkey) : null;

const goBack = () => {
// Once signed in with a linked identity, Back returns to the previous
// onboarding page without clearing Builderlab auth — otherwise creators
// lose their owned-community list and land on "Join a community".
if (auth && identity && !identityMismatch) {
onBack();
return;
}
void run("Signing out…", async () => {
await clearBuilderlabAuth();
onBack();
});
};

const switchToDeviceIdentity = () =>
run("Switching identity…", async () => {
const released = await deleteBuilderlabIdentity();
Expand Down Expand Up @@ -318,7 +326,20 @@ export function HostedCommunityOnboarding({
),
);
}
connect(response.community, true);
const created = response.community;
// Refresh owned list so Connect recovery and Back still see the community
// even if the immediate connect handoff fails.
const account = await loadAccount();
const refreshed =
account.communities.find(
(community) =>
(created.id && community.id === created.id) ||
(created.slug && community.slug === created.slug) ||
(created.name && community.name === created.name) ||
community.slug === normalizedName ||
community.name === normalizedName,
) ?? created;
connect(refreshed, true);
});
};

Expand Down
76 changes: 76 additions & 0 deletions desktop/src/features/messages/lib/collectMentionPubkeys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { hasMention } from "@/features/messages/lib/hasMention";
import { extractNip27MentionPubkeys } from "@/features/messages/lib/nip27Mentions";
import { rewriteMentionsToNip27 } from "@/features/messages/lib/rewriteMentionsToNip27";
import { normalizePubkey } from "@/shared/lib/pubkey";

type MentionCandidateLike = {
displayName?: string;
isMember?: boolean;
pubkey?: string;
};

/**
* Resolve mention pubkeys from `@name` maps/candidates plus NIP-27
* `nostr:npub1…` URIs already present in the text.
*/
export function collectMentionPubkeys(
text: string,
mentionMap: Iterable<readonly [string, string]>,
selectedDisplayNames: Iterable<string>,
mentionCandidates: readonly MentionCandidateLike[],
): string[] {
const pubkeys: string[] = [];
const selected = new Set(
[...selectedDisplayNames].map((name) => name.trim().toLowerCase()),
);

for (const [displayName, pubkey] of mentionMap) {
if (hasMention(text, displayName)) {
pubkeys.push(pubkey);
}
}

for (const candidate of mentionCandidates) {
if (!candidate.pubkey || !candidate.isMember) {
continue;
}
if (pubkeys.includes(candidate.pubkey)) {
continue;
}
const name = candidate.displayName;
if (name && selected.has(name.trim().toLowerCase())) {
continue;
}
if (name && hasMention(text, name)) {
pubkeys.push(candidate.pubkey);
}
}

for (const pubkey of extractNip27MentionPubkeys(text)) {
if (
!pubkeys.some(
(existing) => normalizePubkey(existing) === normalizePubkey(pubkey),
)
) {
pubkeys.push(pubkey);
}
}

return [...new Set(pubkeys)];
}

/** Rewrite resolved `@name` mentions in `text` to NIP-27 wire URIs. */
export function buildNip27WireBody(
text: string,
mentionPubkeys: readonly string[],
getDisplayName: (pubkey: string) => string | null,
): string {
const pairs: Array<readonly [string, string]> = [];
for (const pubkey of mentionPubkeys) {
const displayName = getDisplayName(pubkey);
if (displayName) {
pairs.push([displayName, pubkey]);
}
}
return rewriteMentionsToNip27(text, pairs);
}
2 changes: 1 addition & 1 deletion desktop/src/features/messages/lib/hasMention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ function maskRange(
* Replace Markdown code with spaces while retaining offsets and line endings.
* Handles fenced blocks, four-space/tab-indented lines, and backtick code spans.
*/
function maskMarkdownCode(text: string): string {
export function maskMarkdownCode(text: string): string {
const chars = text.split("");
const lines: Array<{ start: number; end: number; content: string }> = [];

Expand Down
34 changes: 34 additions & 0 deletions desktop/src/features/messages/lib/nip27Mentions.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import test from "node:test";
import { npubEncode } from "nostr-tools/nip19";

import {
extractNip27MentionPubkeys,
replaceNip27MentionsForDisplay,
} from "./nip27Mentions.ts";

const ALICE =
"3bf0c63fcb93463407afdbb8b86e4e2c2e4e2c2e4e2c2e4e2c2e4e2c2e4e2c2e";
const aliceUri = `nostr:${npubEncode(ALICE)}`;

test("extractNip27MentionPubkeys: finds npub in prose", () => {
const out = extractNip27MentionPubkeys(`hello ${aliceUri} world`);
assert.deepEqual(out, [ALICE.toLowerCase()]);
});

test("extractNip27MentionPubkeys: skips code spans", () => {
const out = extractNip27MentionPubkeys(`\`${aliceUri}\` then ${aliceUri}`);
assert.deepEqual(out, [ALICE.toLowerCase()]);
});

test("replaceNip27MentionsForDisplay: maps known pubkeys to @name", () => {
const out = replaceNip27MentionsForDisplay(`hi ${aliceUri}`, {
Alice: ALICE,
});
assert.equal(out, "hi @Alice");
});

test("replaceNip27MentionsForDisplay: leaves unknown URIs", () => {
const out = replaceNip27MentionsForDisplay(`hi ${aliceUri}`, {});
assert.equal(out, `hi ${aliceUri}`);
});
75 changes: 75 additions & 0 deletions desktop/src/features/messages/lib/nip27Mentions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { decode } from "nostr-tools/nip19";

import { maskMarkdownCode } from "@/features/messages/lib/hasMention";
import { normalizePubkey } from "@/shared/lib/pubkey";

const NIP27_NPUB_RE = /nostr:(npub1[02-9ac-hj-np-z]+)/gi;

function npubToHex(npub: string): string | null {
try {
const decoded = decode(npub);
if (decoded.type !== "npub") {
return null;
}
if (typeof decoded.data === "string") {
return normalizePubkey(decoded.data);
}
return null;
} catch {
return null;
}
}

/**
* Extract pubkeys from NIP-27 `nostr:npub1…` URIs in content, skipping
* Markdown code spans/blocks (same masking as `@mention` matching).
*/
export function extractNip27MentionPubkeys(text: string): string[] {
if (!/nostr:npub1/i.test(text)) {
return [];
}

const masked = maskMarkdownCode(text);
const pubkeys: string[] = [];
const seen = new Set<string>();
NIP27_NPUB_RE.lastIndex = 0;
for (const match of masked.matchAll(NIP27_NPUB_RE)) {
const hex = npubToHex(match[1]);
if (!hex || seen.has(hex)) {
continue;
}
seen.add(hex);
pubkeys.push(hex);
}
return pubkeys;
}

/**
* For display, map known NIP-27 URIs back to `@displayName` so existing
* remark mention highlighting keeps working. Unknown URIs are left intact.
*/
export function replaceNip27MentionsForDisplay(
text: string,
mentionPubkeysByName: Readonly<Record<string, string>> | undefined,
): string {
if (!mentionPubkeysByName || !/nostr:npub1/i.test(text)) {
return text;
}

const nameByPubkey = new Map<string, string>();
for (const [name, pubkey] of Object.entries(mentionPubkeysByName)) {
const normalized = normalizePubkey(pubkey);
if (!nameByPubkey.has(normalized)) {
nameByPubkey.set(normalized, name);
}
}

return text.replace(NIP27_NPUB_RE, (full, npub: string) => {
const hex = npubToHex(npub);
if (!hex) {
return full;
}
const name = nameByPubkey.get(hex);
return name ? `@${name}` : full;
});
}
Loading