Skip to content

Commit

Permalink
Merge pull request #24 from hyphacoop/replies
Browse files Browse the repository at this point in the history
feat: implement replies for social reader
  • Loading branch information
akhileshthite authored Aug 22, 2024
2 parents 19517ec + 104d24f commit 59eb3b7
Show file tree
Hide file tree
Showing 10 changed files with 270 additions and 89 deletions.
202 changes: 140 additions & 62 deletions db.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ export function isP2P (url) {
return url.startsWith(HYPER_PREFIX) || url.startsWith(IPNS_PREFIX)
}

const TIMELINE_ALL = 'all'
const TIMELINE_FOLLOWING = 'following'

export class ActivityPubDB extends EventTarget {
constructor (db, fetch = globalThis.fetch) {
super()
Expand All @@ -50,7 +53,7 @@ export class ActivityPubDB extends EventTarget {
}

static async load (name = DEFAULT_DB, fetch = globalThis.fetch) {
const db = await openDB(name, 2, {
const db = await openDB(name, 3, {
upgrade
})

Expand Down Expand Up @@ -209,22 +212,18 @@ export class ActivityPubDB extends EventTarget {
await tx.done()
}

async * searchNotes ({ attributedTo } = {}, { skip = 0, limit = DEFAULT_LIMIT, sort = -1 } = {}) {
async * searchNotes ({ attributedTo, inReplyTo, timeline } = {}, { skip = 0, limit = DEFAULT_LIMIT, sort = -1 } = {}) {
const tx = this.db.transaction(NOTES_STORE, 'readonly')
let count = 0
const direction = sort > 0 ? 'next' : (sort === 0 ? 'next' : 'prev') // 'prev' for descending order
let cursor = null

const indexName = attributedTo ? ATTRIBUTED_TO_FIELD + ', published' : PUBLISHED_FIELD

const indexName = timeline ? 'timeline, published' : inReplyTo ? IN_REPLY_TO_FIELD : (attributedTo ? `${ATTRIBUTED_TO_FIELD}, published` : PUBLISHED_FIELD)
console.log('Using index:', indexName)
const index = tx.store.index(indexName)
const direction = sort > 0 ? 'next' : 'prev'

if (sort === 0) { // Random sort
// TODO: Consider removing duplicates in the future to improve UX
const totalNotes = await index.count()
for (let i = 0; i < limit; i++) {
const randomSkip = Math.floor(Math.random() * totalNotes)
cursor = await index.openCursor()
const cursor = await index.openCursor(null, 'next')
if (randomSkip > 0) {
await cursor.advance(randomSkip)
}
Expand All @@ -233,21 +232,25 @@ export class ActivityPubDB extends EventTarget {
}
}
} else {
if (attributedTo) {
let cursor
if (timeline) {
cursor = await index.openCursor([timeline], direction)
} else if (attributedTo) {
cursor = await index.openCursor([attributedTo], direction)
} else if (inReplyTo) {
cursor = await index.openCursor(inReplyTo, direction)
} else {
cursor = await index.openCursor(null, direction)
}

// Skip the required entries
if (skip) await cursor.advance(skip)

// Collect the required limit of entries
let count = 0
while (cursor) {
if (count >= limit) break
count++
yield cursor.value
cursor = await cursor.continue()
count++
}
}

Expand All @@ -259,6 +262,17 @@ export class ActivityPubDB extends EventTarget {
const actor = await this.getActor(url)
console.log('Actor received:', actor)

// Add 'following' to timeline if the actor is followed
const isFollowing = await this.isActorFollowed(url)
if (isFollowing) {
for await (const note of this.searchNotes({ attributedTo: actor.id })) {
if (!note.timeline.includes(TIMELINE_FOLLOWING)) {
note.timeline.push(TIMELINE_FOLLOWING)
await this.db.put(NOTES_STORE, note)
}
}
}

// If actor has an 'outbox', ingest it as a collection
if (actor.outbox) {
await this.ingestActivityCollection(actor.outbox, actor.id, isInitial)
Expand Down Expand Up @@ -399,24 +413,46 @@ export class ActivityPubDB extends EventTarget {

async ingestNote (note) {
console.log('Ingesting note', note)
// Convert needed fields to date
note.published = new Date(note.published)
// Add tag_names field
note.tag_names = (note.tags || []).map(({ name }) => name)
// Try to retrieve an existing note from the database

if (typeof note === 'string') {
note = await this.getNote(note) // Fetch the note if it's just a URL string
}

note.published = new Date(note.published) // Convert published to Date
note.tag_names = (note.tags || []).map(({ name }) => name) // Extract tag names
note.timeline = [TIMELINE_ALL]

const isFollowingAuthor = await this.isActorFollowed(note.attributedTo)
if (isFollowingAuthor) {
note.timeline.push(TIMELINE_FOLLOWING)
}

const existingNote = await this.db.get(NOTES_STORE, note.id)
console.log(existingNote)
// If there's an existing note and the incoming note is newer, update it
if (existingNote && new Date(note.published) > new Date(existingNote.published)) {
console.log(`Updating note with newer version: ${note.id}`)
await this.db.put(NOTES_STORE, note)
} else if (!existingNote) {
// If no existing note, just add the new note
console.log(`Adding new note: ${note.id}`)
await this.db.put(NOTES_STORE, note)
}
// If the existing note is newer, do not replace it
// TODO: Loop through replies

// Handle replies recursively
if (note.replies) {
console.log('Attempting to load replies for:', note.id)
await this.ingestReplies(note.replies)
}
}

async ingestReplies (url) {
console.log('Ingesting replies for URL:', url)
try {
const replies = await this.iterateCollection(url, { limit: Infinity })
for await (const reply of replies) {
await this.ingestNote(reply) // Recursively ingest replies
}
} catch (error) {
console.error('Error ingesting replies:', error)
}
}

async deleteNote (url) {
Expand Down Expand Up @@ -502,6 +538,20 @@ export class ActivityPubDB extends EventTarget {
return followedActors.length > 0
}

async replyCount (inReplyTo) {
console.log(`Counting replies for ${inReplyTo}`)
await this.ingestNote(inReplyTo) // Ensure the note and its replies are ingested before counting
const tx = this.db.transaction(NOTES_STORE, 'readonly')
const store = tx.objectStore(NOTES_STORE)

// Check if the index is correctly setup
const index = store.index(IN_REPLY_TO_FIELD)

const count = await index.count(inReplyTo)
console.log(`Found ${count} replies for ${inReplyTo}`)
return count
}

async setTheme (themeName) {
await this.db.put('settings', { key: 'theme', value: themeName })
}
Expand All @@ -512,51 +562,79 @@ export class ActivityPubDB extends EventTarget {
}
}

function upgrade (db) {
const actors = db.createObjectStore(ACTORS_STORE, {
keyPath: 'id',
autoIncrement: false
})

actors.createIndex(CREATED_FIELD, CREATED_FIELD)
actors.createIndex(UPDATED_FIELD, UPDATED_FIELD)
actors.createIndex(URL_FIELD, URL_FIELD)

db.createObjectStore(FOLLOWED_ACTORS_STORE, {
keyPath: 'url'
})

const notes = db.createObjectStore(NOTES_STORE, {
keyPath: 'id',
autoIncrement: false
})
notes.createIndex(ATTRIBUTED_TO_FIELD, ATTRIBUTED_TO_FIELD, { unique: false })
notes.createIndex(PUBLISHED_FIELD, PUBLISHED_FIELD, { unique: false })
addRegularIndex(notes, TO_FIELD)
addRegularIndex(notes, URL_FIELD)
addRegularIndex(notes, TAG_NAMES_FIELD, { multiEntry: true })
addSortedIndex(notes, IN_REPLY_TO_FIELD)
addSortedIndex(notes, ATTRIBUTED_TO_FIELD)
addSortedIndex(notes, CONVERSATION_FIELD)
addSortedIndex(notes, TO_FIELD)

const activities = db.createObjectStore(ACTIVITIES_STORE, {
keyPath: 'id',
autoIncrement: false
})
activities.createIndex(ACTOR_FIELD, ACTOR_FIELD)
addSortedIndex(activities, ACTOR_FIELD)
addSortedIndex(activities, TO_FIELD)
addRegularIndex(activities, PUBLISHED_FIELD)
async function migrateNotes (db, transaction) {
const store = transaction.objectStore(NOTES_STORE)

for await (const cursor of store) {
const note = cursor.value
if (!note.timeline) {
note.timeline = ['all']
}
const isFollowing = await db.isActorFollowed(note.attributedTo)
if (isFollowing && !note.timeline.includes(TIMELINE_FOLLOWING)) {
note.timeline.push(TIMELINE_FOLLOWING)
}
cursor.update(note)
}
}

async function upgrade (db, oldVersion, newVersion, transaction) {
if (oldVersion < 1) {
const actors = db.createObjectStore(ACTORS_STORE, {
keyPath: 'id',
autoIncrement: false
})

actors.createIndex(CREATED_FIELD, CREATED_FIELD)
actors.createIndex(UPDATED_FIELD, UPDATED_FIELD)
actors.createIndex(URL_FIELD, URL_FIELD)

db.createObjectStore(FOLLOWED_ACTORS_STORE, {
keyPath: 'url'
})

const notes = db.createObjectStore(NOTES_STORE, {
keyPath: 'id',
autoIncrement: false
})
notes.createIndex(ATTRIBUTED_TO_FIELD, ATTRIBUTED_TO_FIELD, { unique: false })
notes.createIndex(IN_REPLY_TO_FIELD, IN_REPLY_TO_FIELD, { unique: false })
notes.createIndex(PUBLISHED_FIELD, PUBLISHED_FIELD, { unique: false })
addRegularIndex(notes, TO_FIELD)
addRegularIndex(notes, URL_FIELD)
addRegularIndex(notes, TAG_NAMES_FIELD, { multiEntry: true })
addSortedIndex(notes, IN_REPLY_TO_FIELD)
addSortedIndex(notes, ATTRIBUTED_TO_FIELD)
addSortedIndex(notes, CONVERSATION_FIELD)
addSortedIndex(notes, TO_FIELD)

const activities = db.createObjectStore(ACTIVITIES_STORE, {
keyPath: 'id',
autoIncrement: false
})
activities.createIndex(ACTOR_FIELD, ACTOR_FIELD)
addSortedIndex(activities, ACTOR_FIELD)
addSortedIndex(activities, TO_FIELD)
addRegularIndex(activities, PUBLISHED_FIELD)

db.createObjectStore('settings', { keyPath: 'key' })
}

if (oldVersion < 2) {
await migrateNotes(db, transaction)
}

if (oldVersion < 3) {
const notes = transaction.objectStore(NOTES_STORE)
notes.createIndex('timeline, published', ['timeline', PUBLISHED_FIELD], { unique: false })
}

function addRegularIndex (store, field, options = {}) {
store.createIndex(field, field, options)
}
function addSortedIndex (store, field, options = {}) {
store.createIndex(field + ', published', [field, PUBLISHED_FIELD], options)
}

db.createObjectStore('settings', { keyPath: 'key' })
}

// TODO: prefer p2p alternate links when possible
Expand Down
5 changes: 3 additions & 2 deletions example/post.html
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
<!DOCTYPE html>
<title>Reader Post</title>
<style>
@import url("../post.css");
@import url("../common.css");
@import url("../post.css");
@import url("../post-replies.css");
</style>
<div id="post-container">
<!-- https://staticpub.mauve.moe/helloworld.html -->
Expand All @@ -15,7 +16,7 @@
<script>
const params = new URLSearchParams(window.location.search);
// Fallback to a default URL for testing if the parameter is not available
const defaultPostUrl = "ipns://hypha.coop/dripline/announcing-dp-social-inbox.ipns.jsonld";
const defaultPostUrl = "https://mastodon.mauve.moe/users/mauve/statuses/112690528242160776/";
const postUrl = params.get("url") || defaultPostUrl;

if (postUrl) {
Expand Down
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
@import url("./timeline.css");
@import url("./outbox.css");
@import url("./post.css");
@import url("./post-replies.css");
</style>
<div class="container">
<sidebar-nav></sidebar-nav>
Expand Down
11 changes: 11 additions & 0 deletions post-replies.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
.reply-count {
text-align: right;
align-self: flex-end;
}

.reply-count-link{
color: var(--rdp-details-color);
}
.reply-count-link:hover{
text-decoration: none;
}
Loading

0 comments on commit 59eb3b7

Please sign in to comment.