Skip to content

Conversation

@notactuallytreyanastasio

Summary

This PR introduces Link Blogs - a social link-sharing feature designed for "power browsers" who curate and share interesting links. Think del.icio.us meets StumbleUpon, built on ATProto.

Core Value Proposition

  • For curators: Share batches of links with context, not just single URLs
  • For discoverers: Browse links from people you follow, or stumble through random discoveries
  • For the ecosystem: All data stored in user's ATProto PDS, portable and interoperable

Technical Architecture

Why Batched Links, Not Single Links?

Early in development, we considered a simpler model where each link post = one URL. However, this was revised based on how power users actually share links:

  1. Weekly roundups are common ("Here's what I read this week")
  2. Thematic collections ("3 articles on distributed systems")
  3. Reduces noise in feeds - one post with 5 links vs 5 separate posts

The batched model supports 1-50 links per post, with metadata at both the post level AND individual link level.

Data Model Design

ATProto Lexicon: pub.leaflet.link.post

lexicons/src/link.ts
lexicons/pub/leaflet/link/post.json

Record Structure:

{
  $type: "pub.leaflet.link.post",
  title?: string,           // Post-level title ("Weekly Finds")
  description?: string,     // Post-level intro
  tags?: string[],          // Post-level tags (max 10)
  links: LinkItem[],        // 1-50 links (required)
  createdAt: string,        // ISO datetime
  via?: ViaRef              // Attribution for imported content
}

// Each link in the batch:
interface LinkItem {
  url: string,              // Required, max 2048 chars
  title?: string,           // Link-specific title
  description?: string,     // User's commentary on this link
  tags?: string[],          // Link-specific tags (max 5)
  embed?: EmbedMeta         // Future: preview images, etc.
}

// For Bluesky import attribution:
interface ViaRef {
  type: "bsky-post" | "bsky-posts" | "import",
  uris?: string[]           // Source post URIs
}

Design Decisions:

  1. Separate tags at post and link level: A post about "weekly reading" might have tag weekly, while individual links have tags like rust, databases. This enables both collection-level and content-level discovery.

  2. Optional embed metadata: Reserved for future enhancement (link previews, thumbnails). Not implemented yet to keep scope manageable.

  3. Via attribution: When importing from Bluesky, we preserve the source URIs. This enables:

    • Showing "Aggregated from 5 Bluesky posts" in the UI
    • Future: linking back to original posts
    • Audit trail for imported content
  4. Max 50 links per post: Prevents abuse while allowing substantial roundups. Most posts will have 1-10 links.


Database Schema

supabase/migrations/20251212120000_add_link_posts.sql

Tables:

-- Denormalized post metadata for fast queries
CREATE TABLE link_posts (
  uri TEXT PRIMARY KEY,           -- at://did/pub.leaflet.link.post/tid
  author_did TEXT NOT NULL,       -- Foreign key to bsky_profiles
  title TEXT,
  description TEXT,
  link_count INTEGER NOT NULL,    -- Denormalized for display
  record JSONB NOT NULL,          -- Full ATProto record
  created_at TIMESTAMPTZ NOT NULL,
  indexed_at TIMESTAMPTZ DEFAULT NOW()
);

-- Individual links for tag queries and random discovery
CREATE TABLE link_items (
  id SERIAL PRIMARY KEY,
  post_uri TEXT REFERENCES link_posts(uri) ON DELETE CASCADE,
  url TEXT NOT NULL,
  title TEXT,
  description TEXT,
  tags TEXT[],                    -- Array for efficient containment queries
  position INTEGER NOT NULL,      -- Order within post
  created_at TIMESTAMPTZ NOT NULL
);

Why Two Tables?

  1. Query efficiency: Finding "all links tagged 'rust'" requires scanning link_items, not deserializing every post's JSONB
  2. Random discovery: get_random_links() operates on link_items directly
  3. Denormalization trade-off: link_count in posts table avoids COUNT(*) on every feed render

Indexes:

CREATE INDEX idx_link_posts_author ON link_posts(author_did);
CREATE INDEX idx_link_posts_created ON link_posts(created_at DESC);
CREATE INDEX idx_link_items_post ON link_items(post_uri);
CREATE INDEX idx_link_items_tags ON link_items USING GIN(tags);
CREATE INDEX idx_link_items_created ON link_items(created_at DESC);

The GIN index on tags enables efficient @> (contains) queries for tag filtering.

