Bump to v0.4.0 — Phase 3: image lightbox, bookmarks, discover, language filter, UI polish

This commit is contained in:
Jure
2026-03-14 17:56:50 +01:00
parent af8a364e28
commit c8d2b05440
25 changed files with 670 additions and 61 deletions

71
src/lib/language.ts Normal file
View File

@@ -0,0 +1,71 @@
// Unicode script detection for feed filtering
const SCRIPT_RANGES: [string, RegExp][] = [
["Latin", /[\u0041-\u024F\u1E00-\u1EFF]/],
["CJK", /[\u2E80-\u2FFF\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFFEF]|[\uD840-\uD87F][\uDC00-\uDFFF]/],
["Cyrillic", /[\u0400-\u04FF\u0500-\u052F]/],
["Arabic", /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF]/],
["Devanagari", /[\u0900-\u097F]/],
["Thai", /[\u0E00-\u0E7F]/],
["Korean", /[\uAC00-\uD7AF\u1100-\u11FF]/],
["Hebrew", /[\u0590-\u05FF]/],
["Greek", /[\u0370-\u03FF]/],
["Georgian", /[\u10A0-\u10FF]/],
["Armenian", /[\u0530-\u058F]/],
];
export function detectScript(text: string): string {
// Strip URLs, mentions, hashtags to avoid noise
const cleaned = text
.replace(/https?:\/\/\S+/g, "")
.replace(/nostr:\S+/g, "")
.replace(/#\w+/g, "")
.trim();
if (!cleaned) return "Unknown";
// Count characters per script
const counts = new Map<string, number>();
for (const char of cleaned) {
for (const [name, regex] of SCRIPT_RANGES) {
if (regex.test(char)) {
counts.set(name, (counts.get(name) ?? 0) + 1);
break;
}
}
}
if (counts.size === 0) return "Unknown";
// Return dominant script
let maxScript = "Unknown";
let maxCount = 0;
for (const [script, count] of counts) {
if (count > maxCount) {
maxScript = script;
maxCount = count;
}
}
return maxScript;
}
// Check NIP-32 language tags on an event
export function getEventLanguageTag(tags: string[][]): string | null {
const langTag = tags.find(
(t) => t[0] === "l" && t[2] === "ISO-639-1"
);
return langTag?.[1] ?? null;
}
export const FILTER_SCRIPTS = [
"Latin",
"CJK",
"Cyrillic",
"Arabic",
"Devanagari",
"Thai",
"Korean",
"Hebrew",
"Greek",
] as const;

View File

@@ -445,6 +445,29 @@ export async function fetchZapsSent(pubkey: string, limit = 50): Promise<NDKEven
return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0));
}
// ── Bookmarks (NIP-51 kind 10003) ────────────────────────────────────────────
export async function fetchBookmarkList(pubkey: string): Promise<string[]> {
const instance = getNDK();
const filter: NDKFilter = { kinds: [10003 as NDKKind], authors: [pubkey], limit: 1 };
const events = await instance.fetchEvents(filter, {
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
});
if (events.size === 0) return [];
const event = Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))[0];
return event.tags.filter((t) => t[0] === "e" && t[1]).map((t) => t[1]);
}
export async function publishBookmarkList(eventIds: string[]): Promise<void> {
const instance = getNDK();
if (!instance.signer) return;
const event = new NDKEvent(instance);
event.kind = 10003 as NDKKind;
event.content = "";
event.tags = eventIds.map((id) => ["e", id]);
await event.publish();
}
export async function fetchMuteList(pubkey: string): Promise<string[]> {
const instance = getNDK();
const filter: NDKFilter = { kinds: [10000 as NDKKind], authors: [pubkey], limit: 1 };
@@ -520,6 +543,44 @@ export async function fetchUserNotesNIP65(pubkey: string, limit = 30): Promise<N
// ── Notifications (mentions) ──────────────────────────────────────────────────
// ── Follow Suggestions (follows-of-follows) ─────────────────────────────────
export async function fetchFollowSuggestions(myFollows: string[]): Promise<{ pubkey: string; mutualCount: number }[]> {
if (myFollows.length === 0) return [];
const instance = getNDK();
// Fetch contact lists (kind 3) from our follows
const batchSize = 20;
const allContactEvents: NDKEvent[] = [];
for (let i = 0; i < myFollows.length; i += batchSize) {
const batch = myFollows.slice(i, i + batchSize);
const filter: NDKFilter = { kinds: [3 as NDKKind], authors: batch, limit: batch.length };
const events = await instance.fetchEvents(filter, {
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
});
allContactEvents.push(...Array.from(events));
}
// Count how many of our follows follow each pubkey
const myFollowSet = new Set(myFollows);
const counts = new Map<string, number>();
for (const event of allContactEvents) {
const pubkeys = event.tags.filter((t) => t[0] === "p" && t[1]).map((t) => t[1]);
for (const pk of pubkeys) {
if (myFollowSet.has(pk)) continue; // already following
counts.set(pk, (counts.get(pk) ?? 0) + 1);
}
}
// Remove self
const myPubkey = (await instance.signer?.user())?.pubkey;
if (myPubkey) counts.delete(myPubkey);
return Array.from(counts.entries())
.map(([pubkey, mutualCount]) => ({ pubkey, mutualCount }))
.sort((a, b) => b.mutualCount - a.mutualCount)
.slice(0, 30);
}
export async function fetchMentions(pubkey: string, since: number, limit = 50): Promise<NDKEvent[]> {
const instance = getNDK();
const events = await instance.fetchEvents(

View File

@@ -1,2 +1,2 @@
export { getNDK, connectToRelays, fetchGlobalFeed, fetchFollowFeed, fetchReplies, publishNote, publishArticle, publishProfile, publishReaction, publishRepost, publishQuote, publishReply, publishContactList, fetchReactionCount, fetchZapCount, fetchNoteById, fetchUserNotes, fetchProfile, fetchArticle, fetchAuthorArticles, fetchZapsReceived, fetchZapsSent, fetchDMConversations, fetchDMThread, sendDM, decryptDM, fetchMuteList, publishMuteList, getStoredRelayUrls, addRelay, removeRelay, searchNotes, searchUsers, fetchUserRelayList, publishRelayList, fetchUserNotesNIP65, fetchMentions } from "./client";
export { getNDK, connectToRelays, fetchGlobalFeed, fetchFollowFeed, fetchReplies, publishNote, publishArticle, publishProfile, publishReaction, publishRepost, publishQuote, publishReply, publishContactList, fetchReactionCount, fetchZapCount, fetchNoteById, fetchUserNotes, fetchProfile, fetchArticle, fetchAuthorArticles, fetchZapsReceived, fetchZapsSent, fetchDMConversations, fetchDMThread, sendDM, decryptDM, fetchBookmarkList, publishBookmarkList, fetchMuteList, publishMuteList, getStoredRelayUrls, addRelay, removeRelay, searchNotes, searchUsers, fetchUserRelayList, publishRelayList, fetchUserNotesNIP65, fetchFollowSuggestions, fetchMentions } from "./client";
export type { UserRelayList } from "./client";