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
4 changes: 2 additions & 2 deletions web/src/app/routes/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { ChatPage } from "@/features/chat/ui/ChatPage";
import { NuriWalletGate } from "@/features/nuri-wallet/ui/NuriWalletGate";
import { ReposPage } from "@/features/repos/ui/ReposPage";

export const Route = createFileRoute("/")({
component: IndexPage,
Expand All @@ -9,7 +9,7 @@ export const Route = createFileRoute("/")({
function IndexPage() {
return (
<NuriWalletGate>
<ReposPage />
<ChatPage />
</NuriWalletGate>
);
}
14 changes: 12 additions & 2 deletions web/src/app/routes/repos.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import { Navigate, createFileRoute } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router";
import { NuriWalletGate } from "@/features/nuri-wallet/ui/NuriWalletGate";
import { ReposPage } from "@/features/repos/ui/ReposPage";

export const Route = createFileRoute("/repos")({
component: () => <Navigate to="/" />,
component: RepositoriesPage,
});

function RepositoriesPage() {
return (
<NuriWalletGate>
<ReposPage />
</NuriWalletGate>
);
}
105 changes: 105 additions & 0 deletions web/src/features/chat/lib/chat-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import {
type NostrEvent,
publishEvent,
queryEvents,
subscribeEvents,
} from "@/shared/lib/nostr-client";
import { getBrowserPublicKey, signNostrEvent } from "@/shared/lib/nostr-signer";
import { relayWsUrl } from "@/shared/lib/relay-url";
import {
type ChatChannel,
mergeChatMessages,
projectChatChannels,
selectAutoJoinChannel,
} from "./chat";

const CHANNEL_QUERY_LIMIT = 500;
const MESSAGE_QUERY_LIMIT = 100;
const MAX_MESSAGE_LENGTH = 60_000;
const CHAT_MESSAGE_KINDS = [9, 40002];

async function queryChannelCatalog(
wsUrl: string,
pubkey: string,
): Promise<ChatChannel[]> {
const [memberships, metadata] = await Promise.all([
queryEvents(wsUrl, {
kinds: [39002],
"#p": [pubkey],
limit: CHANNEL_QUERY_LIMIT,
}),
queryEvents(wsUrl, { kinds: [39000], limit: CHANNEL_QUERY_LIMIT }),
]);
return projectChatChannels(memberships, metadata);
}

export type ChatCatalog = {
pubkey: string;
channels: ChatChannel[];
};

export async function loadChatCatalog(): Promise<ChatCatalog> {
const wsUrl = relayWsUrl();
const pubkey = await getBrowserPublicKey();
let channels = await queryChannelCatalog(wsUrl, pubkey);
const autoJoin = selectAutoJoinChannel(channels);
if (autoJoin) {
await joinChatChannel(autoJoin.id);
channels = await queryChannelCatalog(wsUrl, pubkey);
}
return { pubkey, channels };
}

export async function joinChatChannel(channelId: string): Promise<void> {
const signed = await signNostrEvent({
kind: 9021,
tags: [["h", channelId]],
content: "",
});
await publishEvent(relayWsUrl(), signed);
}

export async function loadChatMessages(
channelId: string,
): Promise<NostrEvent[]> {
const events = await queryEvents(relayWsUrl(), {
kinds: CHAT_MESSAGE_KINDS,
"#h": [channelId],
limit: MESSAGE_QUERY_LIMIT,
});
return mergeChatMessages([], events);
}

export async function sendChatMessage(
channelId: string,
content: string,
): Promise<NostrEvent> {
const trimmed = content.trim();
if (!trimmed) throw new Error("Write a message first.");
if (trimmed.length > MAX_MESSAGE_LENGTH) {
throw new Error("Message is too long.");
}
const signed = await signNostrEvent({
kind: 9,
tags: [["h", channelId]],
content: trimmed,
});
await publishEvent(relayWsUrl(), signed);
return signed;
}

export function subscribeToChatChannel(
channelId: string,
onEvent: (event: NostrEvent) => void,
onError: (error: Error) => void,
): () => void {
return subscribeEvents(
relayWsUrl(),
{
kinds: CHAT_MESSAGE_KINDS,
"#h": [channelId],
since: Math.floor(Date.now() / 1000),
},
{ onEvent, onError },
);
}
111 changes: 111 additions & 0 deletions web/src/features/chat/lib/chat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import type { NostrEvent } from "@/shared/lib/nostr-client";

export type ChatChannelType = "stream" | "forum" | "dm" | "private" | "unknown";
export type ChatChannelVisibility = "open" | "private";

export type ChatChannel = {
id: string;
name: string;
type: ChatChannelType;
visibility: ChatChannelVisibility;
isMember: boolean;
};

const CHAT_MESSAGE_KINDS = new Set([9, 40002]);

function tagValue(event: NostrEvent, name: string): string | undefined {
return event.tags.find((tag) => tag[0] === name)?.[1];
}

function hasTag(event: NostrEvent, name: string): boolean {
return event.tags.some((tag) => tag[0] === name);
}

function channelType(event: NostrEvent): ChatChannelType {
const declared = tagValue(event, "t");
if (declared === "forum" || declared === "dm" || declared === "private") {
return declared;
}
if (hasTag(event, "hidden")) return "dm";
if (hasTag(event, "private")) return "private";
return declared ? "unknown" : "stream";
}

function channelVisibility(event: NostrEvent): ChatChannelVisibility {
return hasTag(event, "private") || channelType(event) === "private"
? "private"
: "open";
}

export function projectChatChannels(
membershipEvents: NostrEvent[],
metadataEvents: NostrEvent[],
): ChatChannel[] {
const memberships = new Set(
membershipEvents
.filter((event) => event.kind === 39002)
.map((event) => tagValue(event, "d"))
.filter((id): id is string => Boolean(id)),
);
const latestMetadata = new Map<string, NostrEvent>();

for (const event of metadataEvents) {
if (event.kind !== 39000) continue;
const id = tagValue(event, "d");
if (!id) continue;
const previous = latestMetadata.get(id);
if (!previous || event.created_at >= previous.created_at) {
latestMetadata.set(id, event);
}
}

return [...latestMetadata.entries()]
.filter(([, event]) => tagValue(event, "archived") !== "true")
.map(([id, event]): ChatChannel => {
const isMember = memberships.has(id);
return {
id,
name: tagValue(event, "name")?.trim() || "unnamed",
type: channelType(event),
visibility: channelVisibility(event),
isMember,
};
})
.filter((channel) => channel.visibility === "open" || channel.isMember)
.sort((left, right) => {
if (left.isMember !== right.isMember) return left.isMember ? -1 : 1;
const leftGeneral = left.name.toLowerCase() === "general";
const rightGeneral = right.name.toLowerCase() === "general";
if (leftGeneral !== rightGeneral) return leftGeneral ? -1 : 1;
return left.name.localeCompare(right.name, undefined, {
sensitivity: "base",
});
});
}

export function selectAutoJoinChannel(
channels: ChatChannel[],
): ChatChannel | null {
if (channels.some((channel) => channel.isMember)) return null;
const candidates = channels.filter(
(channel) =>
channel.visibility === "open" &&
channel.type === "stream" &&
channel.name.toLowerCase() === "general",
);
return candidates.length === 1 ? candidates[0] : null;
}

export function mergeChatMessages(
current: NostrEvent[],
incoming: NostrEvent[],
): NostrEvent[] {
const byId = new Map<string, NostrEvent>();
for (const event of [...current, ...incoming]) {
if (CHAT_MESSAGE_KINDS.has(event.kind)) byId.set(event.id, event);
}
return [...byId.values()].sort(
(left, right) =>
left.created_at - right.created_at || left.id.localeCompare(right.id),
);
}
Loading
Loading