RPC Functions:

  1. get_followed_link_posts(viewer_did, limit, cursor)

    • Joins link_posts with bsky_follows to get posts from followed users
    • Uses cursor-based pagination on created_at
    • Returns posts with author profile data
  2. get_random_links(limit, tag_filter)

    • Uses ORDER BY RANDOM() for true randomness
    • Optional tag filter for focused discovery
    • Returns individual links, not posts (for StumbleUpon UX)
  3. get_link_posts_by_tag(tag_name, limit, cursor)

    • Finds posts containing links with the specified tag
    • Uses EXISTS subquery for efficiency
  4. search_link_tags(query)

    • Autocomplete for tag input
    • Returns distinct tags with usage counts
    • Case-insensitive prefix matching

Appview Integration

appview/index.ts

The firehose consumer now indexes pub.leaflet.link.post records:

if (collection === ids.PubLeafletLinkPost && op.action === "create") {
  const record = op.record as PubLeafletLinkPost.Record;
  
  // Upsert post metadata
  await supabase.from("link_posts").upsert({
    uri: op.uri,
    author_did: op.repo,
    title: record.title,
    description: record.description,
    link_count: record.links.length,
    record: record,
    created_at: record.createdAt,
  });

  // Index individual links
  const linkItems = record.links.map((link, i) => ({
    post_uri: op.uri,
    url: link.url,
    title: link.title,
    description: link.description,
    tags: link.tags || [],
    position: i,
    created_at: record.createdAt,
  }));
  
  await supabase.from("link_items").insert(linkItems);
}

Deletion handling: When a post is deleted, CASCADE on the foreign key automatically removes link_items.


Server Actions

actions/linkActions.ts
Action Purpose
createLinkPost(input) Create new batched link post via ATProto
deleteLinkPost(uri) Delete post from user's PDS
getMyLinkPosts(limit, cursor) Current user's posts
getAllLinkPosts(limit, cursor) Global feed
getFollowedLinkPosts(limit, cursor) Following feed (uses RPC)
getRandomLinks(limit, tag?) StumbleUpon mode
getLinkPostsByTag(tag, limit, cursor) Tag-filtered feed
getLinkPostsByUser(did, limit, cursor) User profile feed
getUserProfile(did) Fetch bsky_profile for display
getMyBskyPostsWithLinks(limit) Fetch user's Bluesky posts containing links
aggregateBskyLinksToPost(uris, title?, desc?, tags?) Bundle selected Bluesky posts into link post

Bluesky Import Logic:

// Extracts links from Bluesky posts:
// 1. Embedded external links (app.bsky.embed.external)
// 2. Link facets in post text (app.bsky.richtext.facet#link)

// Deduplicates by URL, uses post text as fallback description
// Preserves source URIs in via.uris for attribution

UI Components

Feed Component (LinksFeed.tsx)

Feed Modes:

  1. All Posts: Global chronological feed
  2. Following: Posts from users you follow (requires login)
  3. Discover: Random individual links (StumbleUpon mode)
  4. Tag: Filtered by specific tag

State Management:

  • Feed type, tag filter, posts array, cursor for pagination
  • Separate randomLinks state for discover mode (different data shape)
  • useCallback for fetch to handle cursor correctly

Tag Filter UX:

  • Text input that switches to "tag" feed mode when typing
  • Clear button to reset to "all" mode

Post Card (LinkPostCard.tsx)

Displays a batched link post:

  • Author info (avatar, handle, relative time)
  • Post-level title/description/tags
  • Link count badge
  • Expandable list of individual links
  • Each link shows: title, hostname, description, link-specific tags
  • "Via" indicator for imported content

Submit Form (LinkSubmitForm.tsx)

Modal form for creating link posts:

  • Post metadata section (title, intro, tags)
  • Dynamic link entries (add/remove, max 50)
  • Each link: URL (required), title, description, tags
  • URL validation before submit
  • Loading state with dynamic button text

Bluesky Import (BskyImportForm.tsx)

Modal for importing from Bluesky:

  • Fetches user's recent posts with links
  • Checkbox selection with select all/none
  • Shows post text preview and link hostnames
  • Displays selected count and total link count
  • Creates batched post with via attribution

User Profile (links/user/[did]/page.tsx)

Profile page showing:

  • User avatar, display name, handle
  • Total link post count
  • Chronological feed of their posts
  • Back link to main feed

Navigation Integration

components/ActionBar/Navigation.tsx
components/Icons/LinkSmall.tsx

Added "Links" to the main navigation bar with a link icon.


Trade-offs & Alternatives Considered

1. Single links vs batched posts

  • Chose: Batched posts
  • Trade-off: More complex data model, but matches real usage patterns
  • Alternative: Could add single-link shortcut in UI later

2. Tags on posts vs tags on links vs both

  • Chose: Both levels
  • Trade-off: More complex queries, but enables richer taxonomy
  • Alternative: Post-level only would be simpler

3. Denormalized link_items table vs JSONB queries

  • Chose: Separate table
  • Trade-off: More storage, but dramatically faster tag queries
  • Alternative: JSONB @> queries work but don't scale

