f41c6a4126
- Add Following/Global feed tabs to Books page with sticky tab bar - Add KindInfoButton linking to Bookstr (bookstr.xyz) for content creation - Add text truncation with 'Read more' fade (300px max-height, matching NoteCard) - Include kind 1111 book comments in the feed with 'commented' badge - Comments on books navigate to the book page instead of event detail - Add Reviews tab on book detail pages (/i/isbn:*) alongside Comments - Add review submission form with 5-star rating, spoiler warnings, and edit support - Add 'Write Review' button in book page action bar and Reviews tab - Support useFollowList for follows-tab scoped book queries
118 lines
3.6 KiB
TypeScript
118 lines
3.6 KiB
TypeScript
import type { NostrEvent } from '@nostrify/nostrify';
|
|
|
|
/** Bookstr NIP-XX event kind constants. */
|
|
export const BOOKSTR_KINDS = {
|
|
READ_BOOKS: 10073,
|
|
CURRENTLY_READING: 10074,
|
|
TO_BE_READ: 10075,
|
|
BOOK_REVIEW: 31985,
|
|
READING_GOAL: 30078,
|
|
} as const;
|
|
|
|
/** Parsed book review data. */
|
|
export interface BookReview {
|
|
isbn: string;
|
|
content: string;
|
|
/** 0-1 fraction, where 1.0 = 5/5 stars. */
|
|
rating?: number;
|
|
contentWarning?: string;
|
|
}
|
|
|
|
/** Check if a Nostr event is book-related. */
|
|
export function isBookEvent(event: NostrEvent): boolean {
|
|
// Check for bookstr hashtag
|
|
if (event.tags.some(([name, value]) => name === 't' && value === 'bookstr')) {
|
|
return true;
|
|
}
|
|
|
|
// Check for ISBN references (lowercase i tag — kind 1 posts)
|
|
if (event.tags.some(([name, value]) => name === 'i' && value?.startsWith('isbn:'))) {
|
|
return true;
|
|
}
|
|
|
|
// Check for ISBN references (uppercase I tag — kind 1111 NIP-22 comments on books)
|
|
if (event.tags.some(([name, value]) => name === 'I' && value?.startsWith('isbn:'))) {
|
|
return true;
|
|
}
|
|
|
|
// Check for book-specific kinds
|
|
return (Object.values(BOOKSTR_KINDS) as number[]).includes(event.kind);
|
|
}
|
|
|
|
/** Extract an ISBN from a book event, or null if none found. */
|
|
export function extractISBNFromEvent(event: NostrEvent): string | null {
|
|
// For reviews (kind 31985), check the d tag
|
|
if (event.kind === BOOKSTR_KINDS.BOOK_REVIEW) {
|
|
const dTag = event.tags.find(([name]) => name === 'd')?.[1];
|
|
if (dTag?.startsWith('isbn:')) {
|
|
return dTag.replace('isbn:', '');
|
|
}
|
|
}
|
|
|
|
// For kind 1111 comments, check uppercase I tag (NIP-22 root reference)
|
|
if (event.kind === 1111) {
|
|
const iTag = event.tags.find(
|
|
([name, value]) => name === 'I' && value?.startsWith('isbn:'),
|
|
)?.[1];
|
|
if (iTag) return iTag.replace('isbn:', '');
|
|
}
|
|
|
|
// For other events, check lowercase i tags
|
|
const iTag = event.tags.find(
|
|
([name, value]) => name === 'i' && value?.startsWith('isbn:'),
|
|
)?.[1];
|
|
return iTag ? iTag.replace('isbn:', '') : null;
|
|
}
|
|
|
|
/** Parse a kind 31985 event into a BookReview, or null if invalid. */
|
|
export function parseBookReview(event: NostrEvent): BookReview | null {
|
|
if (event.kind !== BOOKSTR_KINDS.BOOK_REVIEW) return null;
|
|
|
|
const dTag = event.tags.find(([name]) => name === 'd')?.[1];
|
|
if (!dTag?.startsWith('isbn:')) return null;
|
|
|
|
const isbn = dTag.replace('isbn:', '');
|
|
const ratingTag = event.tags.find(([name]) => name === 'rating')?.[1];
|
|
const contentWarningTag = event.tags.find(([name]) => name === 'content-warning')?.[1];
|
|
|
|
let rating: number | undefined;
|
|
if (ratingTag) {
|
|
const parsed = parseFloat(ratingTag);
|
|
if (!isNaN(parsed) && parsed >= 0 && parsed <= 1) {
|
|
rating = parsed;
|
|
}
|
|
}
|
|
|
|
return {
|
|
isbn,
|
|
content: event.content,
|
|
rating,
|
|
contentWarning: contentWarningTag,
|
|
};
|
|
}
|
|
|
|
/** Validate that a kind 31985 event has the required tags. */
|
|
export function validateBookReview(event: NostrEvent): boolean {
|
|
if (event.kind !== BOOKSTR_KINDS.BOOK_REVIEW) return false;
|
|
|
|
const dTag = event.tags.find(([name]) => name === 'd')?.[1];
|
|
if (!dTag?.startsWith('isbn:')) return false;
|
|
|
|
const kTag = event.tags.find(([name]) => name === 'k')?.[1];
|
|
if (kTag !== 'isbn') return false;
|
|
|
|
// If rating tag exists, validate range
|
|
const ratingTag = event.tags.find(([name]) => name === 'rating')?.[1];
|
|
if (ratingTag) {
|
|
const parsed = parseFloat(ratingTag);
|
|
if (isNaN(parsed) || parsed < 0 || parsed > 1) return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/** Convert a 0-1 rating fraction to a 0-5 star count (rounded to nearest integer). */
|
|
export function ratingToStars(fraction: number): number {
|
|
return Math.round(fraction * 5);
|
|
}
|