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);
+// });
+// });
+// });