4. Random discovery algorithm

  • Chose: ORDER BY RANDOM()
  • Trade-off: Not reproducible, can't "go back"
  • Alternative: Seeded random, shuffle-then-paginate

5. Bluesky import as batch vs individual posts

  • Chose: Batch into single post
  • Trade-off: Loses individual post timestamps
  • Alternative: Create separate link posts per Bluesky post

Testing Notes

No test framework is configured in this project. Manual testing recommended:

  1. Create link post with 1 link
  2. Create link post with multiple links and tags
  3. Test all feed modes (all, following, discover)
  4. Test tag filtering
  5. Test Bluesky import flow
  6. Test user profile page
  7. Verify appview indexes new posts

Deployment Checklist

  1. Run migration: supabase db push or apply migration manually
  2. Regenerate types: npm run generate-db-types (will error until migration applied)
  3. Restart appview: To pick up new collection filter
  4. Publish lexicon: npm run publish-lexicons (optional, for ecosystem interop)

Future Enhancements

  1. Link previews: Fetch OG metadata, store in embed field
  2. Bookmark/save: Let users save others' links
  3. Reactions: Like/boost individual links
  4. RSS export: Generate feed of user's links
  5. Browser extension: Quick-share from any page
  6. Collaborative collections: Shared link lists

Files Changed

New Files (12)

  • lexicons/src/link.ts - ATProto lexicon definition
  • lexicons/pub/leaflet/link/post.json - Generated lexicon
  • lexicons/api/types/pub/leaflet/link/post.ts - Generated types
  • supabase/migrations/20251212120000_add_link_posts.sql - Database schema
  • actions/linkActions.ts - Server actions
  • app/(home-pages)/links/page.tsx - Main page
  • app/(home-pages)/links/LinksFeed.tsx - Feed component
  • app/(home-pages)/links/LinkPostCard.tsx - Post card
  • app/(home-pages)/links/LinkSubmitForm.tsx - Submit form
  • app/(home-pages)/links/BskyImportForm.tsx - Bluesky import
  • app/(home-pages)/links/user/[did]/page.tsx - User profile
  • components/Icons/LinkSmall.tsx - Nav icon

Modified Files (4)

  • lexicons/build.ts - Include link lexicons
  • lexicons/api/index.ts - Export link types
  • lexicons/api/lexicons.ts - Register lexicon
  • appview/index.ts - Index link posts
  • components/ActionBar/Navigation.tsx - Add links nav

Decision Graph

Development decisions were logged in real-time. View the graph:
https://notactuallytreyanastasio.github.io/leaflet/

18 nodes tracking: goal → observations → decisions → actions → outcomes

- ATProto lexicon for batched link posts (pub.leaflet.link.post)
- Database migration with link_posts and link_items tables
- Server actions for CRUD, feeds, and Bluesky import
- UI: submit form, feed browser, user profiles
- Feed modes: all posts, following, discover (stumbleupon-style)
- Import links from Bluesky posts
- Navigation integration
@vercel
Copy link

vercel bot commented Dec 12, 2025

@notactuallytreyanastasio is attempting to deploy a commit to the Hyperlink Team on Vercel.

A member of the Team first needs to authorize it.

@notactuallytreyanastasio notactuallytreyanastasio marked this pull request as draft December 12, 2025 18:15
@juice928
Copy link

👋 Hi, I'm an automated AI code review bot. I ran some checks on this PR and found 3 points that might be worth attention (could be false positives, please use your judgment):

  1. The indexing logic could be more robust by ensuring update atomicity

    • appview/index.ts:L296-L311: appview/index.ts:L296-L311
    • Impact: If a process failure occurs between the delete and insert operations, it may result in permanent data loss.
    • Suggestion: Consider wrapping these operations in a single database transaction or using a Supabase RPC function.
  2. The random row selection method could impact performance as the dataset grows

    • supabase/migrations/20251212120000_add_link_posts.sql:L121: supabase/migrations/20251212120000_add_link_posts.sql:L121
    • Impact: ORDER BY RANDOM() requires sorting the entire table, which can become a major bottleneck for the "Discover" feed.
    • Suggestion: Consider using TABLESAMPLE SYSTEM or querying for a random ID range for more efficient selection.
  3. The tag search query pattern might lead to slower response times over time

    • supabase/migrations/20251212120000_add_link_posts.sql:L131-L147: supabase/migrations/20251212120000_add_link_posts.sql:L131-L147
    • Impact: Leading wildcards in LIKE queries prevent standard index usage, potentially forcing slow full table scans.
    • Suggestion: You might explore normalizing tags into a separate table or implementing PostgreSQL Full Text Search with a GIN index.

If you find these suggestions disruptive, you can reply "stop" , and I'll automatically skip this repository in the future.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants