Files
buzz/desktop/src/shared/api/relayMembers.ts
Wes e341b09cb9 fix: expose community icon control on open relays (#2640)
Signed-off-by: npub1cl47vfhsqpqy9pwndphpm36vcp7vvz5h2js4qpqm5yewzj7nutkq7xyw8c <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
Co-authored-by: npub1cl47vfhsqpqy9pwndphpm36vcp7vvz5h2js4qpqm5yewzj7nutkq7xyw8c <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
2026-07-23 16:13:48 -07:00

194 lines
5.2 KiB
TypeScript

import { relayClient } from "@/shared/api/relayClient";
import { invokeTauri, signRelayEvent } from "@/shared/api/tauri";
import { getIdentity } from "@/shared/api/tauriIdentity";
import type {
RelayEvent,
RelayMember,
RelayMemberRole,
} from "@/shared/api/types";
const KIND_NIP43_MEMBERSHIP_LIST = 13534;
const KIND_RELAY_ADMIN_ADD_MEMBER = 9030;
const KIND_RELAY_ADMIN_REMOVE_MEMBER = 9031;
const KIND_RELAY_ADMIN_CHANGE_ROLE = 9032;
function isRelayMemberRole(
value: string | undefined,
): value is RelayMemberRole {
return value === "owner" || value === "admin" || value === "member";
}
function normalizePubkey(pubkey: string): string {
return pubkey.trim().toLowerCase();
}
function eventCreatedAtIso(event: RelayEvent): string {
return new Date(event.created_at * 1_000).toISOString();
}
export type RelayMembershipLookup = {
/**
* True when the relay returned a NIP-43 membership snapshot.
*
* Open relays do not publish kind:13534, so absence of this snapshot must not
* be treated as a denial by onboarding.
*/
snapshotFound: boolean;
membershipRequired: boolean;
membership: RelayMember | null;
};
export function canEditCommunityProfile(
lookup: RelayMembershipLookup | undefined,
): boolean {
const role = lookup?.membership?.role;
return (
lookup?.membershipRequired === false || role === "owner" || role === "admin"
);
}
export function shouldWarnMissingMembershipSnapshot(
lookup: RelayMembershipLookup | undefined,
): boolean {
return lookup?.membershipRequired === true && !lookup.snapshotFound;
}
export function relayMembersFromEvent(event: RelayEvent): RelayMember[] {
const seen = new Set<string>();
const members: RelayMember[] = [];
const createdAt = eventCreatedAtIso(event);
for (const tag of event.tags) {
const [name, rawPubkey, maybeRoleOrRelay, maybePTagRole] = tag;
if (name !== "member" && name !== "p") continue;
if (!rawPubkey) continue;
const pubkey = normalizePubkey(rawPubkey);
if (!/^[0-9a-f]{64}$/.test(pubkey) || seen.has(pubkey)) continue;
seen.add(pubkey);
const rawRole = name === "member" ? maybeRoleOrRelay : maybePTagRole;
const role = isRelayMemberRole(rawRole) ? rawRole : "member";
members.push({
pubkey,
role,
addedBy: null,
createdAt,
});
}
return members;
}
export function relayMembershipLookupFromEvent(
event: RelayEvent | null,
pubkey: string,
membershipRequired = event !== null,
): RelayMembershipLookup {
if (!event) {
return {
snapshotFound: false,
membershipRequired,
membership: null,
};
}
const normalizedPubkey = normalizePubkey(pubkey);
return {
snapshotFound: true,
membershipRequired,
membership:
relayMembersFromEvent(event).find(
(member) => normalizePubkey(member.pubkey) === normalizedPubkey,
) ?? null,
};
}
async function fetchMembershipListEvent(): Promise<RelayEvent | null> {
const events = await relayClient.fetchEvents({
kinds: [KIND_NIP43_MEMBERSHIP_LIST],
limit: 1,
});
return events[events.length - 1] ?? null;
}
/** Loads the NIP-43 snapshot only when the relay advertises membership support. */
export async function loadRelayMembershipLookup(
pubkey: string,
membershipRequired: boolean,
fetchSnapshot: () => Promise<RelayEvent | null> = fetchMembershipListEvent,
): Promise<RelayMembershipLookup> {
if (!membershipRequired) {
return relayMembershipLookupFromEvent(null, pubkey, false);
}
return relayMembershipLookupFromEvent(await fetchSnapshot(), pubkey, true);
}
export async function listRelayMembers(): Promise<RelayMember[]> {
const event = await fetchMembershipListEvent();
return event ? relayMembersFromEvent(event) : [];
}
async function relayRequiresMembership(): Promise<boolean> {
return invokeTauri<boolean>("relay_requires_membership");
}
export async function getMyRelayMembershipLookup(): Promise<RelayMembershipLookup> {
const [{ pubkey }, membershipRequired] = await Promise.all([
getIdentity(),
relayRequiresMembership(),
]);
return loadRelayMembershipLookup(pubkey, membershipRequired);
}
export async function getMyRelayMembership(): Promise<RelayMember | null> {
return (await getMyRelayMembershipLookup()).membership;
}
async function publishRelayAdminEvent(
kind: number,
targetPubkey: string,
role?: string,
): Promise<void> {
const tags = [["p", normalizePubkey(targetPubkey)]];
if (role) {
tags.push(["role", role]);
}
const event = await signRelayEvent({
kind,
content: "",
tags,
});
await relayClient.publishEvent(
event,
"Timed out while updating relay access.",
"Failed to update relay access.",
);
}
export async function addRelayMember(
targetPubkey: string,
role: string,
): Promise<void> {
await publishRelayAdminEvent(KIND_RELAY_ADMIN_ADD_MEMBER, targetPubkey, role);
}
export async function removeRelayMember(targetPubkey: string): Promise<void> {
await publishRelayAdminEvent(KIND_RELAY_ADMIN_REMOVE_MEMBER, targetPubkey);
}
export async function changeRelayMemberRole(
targetPubkey: string,
newRole: string,
): Promise<void> {
await publishRelayAdminEvent(
KIND_RELAY_ADMIN_CHANGE_ROLE,
targetPubkey,
newRole,
);
}