diff --git a/assets/css/layout.css b/assets/css/layout.css index 18c696f..6986c45 100644 --- a/assets/css/layout.css +++ b/assets/css/layout.css @@ -142,6 +142,14 @@ main article footer { margin-top: 5rem; } +main article footer .actions { + margin-bottom: 3rem; +} + +main article footer p { + font-size: 1rem; +} + .nip05 .verified, .nip05 .not-verified { margin-left: 0.3rem; diff --git a/assets/css/themes/default-light.css b/assets/css/themes/default-light.css index 5e880b0..6052e80 100644 --- a/assets/css/themes/default-light.css +++ b/assets/css/themes/default-light.css @@ -76,6 +76,17 @@ main.profile-page .pubkey { color: var(--text-color-discreet); } +main article footer a, +main article footer a:visited { + text-decoration: none; + color: var(--text-color-discreet); +} + +main article footer a:hover, +main article footer a:active { + text-decoration: underline; +} + .nip05 .verified { background-color: #e8e3da; color: #027739; diff --git a/handlers/user-profile.ts b/handlers/user-profile.ts index 5721f07..2a5480f 100644 --- a/handlers/user-profile.ts +++ b/handlers/user-profile.ts @@ -19,7 +19,9 @@ const userProfileHandler = async function (ctx: Context) { if (profileEvent) { const profile = new Profile(profileEvent, username); - const articles = await fetchArticlesByAuthor(pubkey, 210); + + const articles = await fetchArticlesByAuthor(pubkey, 210, ctx.state.tags); + const html = await profilePageHtml(profile, articles); generateOgProfileImage(profile); diff --git a/html.ts b/html.ts index c5a7818..46c4e1a 100644 --- a/html.ts +++ b/html.ts @@ -38,6 +38,16 @@ export function errorPageHtml(statusCode: number, title: string): string { return htmlLayout({ title, body }); } +function articleTagsHtml(article: Article, profile: Profile): string { + if (article.tags.length === 0) return ""; + + const tags = article.tags.map((tag) => { + return `${tag}`; + }); + + return `Tags: ${tags.join(", ")}

\n`; +} + export async function articleHtml( article: Article, profile: Profile, @@ -65,7 +75,12 @@ export async function articleHtml(
${await article.buildContentHtml()}
diff --git a/models/article.ts b/models/article.ts index 5b423d0..916765e 100644 --- a/models/article.ts +++ b/models/article.ts @@ -39,6 +39,13 @@ export default class Article { return tag ? tag[1] : ""; } + get tags(): string[] { + return this.event.tags + .filter((t) => t[0] === "t") + .filter((t) => t[1] !== "") + .map((t) => t[1]); + } + get publishedAt(): number { const tag = this.event.tags.find((t) => t[0] === "published_at"); return tag ? parseInt(tag[1]) : this.event.created_at; diff --git a/nostr.ts b/nostr.ts index 03ff19a..89dfd24 100644 --- a/nostr.ts +++ b/nostr.ts @@ -15,7 +15,7 @@ const relayPool = new NPool({ eventRouter: async (_event) => [], }); -async function fetchWithTimeout(filters: NostrFilter[]) { +export async function fetchWithTimeout(filters: NostrFilter[]) { const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("relay timeout")), config.query_timeout) ); @@ -50,16 +50,28 @@ export async function fetchReplaceableEvent( } } +export function createTagList( + articles: Article[], +): Record { + return articles.flatMap((a) => a.tags).reduce((acc, tag) => { + acc[tag] = (acc[tag] || 0) + 1; + return acc; + }, {} as Record); +} + export async function fetchArticlesByAuthor( pubkey: string, limit: number = 10, + tags?: string[], ) { - const events = await fetchWithTimeout([{ + const filter = { authors: [pubkey], kinds: [30023], limit: limit, - }]) as NostrEvent[]; + }; + if (typeof tags !== "undefined") filter["#t"] = tags; + const events = await fetchWithTimeout([filter]) as NostrEvent[]; const articles = events.map((a) => new Article(a)); return articles diff --git a/server.ts b/server.ts index 90d680b..253cab3 100644 --- a/server.ts +++ b/server.ts @@ -26,6 +26,10 @@ router.get("/:path", async (ctx) => { } else if (path.startsWith("npub")) { await npubHandler(ctx); } else if (path.startsWith("@") || path.startsWith("~")) { + const tags = ctx.request.url.searchParams.get("tags"); + if (typeof tags === "string" && tags !== "") { + ctx.state.tags = tags.split(","); + } await userProfileHandler(ctx); } else { notFoundHandler(ctx); diff --git a/tests/models/article_test.ts b/tests/models/article_test.ts index 4519ec7..8f9395c 100644 --- a/tests/models/article_test.ts +++ b/tests/models/article_test.ts @@ -47,6 +47,16 @@ describe("Article", () => { }); }); + describe("#tags", () => { + it("returns a flattened tag list", () => { + expect(article.tags).toEqual([ + "lightning", + "lightning network", + "howto", + ]); + }); + }); + describe("#publishedAt", () => { it("returns the value of the first 'published_at' tag", () => { expect(article.publishedAt).toEqual(1726402055); diff --git a/tests/nostr_test.ts b/tests/nostr_test.ts new file mode 100644 index 0000000..2d3f641 --- /dev/null +++ b/tests/nostr_test.ts @@ -0,0 +1,27 @@ +// import { describe, it } from "@std/testing/bdd"; +// import { stub } from "@std/testing/mock"; +// import { expect } from "@std/expect"; +// import { NostrEvent, NostrFilter } from "@nostrify/nostrify"; +// import * as nostr from "../nostr.ts"; +// +// async function fetchWithTimeout(filters: NostrFilter[]) { +// console.log("================") +// const events = []; +// const fixtures = [ "article-1.json", "article-deleted.json" ] +// for (const filename of fixtures) { +// const event = JSON.parse(Deno.readTextFileSync(`tests/fixtures/${filename}`)); +// events.push(event); +// } +// return Promise.resolve(events); +// } +// +// describe("Nostr", () => { +// describe("#fetchArticlesByAuthor", () => { +// it("removes the anchor links for headlines", async () => { +// stub(nostr, "fetchArticlesByAuthor"); +// +// const articles = await nostr.fetchArticlesByAuthor("123456abcdef"); +// expect(articles.length).toEqual(2); +// }); +// }); +// });