├── .gitignore ├── LICENSE ├── README.md ├── avatar.jpg ├── index.js ├── lib ├── feed.js ├── notifications.js ├── social.js └── util.js ├── package-lock.json ├── package.json ├── schemas ├── post.json ├── profile.json └── vote.json └── test └── api.js /.gitignore: -------------------------------------------------------------------------------- 1 | .dat 2 | assets/*.css 3 | assets/*.js 4 | node_modules 5 | package-lock.json 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Blue Link Labs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | **Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)* 4 | 5 | - [libfritter](#libfritter) 6 | - [Usage](#usage) 7 | - [Getting started](#getting-started) 8 | - [Profiles](#profiles) 9 | - [Social](#social) 10 | - [Feed](#feed) 11 | - [Like / Unlike](#like--unlike) 12 | - [Notifications](#notifications) 13 | - [API](#api) 14 | - [new LibFritter([opts])](#new-libfritteropts) 15 | - [fritter.db](#fritterdb) 16 | - [fritter.setUser(archive)](#frittersetuserarchive) 17 | - [fritter.prepareArchive(archive)](#fritterpreparearchivearchive) 18 | - [fritter.social.getProfile(archive)](#frittersocialgetprofilearchive) 19 | - [fritter.social.setProfile(archive, profile)](#frittersocialsetprofilearchive-profile) 20 | - [fritter.social.setAvatar(archive, imgDataBuffer, extension)](#frittersocialsetavatararchive-imgdatabuffer-extension) 21 | - [fritter.social.follow(archive, targetUser[, targetUserName])](#frittersocialfollowarchive-targetuser-targetusername) 22 | - [fritter.social.unfollow(archive, targetUser)](#frittersocialunfollowarchive-targetuser) 23 | - [fritter.social.listFollowers(archive)](#frittersociallistfollowersarchive) 24 | - [fritter.social.countFollowers(archive)](#frittersocialcountfollowersarchive) 25 | - [fritter.social.listFriends(archive)](#frittersociallistfriendsarchive) 26 | - [fritter.social.countFriends(archive)](#frittersocialcountfriendsarchive) 27 | - [fritter.social.isFollowing(archiveA, archiveB)](#frittersocialisfollowingarchivea-archiveb) 28 | - [fritter.social.isFriendsWith(archiveA, archiveB)](#frittersocialisfriendswitharchivea-archiveb) 29 | - [fritter.feed.post(archive, post)](#fritterfeedpostarchive-post) 30 | - [fritter.feed.listPosts([opts])](#fritterfeedlistpostsopts) 31 | - [fritter.feed.countPosts([opts])](#fritterfeedcountpostsopts) 32 | - [fritter.feed.getThread(url[, opts])](#fritterfeedgetthreadurl-opts) 33 | - [fritter.feed.vote(archive, data)](#fritterfeedvotearchive-data) 34 | - [fritter.feed.listVotesFor(subject)](#fritterfeedlistvotesforsubject) 35 | - [fritter.notifications.listNotifications([opts])](#fritternotificationslistnotificationsopts) 36 | - [fritter.notifications.countNotifications([opts])](#fritternotificationscountnotificationsopts) 37 | 38 | 39 | 40 | # libfritter 41 | 42 | Data definitions and methods for Fritter, a Twitter clone built on top of [Dat](https://github.com/datproject/dat). Uses [WebDB](https://github.com/beakerbrowser/webdb) to read and write records on the Dat network. 43 | 44 | See the [Fritter app](https://github.com/beakerbrowser/fritter) to see this library in use. 45 | 46 | ```js 47 | const LibFritter = require('@beaker/libfritter') 48 | const fritter = new LibFritter() 49 | await fritter.db.open() 50 | await fritter.db.indexArchive('dat://bob.com') 51 | await fritter.social.getProfile('dat://bob.com') // => ... 52 | ``` 53 | 54 | Schemas: 55 | 56 | - [Profile](./schemas/profile.json). The schema for user profiles. A very simple "social media" profile: name, bio, profile pic, and a list of followed users. 57 | - [Post](./schemas/post.json). The schema for feed posts. Like in Twitter, posts are microblog posts, and can be in reply to other Fritter posts. 58 | - [Vote](./schemas/vote.json). The schema for votes. In Fritter, only upvotes are used. 59 | 60 | ## Usage 61 | 62 | ### Getting started 63 | 64 | LibFritter provides a set of methods to be used on top of a [WebDB](https://github.com/beakerbrowser/webdb) instance. 65 | 66 | Setup will always include the following steps: 67 | 68 | ```js 69 | // create the libfritter instance 70 | const fritter = new LibFritter() 71 | // open the webdb 72 | await fritter.db.open() 73 | ``` 74 | 75 | WebDB maintains an index which will determine who shows up in the feed, and whether any read method works for a given archive. 76 | (For instance, you can't call `getProfile()` on an archive that hasn't been indexed.) 77 | You can manage the index's membership using WebDB's methods: 78 | 79 | ```js 80 | // add a user 81 | await fritter.db.indexArchive('dat://bob.com') 82 | // remove a user 83 | await fritter.db.unindexArchive('dat://bob.com') 84 | ``` 85 | 86 | You can also add individual files to the index, which is helpful when the user navigates to a thread: 87 | 88 | ```js 89 | // add an individual file 90 | await fritter.db.indexFile('dat://bob.com/posts/1.json') 91 | ``` 92 | 93 | When you create a dat archive for the local user, you'll want to call `prepareArchive()` to setup the folder structure: 94 | 95 | ```js 96 | var alice = DatArchive.create({title: 'Alice', description: 'My Fritter profile'}) 97 | await fritter.prepareArchive(alice) 98 | ``` 99 | 100 | - [new LibFritter([opts])](#new-libfritteropts) 101 | - [fritter.db](#fritterdb) 102 | - [fritter.setUser(archive)](#frittersetuserarchive) 103 | - [fritter.prepareArchive(archive)](#fritterpreparearchivearchive) 104 | 105 | ### Profiles 106 | 107 | User profiles include a `name`, `bio`, and an avatar image. 108 | 109 | ```js 110 | await fritter.social.setProfile(alice, { 111 | name: 'Alice', 112 | bio: 'A cool hacker' 113 | }) 114 | 115 | await fritter.social.setAvatar(alice, 'iVBORw...rkJggg==', 'png') 116 | 117 | await fritter.social.getProfile(alice) /* => { 118 | name: 'Alice', 119 | bio: 'A cool hacker', 120 | avatar: '/avatar.png' 121 | } */ 122 | ``` 123 | 124 | - [fritter.social.getProfile(archive)](#frittersocialgetprofilearchive) 125 | - [fritter.social.setProfile(archive, profile)](#frittersocialsetprofilearchive-profile) 126 | - [fritter.social.setAvatar(archive, imgDataBuffer, extension)](#frittersocialsetavatararchive-imgdatabuffer-extension) 127 | 128 | ### Social 129 | 130 | Every user maintains a public list of other users they follow. 131 | You can modify and examine the social graph using these methods. 132 | 133 | ```js 134 | await fritter.social.follow(alice, bob) 135 | await fritter.social.follow(alice, 'dat://bob.com') // (urls work too) 136 | await fritter.social.listFollowers(bob) // => [{name: 'Alice', bio: 'A cool hacker', ...}] 137 | ``` 138 | 139 | - [fritter.social.follow(archive, targetUser[, targetUserName])](#frittersocialfollowarchive-targetuser-targetusername) 140 | - [fritter.social.unfollow(archive, targetUser)](#frittersocialunfollowarchive-targetuser) 141 | - [fritter.social.listFollowers(archive)](#frittersociallistfollowersarchive) 142 | - [fritter.social.countFollowers(archive)](#frittersocialcountfollowersarchive) 143 | - [fritter.social.listFriends(archive)](#frittersociallistfriendsarchive) 144 | - [fritter.social.countFriends(archive)](#frittersocialcountfriendsarchive) 145 | - [fritter.social.isFollowing(archiveA, archiveB)](#frittersocialisfollowingarchivea-archiveb) 146 | - [fritter.social.isFriendsWith(archiveA, archiveB)](#frittersocialisfriendswitharchivea-archiveb) 147 | 148 | ### Feed 149 | 150 | The feed contains simple text-based posts. 151 | 152 | ```js 153 | // posting a new thread 154 | await fritter.feed.post(alice, { 155 | text: 'Hello, world!', 156 | }) 157 | 158 | // posting a reply 159 | await fritter.feed.post(alice, { 160 | text: 'Hello, world!', 161 | threadParent: parent.getRecordURL(), // url of message replying to 162 | threadRoot: root.getRecordURL(), // url of topmost ancestor message 163 | createdAt: Date.parse('04 Dec 2017 00:12:00 GMT') // optional 164 | }) 165 | ``` 166 | 167 | The list method will show any indexed posts. 168 | 169 | ```js 170 | await fritter.feed.listPosts({ 171 | fetchAuthor: true, 172 | countVotes: true, 173 | reverse: true, 174 | rootPostsOnly: true, 175 | countReplies: true 176 | }) 177 | ``` 178 | 179 | You can view the posts of an individual user by adding the `author` filter, and also narrow down the feed to only include the followed users using the `authors` filter. 180 | 181 | - [fritter.feed.post(archive, post)](#fritterfeedpostarchive-post) 182 | - [fritter.feed.listPosts([opts])](#fritterfeedlistpostsopts) 183 | - [fritter.feed.countPosts([opts])](#fritterfeedcountpostsopts) 184 | - [fritter.feed.getThread(url[, opts])](#fritterfeedgetthreadurl-opts) 185 | 186 | ### Like / Unlike 187 | 188 | Users can like posts using the votes. 189 | 190 | ```js 191 | await fritter.feed.vote(alice, {vote: 1, subject: 'dat://bob.com/posts/1.json'}) 192 | await fritter.feed.listVotesFor('dat://bob.com/posts/1.json') /* => { 193 | up: 1, 194 | down: 0, 195 | value: 1, 196 | upVoters: ['dat://alice.com'] 197 | } 198 | ``` 199 | 200 | - [fritter.feed.vote(archive, data)](#fritterfeedvotearchive-data) 201 | - [fritter.feed.listVotesFor(subject)](#fritterfeedlistvotesforsubject) 202 | 203 | ### Notifications 204 | 205 | You can view recent notifications (mentions, likes and replies on your posts) using the notifications api. 206 | 207 | ```js 208 | await fritter.notifications.listNotifications() /* => [ 209 | { type: 'mention', 210 | url: 'dat://bob.com/posts/0jc7w0d5cd.json', 211 | createdAt: 15155171572345 }, 212 | { type: 'reply', 213 | url: 'dat://alice.com/posts/0jc7w07be.json', 214 | createdAt: 1515517526289 }, 215 | { type: 'vote', 216 | vote: 1, 217 | subject: 'dat://alice.com/posts/0jc7w079o.json', 218 | origin: 'dat://bob.com', 219 | createdAt: 1515517526308 } 220 | ]*/ 221 | ``` 222 | 223 | - [fritter.notifications.listNotifications(opts)](#fritternotificationslistnotificationsopts) 224 | - [fritter.notifications.countNotifications([opts])](#fritternotificationscountnotificationsopts) 225 | 226 | ## API 227 | 228 | ### new LibFritter([opts]) 229 | 230 | ```js 231 | const fritter = new LibFritter() 232 | ``` 233 | 234 | - `opts` Object. 235 | - `mainIndex` String. The name (in the browser) or path (in node) of the main indexes. Defaults to `'fritter'`. 236 | - `DatArchive` Constructor. The class constructor for dat archive instances. If in node, you should specify [node-dat-archive](https://npm.im/node-dat-archive). 237 | 238 | Create a new `LibFritter` instance. 239 | The `mainIndex` will control where the indexes are stored. 240 | You can specify different names to run multiple LibFritter instances at once. 241 | 242 | ### fritter.db 243 | 244 | The [WebDB](https://github.com/beakerbrowser/webdb) instance. 245 | 246 | ### fritter.setUser(archive) 247 | 248 | ```js 249 | fritter.setUser(alice) 250 | ``` 251 | 252 | - `archive` DatArchive. The archive which represents the local user. 253 | 254 | Sets the local user. Used in notifications to know which posts should be indexed. 255 | 256 | ### fritter.prepareArchive(archive) 257 | 258 | ```js 259 | await fritter.prepareArchive(alice) 260 | ``` 261 | 262 | - `archive` DatArchive. The archive to prepare for use in fritter. 263 | 264 | Create needed folders for writing to an archive. 265 | This should be called on any archive that represents the local user. 266 | 267 | ### fritter.social.getProfile(archive) 268 | 269 | ```js 270 | await fritter.social.getProfile(alice) // => {name: 'Alice', bio: 'A cool hacker', avatar: '/avatar.png'} 271 | ``` 272 | 273 | - `archive` DatArchive or String. The archive to read. 274 | 275 | Get the profile data of the given archive. 276 | 277 | ### fritter.social.setProfile(archive, profile) 278 | 279 | ```js 280 | await fritter.social.setProfile(alice, {name: 'Alice', bio: 'A cool hacker'}) 281 | ``` 282 | 283 | - `archive` DatArchive or String. The archive to modify. 284 | - `profile` Object. 285 | - `name` String. 286 | - `bio` String. 287 | 288 | Set the profile data of the given archive. 289 | 290 | ### fritter.social.setAvatar(archive, imgDataBuffer, extension) 291 | 292 | ```js 293 | await fritter.social.setAvatar(alice, myPngData, 'png') 294 | ``` 295 | 296 | - `archive` DatArchive or String. The archive to modify. 297 | - `imgDataBuffer` String, ArrayBuffer, or Buffer. The image data to store. If a string, must be base64-encoded. 298 | - `extensions` String. The file-extension of the avatar. 299 | 300 | Set the avatar image of the given archive. 301 | 302 | ### fritter.social.follow(archive, targetUser[, targetUserName]) 303 | 304 | ```js 305 | await fritter.social.follow(alice, bob, 'Bob') 306 | ``` 307 | 308 | - `archive` DatArchive or String. The archive to modify. 309 | - `targetUser` DatArchive or String. The archive to follow. 310 | - `targetUserName` String. The name of the archive being followed. 311 | 312 | Add to the follow-list of the given archive. 313 | 314 | ### fritter.social.unfollow(archive, targetUser) 315 | 316 | ```js 317 | await fritter.social.unfollow(alice, bob) 318 | ``` 319 | 320 | - `archive` DatArchive or String. The archive to modify. 321 | - `targetUser` DatArchive or String. The archive to unfollow. 322 | 323 | Remove from the follow-list of the given archive. 324 | 325 | ### fritter.social.listFollowers(archive) 326 | 327 | ```js 328 | await fritter.social.listFollowers(alice) 329 | ``` 330 | 331 | - `archive` DatArchive or String. The archive to find followers of. 332 | 333 | List users in db that follow the given archive. 334 | 335 | ### fritter.social.countFollowers(archive) 336 | 337 | ```js 338 | await fritter.social.countFollowers(alice) 339 | ``` 340 | 341 | - `archive` DatArchive or String. The archive to find followers of. 342 | 343 | Count users in db that follow the given archive. 344 | 345 | ### fritter.social.listFriends(archive) 346 | 347 | ```js 348 | await fritter.social.listFriends(alice) 349 | ``` 350 | 351 | - `archive` DatArchive or String. The archive to find friends of. 352 | 353 | List users in db that mutually follow the given archive. 354 | 355 | ### fritter.social.countFriends(archive) 356 | 357 | ```js 358 | await fritter.social.countFriends(alice) 359 | ``` 360 | 361 | - `archive` DatArchive or String. The archive to find friends of. 362 | 363 | Count users in db that mutually follow the given archive. 364 | 365 | 366 | ### fritter.social.isFollowing(archiveA, archiveB) 367 | 368 | ```js 369 | await fritter.social.isFollowing(alice, bob) // => true 370 | ``` 371 | 372 | - `archiveA` DatArchive or String. The archive to test. 373 | - `archiveB` DatArchive or String. The follow target. 374 | 375 | Test if `archiveA` is following `archiveB`. 376 | 377 | ### fritter.social.isFriendsWith(archiveA, archiveB) 378 | 379 | ```js 380 | await fritter.social.isFriendsWith(alice, bob) // => true 381 | ``` 382 | - `archiveA` DatArchive or String. 383 | - `archiveB` DatArchive or String. 384 | 385 | Test if `archiveA` and `archiveB` are mutually following each other. 386 | 387 | ### fritter.feed.post(archive, post) 388 | 389 | ```js 390 | // posting a new thread 391 | await fritter.feed.post(alice, { 392 | text: 'Hello, world!', 393 | }) 394 | 395 | // posting a reply 396 | await fritter.feed.post(alice, { 397 | text: 'Hello, world!', 398 | threadParent: parent.getRecordURL(), // url of message replying to 399 | threadRoot: root.getRecordURL() // url of topmost ancestor message 400 | }) 401 | ``` 402 | 403 | - `archive` DatArchive or String. The archive to modify. 404 | - `post` Object. 405 | - `text` String. The content of the post. 406 | - `threadParent` String. The URL of the parent post in the thread. Only needed in a reply; must also include `threadRoot`. 407 | - `threadRoot` String. The URL of the root post in the thread. Only needed in a reply; must also include `threadParent`. 408 | - `mentions` Array<{url: String, name: String}. An array of users mentioned in the posts, who should be pinged. 409 | 410 | Post a new message to the feed. 411 | 412 | ### fritter.feed.listPosts([opts]) 413 | 414 | ```js 415 | await fritter.feed.listPosts({limit: 30}) 416 | ``` 417 | 418 | - `opts` Object. 419 | - `author` String | DatArchive. Single-author filter. 420 | - `authors` Array. Multi-author filter. 421 | - `rootPostsOnly` Boolean. Remove posts in the feed that are replies 422 | - `after` Number. Filter out posts created before the given timestamp. 423 | - `before` Number. Filter out posts created after the given timestamp. 424 | - `limit` Number. Add a limit to the number of results given. 425 | - `offset` Number. Add an offset to the results given. Useful in pagination. 426 | - `reverse` Boolean. Reverse the order of the output. 427 | - `fetchAuthor` Boolean. Populate the `.author` attribute of the result objects with the author's profile record. 428 | - `countReplies` Boolean. Populate the `.replies` attribute of the result objects with number of replies to the post. 429 | - `countVotes` Boolean. Populate the `.votes` attribute of the result objects with the results of `countVotesFor`. 430 | 431 | Fetch a list of posts in the feed index. 432 | 433 | ### fritter.feed.countPosts([opts]) 434 | 435 | ```js 436 | await fritter.feed.countPosts({author: alice}) 437 | ``` 438 | 439 | - `opts` Object. 440 | - `author` String | DatArchive. Single-author filter. 441 | - `authors` Array. Multi-author filter. 442 | - `rootPostsOnly` Boolean. Remove posts in the feed that are replies 443 | - `after` Number. Filter out posts created before the given timestamp. 444 | - `before` Number. Filter out posts created after the given timestamp. 445 | - `limit` Number. Add a limit to the number of results given. 446 | - `offset` Number. Add an offset to the results given. Useful in pagination. 447 | 448 | Count posts in the feed index. 449 | 450 | ### fritter.feed.getThread(url[, opts]) 451 | 452 | ```js 453 | await fritter.feed.getThread('dat://alice.com/posts/1.json') 454 | ``` 455 | 456 | - `url` String. The URL of the thread. 457 | - `opts` Object. 458 | - `authors` Array. Filter the posts in the thread down to those published by the given list of archive urls. 459 | 460 | Fetch a discussion thread, including all replies. 461 | 462 | ### fritter.feed.vote(archive, data) 463 | 464 | ```js 465 | await fritter.feed.vote(alice, { 466 | vote: 1, 467 | subject: 'dat://bob.com/posts/1.json' 468 | }) 469 | ``` 470 | 471 | - `archive` DatArchive or String. The archive to modify. 472 | - `data` Object. 473 | - `vote` Number. The vote value. Must be -1 (dislike), 0 (no opinion), or 1 (like). 474 | - `subject` String. The url of the item being voted on. 475 | 476 | Publish a vote on the given subject. 477 | 478 | ### fritter.feed.listVotesFor(subject) 479 | 480 | ```js 481 | await fritter.feed.listVotesFor('dat://bob.com/posts/1.json') 482 | ``` 483 | 484 | - `subject` String. The url of the item. 485 | 486 | Returns a vote tabulation of the given subject. 487 | 488 | ### fritter.notifications.listNotifications([opts]) 489 | 490 | ```js 491 | await fritter.notifications.listNotifications({limit: 30}) 492 | ``` 493 | 494 | - `opts` Object. 495 | - `after` Number. Filter out notifications created before the given timestamp. 496 | - `before` Number. Filter out notifications created after the given timestamp. 497 | - `limit` Number. Add a limit to the number of results given. 498 | - `offset` Number. Add an offset to the results given. Useful in pagination. 499 | - `reverse` Boolean. Reverse the order of the output. 500 | - `fetchAuthor` Boolean. Populate the `.author` attribute of the result objects with the author's profile record. 501 | - `fetchPost` Boolean. Populate the `.post` attribute of the result objects with the post that's the subject of the notification. 502 | 503 | Fetch a list of events in the notifications index. 504 | 505 | ### fritter.notifications.countNotifications([opts]) 506 | 507 | ```js 508 | await fritter.notifications.countNotifications() 509 | ``` 510 | 511 | - `opts` Object. 512 | - `after` Number. Filter out notifications created before the given timestamp. 513 | - `before` Number. Filter out notifications created after the given timestamp. 514 | - `limit` Number. Add a limit to the number of results given. 515 | - `offset` Number. Add an offset to the results given. Useful in pagination. 516 | 517 | Fetch a count of events in the notifications index. -------------------------------------------------------------------------------- /avatar.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beakerbrowser/libfritter/e52f3d4004b25ed743b4677bf5d1f2e37654c8d6/avatar.jpg -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const WebDB = require('@beaker/webdb') 2 | const assert = require('assert') 3 | const LibFritterSocialAPI = require('./lib/social') 4 | const LibFritterFeedAPI = require('./lib/feed') 5 | const LibFritterNotificationsAPI = require('./lib/notifications') 6 | const {normalizeUrl, toArchiveOrigin} = require('./lib/util') 7 | 8 | // exported API 9 | // = 10 | 11 | class LibFritter { 12 | constructor (opts = {}) { 13 | this.db = new WebDB(opts.mainIndex || 'fritter', { 14 | DatArchive: opts.DatArchive 15 | }) 16 | defineTables(this.db) 17 | setHooks(this) 18 | this.userUrl = '' 19 | this.social = new LibFritterSocialAPI(this) 20 | this.feed = new LibFritterFeedAPI(this) 21 | this.notifications = new LibFritterNotificationsAPI(this) 22 | } 23 | 24 | setUser (user) { 25 | this.userUrl = toArchiveOrigin(user) 26 | } 27 | 28 | async prepareArchive (archive) { 29 | async function mkdir (path) { 30 | try { await archive.mkdir(path) } catch (e) {} 31 | } 32 | await mkdir('posts') 33 | await mkdir('votes') 34 | } 35 | } 36 | 37 | module.exports = LibFritter 38 | 39 | // internal methods 40 | // = 41 | 42 | 43 | function defineTables (db) { 44 | db.define('profiles', { 45 | filePattern: '/profile.json', 46 | index: ['*followUrls'], 47 | validate (record) { 48 | if (record.name) assert(typeof record.name === 'string', 'The .name attribute must be a string') 49 | if (record.bio) assert(typeof record.bio === 'string', 'The .bio attribute must be a string') 50 | if (record.avatar) assert(typeof record.avatar === 'string', 'The .avatar attribute must be a string') 51 | if (record.follows) { 52 | assert(Array.isArray(record.follows), 'The .follows attribute must be an array') 53 | for (let i = 0; i < record.follows.length; i++) { 54 | assert(record.follows[i] && typeof record.follows[i] === 'object', 'Every value in the .follows array must be an object') 55 | assert(typeof record.follows[i].url === 'string', 'Every object in the .follows array must include a .url string') 56 | if (record.follows[i].name) assert(typeof record.follows[i].name === 'string', 'Every .name in the .follows objects must be a string') 57 | } 58 | } 59 | return true 60 | }, 61 | preprocess (record) { 62 | record.follows = record.follows || [] 63 | record.followUrls = record.follows.map(f => toArchiveOrigin(f.url)) 64 | }, 65 | serialize (record) { 66 | return { 67 | name: record.name, 68 | bio: record.bio, 69 | avatar: record.avatar, 70 | follows: record.follows 71 | } 72 | } 73 | }) 74 | 75 | db.define('posts', { 76 | filePattern: '/posts/*.json', 77 | index: ['createdAt', ':origin+createdAt', 'threadRoot'], 78 | validate (record) { 79 | assert(typeof record.text === 'string', 'The .text attribute is required and must be a string') 80 | assert(typeof record.createdAt === 'number', 'The .createdAt attribute is required and must be a number') 81 | if (record.threadRoot) assert(typeof record.threadRoot === 'string', 'The .threadRoot attribute must be a string') 82 | if (record.threadParent) assert(typeof record.threadParent === 'string', 'The .threadRoot attribute must be a string') 83 | return true 84 | } 85 | }) 86 | 87 | db.define('votes', { 88 | filePattern: '/votes/*.json', 89 | index: ['subject'], 90 | validate (record) { 91 | assert(typeof record.subject === 'string', 'The .subject attribute is required and must be a string') 92 | assert(record.vote === -1 || record.vote === 0 || record.vote === 1, 'The .vote attribute is required and must be a number of the value -1, 0, or 1') 93 | if (record.createdAt) assert(typeof record.createdAt === 'number', 'The .createdAt attribute must be a number') 94 | return true 95 | }, 96 | preprocess (record) { 97 | record.subject = normalizeUrl(record.subject) 98 | } 99 | }) 100 | 101 | db.define('notifications', { 102 | helperTable: true, 103 | index: ['createdAt'], 104 | preprocess (record) { 105 | record.createdAt = record.createdAt || Date.now() 106 | } 107 | }) 108 | } 109 | 110 | function setHooks (inst) { 111 | const db = inst.db 112 | const consoleDebug = console.debug || console.log 113 | db.on('open-failed', err => console.error('Database failed to open.', err)) 114 | db.on('indexes-reset', () => consoleDebug('Rebuilding indexes.')) 115 | 116 | function isAReplyToUser (record) { 117 | if (record.threadRoot && record.threadRoot.startsWith(inst.userUrl)) return true 118 | if (record.threadParent && record.threadParent.startsWith(inst.userUrl)) return true 119 | return false 120 | } 121 | 122 | function isALikeOnUserPost (record) { 123 | return record.subject.startsWith(inst.userUrl + '/posts/') 124 | } 125 | 126 | function isAMentionOfUser (record) { 127 | return record.hasOwnProperty('mentions') && !(record.mentions.find(x => { 128 | return x.url == inst.userUrl 129 | }) == undefined) 130 | } 131 | 132 | async function isNotificationIndexed (url) { 133 | let record = await db.notifications.get(url) 134 | return !!record 135 | } 136 | 137 | db.on('open', () => { 138 | consoleDebug('Database is opened.') 139 | 140 | // reply notifications 141 | db.posts.on('put-record', async ({record, url, origin}) => { 142 | if (origin === inst.userUrl) return // dont index the user's replies 143 | if (isAReplyToUser(record) === false) return // only index replies to the user 144 | if (await isNotificationIndexed(url)) return // don't index if already indexed 145 | await db.notifications.put(url, {type: 'reply', url, createdAt: record.createdAt}) 146 | }) 147 | db.posts.on('del-record', async ({url}) => { 148 | if (await isNotificationIndexed(url)) { 149 | await db.notifications.delete(url) 150 | } 151 | }) 152 | 153 | // like notifications 154 | db.votes.on('put-record', async ({record, url, origin}) => { 155 | if (origin === inst.userUrl) return // dont index the user's votes 156 | if (isALikeOnUserPost(record) === false) return // only index votes on the user's posts 157 | if (record.vote === 1) { 158 | await db.notifications.put(url, {type: 'vote', vote: 1, subject: record.subject, origin, createdAt: record.createdAt}) 159 | } else { 160 | await db.notifications.delete(url) 161 | } 162 | }) 163 | db.votes.on('del-record', async ({url}) => { 164 | if (isNotificationIndexed(url)) { 165 | await db.notifications.delete(url) 166 | } 167 | }) 168 | 169 | // mention notifications 170 | db.posts.on('put-record', async ({record, url, origin}) => { 171 | if (origin === inst.userUrl) return // dont index the user's self-mentions 172 | if (isAMentionOfUser(record) === false) return 173 | if (await isNotificationIndexed(url)) return // don't index if already indexed 174 | await db.notifications.put(url, {type: 'mention', url, createdAt: record.createdAt}) 175 | }) 176 | }) 177 | } 178 | -------------------------------------------------------------------------------- /lib/feed.js: -------------------------------------------------------------------------------- 1 | const newID = require('monotonic-timestamp-base36') 2 | const {toUrl, urlSlug, normalizeUrl, toArchiveOrigin} = require('./util') 3 | 4 | // exported api 5 | // = 6 | 7 | class LibFritterFeedAPI { 8 | constructor (libfritter) { 9 | this.libfritter = libfritter 10 | this.db = libfritter.db 11 | } 12 | 13 | async post (archive, {text, threadRoot, threadParent, mentions, createdAt}) { 14 | const archiveUrl = toArchiveOrigin(archive) 15 | const url = `${archiveUrl}/posts/${newID()}.json` 16 | if (typeof createdAt === 'undefined') { 17 | createdAt = Date.now() 18 | } 19 | await this.db.posts.put(url, {text, threadRoot, threadParent, createdAt, mentions}) 20 | return url 21 | } 22 | 23 | async vote (archive, {vote, subject}) { 24 | const archiveUrl = toArchiveOrigin(archive) 25 | if (subject && subject.getRecordURL) subject = subject.getRecordURL() 26 | if (subject && subject.url) subject = subject.url 27 | const createdAt = Date.now() 28 | const url = `${archiveUrl}/votes/${urlSlug(subject)}.json` 29 | await this.db.votes.put(url, {vote, subject, createdAt}) 30 | return url 31 | } 32 | 33 | getPostsQuery ({author, authors, rootPostsOnly, after, before, offset, limit, reverse} = {}) { 34 | var query = this.db.posts 35 | if (author) { 36 | author = toArchiveOrigin(author) 37 | after = after || 0 38 | before = before || Infinity 39 | query = query.where(':origin+createdAt').between([author, after], [author, before]) 40 | } else if (after || before) { 41 | after = after || 0 42 | before = before || Infinity 43 | query = query.where('createdAt').between(after, before) 44 | } else { 45 | query = query.orderBy('createdAt') 46 | } 47 | if (rootPostsOnly || authors) { 48 | query = query.filter(post => { 49 | if (rootPostsOnly && post.threadParent) return false 50 | if (authors && !authors.includes(post.getRecordOrigin())) return false 51 | return true 52 | }) 53 | } 54 | if (offset) query = query.offset(offset) 55 | if (limit) query = query.limit(limit) 56 | if (reverse) query = query.reverse() 57 | return query 58 | } 59 | 60 | getRepliesQuery (threadRootUrl, {offset, limit, reverse} = {}) { 61 | var query = this.db.posts.where('threadRoot').equals(threadRootUrl) 62 | if (offset) query = query.offset(offset) 63 | if (limit) query = query.limit(limit) 64 | if (reverse) query = query.reverse() 65 | return query 66 | } 67 | 68 | async listPosts (opts = {}, query) { 69 | var promises = [] 70 | query = query || this.getPostsQuery(opts) 71 | var posts = await query.toArray() 72 | 73 | // fetch author profile 74 | if (opts.fetchAuthor) { 75 | let profiles = {} 76 | promises = promises.concat(posts.map(async b => { 77 | if (!profiles[b.getRecordOrigin()]) { 78 | profiles[b.getRecordOrigin()] = this.libfritter.social.getProfile(b.getRecordOrigin()) 79 | } 80 | b.author = await profiles[b.getRecordOrigin()] 81 | })) 82 | } 83 | 84 | // tabulate votes 85 | if (opts.countVotes) { 86 | promises = promises.concat(posts.map(async b => { 87 | b.votes = await this.countVotesFor(b.getRecordURL()) 88 | })) 89 | } 90 | 91 | // count replies 92 | if (opts.countReplies) { 93 | promises = promises.concat(posts.map(async b => { 94 | b.replies = await this.countPosts({}, this.getRepliesQuery(b.getRecordURL())) 95 | })) 96 | } 97 | 98 | await Promise.all(promises) 99 | return posts 100 | } 101 | 102 | countPosts (opts, query) { 103 | query = query || this.getPostsQuery(opts) 104 | return query.count() 105 | } 106 | 107 | async getPost (record, query={}) { 108 | const recordUrl = toUrl(record) 109 | record = await this.db.posts.get(recordUrl) 110 | if (!record) return null 111 | record.author = await this.libfritter.social.getProfile(record.getRecordOrigin()) 112 | record.votes = await this.countVotesFor(record.getRecordURL()) 113 | return record 114 | } 115 | 116 | async getThread (record, query={}) { 117 | const recordUrl = toUrl(record) 118 | record = await this.db.posts.get(recordUrl) 119 | if (!record) return null 120 | 121 | // fetch the full thread 122 | const threadPosts = await this.listPosts({fetchAuthor: true, countVotes: true}, this.getRepliesQuery(record.threadRoot || recordUrl)) 123 | 124 | // convert to map for fast lookup 125 | const threadPostsMap = {} 126 | threadPosts.forEach(post => threadPostsMap[post.getRecordURL()] = post) 127 | 128 | // add the root message to the map 129 | const rootPost = (record.threadRoot) ? await this.db.posts.get(record.threadRoot) : record 130 | if (rootPost) { 131 | rootPost.author = await this.libfritter.social.getProfile(rootPost.getRecordOrigin()) 132 | rootPost.votes = await this.countVotesFor(rootPost.getRecordURL()) 133 | threadPostsMap[rootPost.getRecordURL()] = rootPost 134 | } 135 | 136 | // make sure the authors filter includes the record and root authors 137 | if (query.authors) { 138 | if (!query.authors.includes(rootPost.getRecordOrigin())) { 139 | query.authors.push(rootPost.getRecordOrigin()) 140 | } 141 | if (!query.authors.includes(record.getRecordOrigin())) { 142 | query.authors.push(record.getRecordOrigin()) 143 | } 144 | } 145 | 146 | // convert the map's nodes into a tree structure 147 | for (let url in threadPostsMap) { 148 | let post = threadPostsMap[url] 149 | 150 | // apply filter 151 | if (query.authors && !query.authors.includes(post.getRecordOrigin())) { 152 | continue 153 | } 154 | 155 | // map 156 | if (post.threadParent && post.threadParent in threadPostsMap) { 157 | let parent = threadPostsMap[post.threadParent] 158 | post.parent = parent 159 | post.root = rootPost 160 | parent.replies = parent.replies || [] 161 | parent.replies.push(post) 162 | } 163 | } 164 | 165 | // return the target record 166 | return threadPostsMap[recordUrl] 167 | } 168 | 169 | getVotesForQuery (subject) { 170 | return this.db.votes.where('subject').equals(normalizeUrl(toUrl(subject))) 171 | } 172 | 173 | listVotesFor (subject) { 174 | return this.getVotesForQuery(subject).toArray() 175 | } 176 | 177 | async countVotesFor (subject) { 178 | var res = {up: 0, down: 0, value: 0, upVoters: []} 179 | await this.getVotesForQuery(subject).each(record => { 180 | res.value += record.vote 181 | if (record.vote === 1) { 182 | res.upVoters.push(record.getRecordOrigin()) 183 | res.up++ 184 | } 185 | if (record.vote === -1) { 186 | res.down++ 187 | } 188 | }) 189 | return res 190 | } 191 | } 192 | 193 | module.exports = LibFritterFeedAPI 194 | -------------------------------------------------------------------------------- /lib/notifications.js: -------------------------------------------------------------------------------- 1 | const {toUrl, toArchiveOrigin} = require('./util') 2 | 3 | // exported api 4 | // = 5 | 6 | class LibFritterNotificationsAPI { 7 | constructor (libfritter) { 8 | this.libfritter = libfritter 9 | this.db = libfritter.db 10 | } 11 | 12 | getNotificationsQuery ({after, before, offset, limit, reverse} = {}) { 13 | var query = this.db.notifications 14 | if (after || before) { 15 | after = after || 0 16 | before = before || Infinity 17 | query = query.where('createdAt').between(after, before) 18 | } else { 19 | query = query.orderBy('createdAt') 20 | } 21 | if (offset) query = query.offset(offset) 22 | if (limit) query = query.limit(limit) 23 | if (reverse) query = query.reverse() 24 | return query 25 | } 26 | 27 | async listNotifications (opts = {}) { 28 | var promises = [] 29 | var notifications = await this.getNotificationsQuery(opts).toArray() 30 | 31 | // fetch author profile 32 | if (opts.fetchAuthor) { 33 | let profiles = {} 34 | promises = promises.concat(notifications.map(async n => { 35 | const origin = n.origin || toArchiveOrigin(n.url) 36 | if (!profiles[origin]) { 37 | profiles[origin] = this.libfritter.social.getProfile(origin) 38 | } 39 | n.author = await profiles[origin] 40 | })) 41 | } 42 | 43 | // fetch posts 44 | if (opts.fetchPost) { 45 | promises = promises.concat(notifications.map(async n => { 46 | if (n.type === 'reply' || n.type === 'mention') { 47 | n.post = await this.libfritter.feed.getPost(n.url) 48 | } else { 49 | n.post = await this.libfritter.feed.getPost(n.subject) 50 | } 51 | })) 52 | } 53 | 54 | await Promise.all(promises) 55 | return notifications 56 | } 57 | 58 | countNotifications (opts) { 59 | return this.getNotificationsQuery(opts).count() 60 | } 61 | } 62 | 63 | module.exports = LibFritterNotificationsAPI 64 | -------------------------------------------------------------------------------- /lib/social.js: -------------------------------------------------------------------------------- 1 | const {toUrl, toArchiveOrigin} = require('./util') 2 | 3 | // exported api 4 | // = 5 | 6 | class LibFritterSocialAPI { 7 | constructor (libfritter) { 8 | this.db = libfritter.db 9 | } 10 | 11 | getProfile (archive) { 12 | var archiveUrl = toArchiveOrigin(archive) 13 | return this.db.profiles.get(archiveUrl + '/profile.json') 14 | } 15 | 16 | async setProfile (archive, profile) { 17 | var archiveUrl = toArchiveOrigin(archive) 18 | await this.db.profiles.upsert(archiveUrl + '/profile.json', profile) 19 | } 20 | 21 | async setAvatar (archive, imgData, extension) { 22 | archive = this.db._archives[toArchiveOrigin(archive)] 23 | if (!archive) throw new Error('Given archive is not indexed by WebDB') 24 | const filename = `avatar.${extension}` 25 | await archive.writeFile(filename, imgData, typeof imgData === 'string' ? 'base64' : 'binary') 26 | return this.db.profiles.upsert(archive.url + '/profile.json', {avatar: filename}) 27 | } 28 | 29 | async follow (archive, target, name) { 30 | // update the follow record 31 | var archiveUrl = toArchiveOrigin(archive) 32 | var targetUrl = toArchiveOrigin(target) 33 | var changes = await this.db.profiles.where(':origin').equals(archiveUrl).update(record => { 34 | record.follows = record.follows || [] 35 | if (!record.follows.find(f => f.url === targetUrl)) { 36 | record.follows.push({url: targetUrl, name}) 37 | } 38 | return record 39 | }) 40 | if (changes === 0) { 41 | throw new Error('Failed to follow: no profile record exists. Run setProfile() before follow().') 42 | } 43 | } 44 | 45 | async unfollow (archive, target) { 46 | // update the follow record 47 | var archiveUrl = toArchiveOrigin(archive) 48 | var targetUrl = toArchiveOrigin(target) 49 | var changes = await this.db.profiles.where(':origin').equals(archiveUrl).update(record => { 50 | record.follows = record.follows || [] 51 | record.follows = record.follows.filter(f => f.url !== targetUrl) 52 | return record 53 | }) 54 | if (changes === 0) { 55 | throw new Error('Failed to unfollow: no profile record exists. Run setProfile() before unfollow().') 56 | } 57 | } 58 | 59 | getFollowersQuery (archive) { 60 | var archiveUrl = toArchiveOrigin(archive) 61 | return this.db.profiles.where('followUrls').equals(archiveUrl) 62 | } 63 | 64 | listFollowers (archive) { 65 | return this.getFollowersQuery(archive).toArray() 66 | } 67 | 68 | countFollowers (archive) { 69 | return this.getFollowersQuery(archive).count() 70 | } 71 | 72 | async isFollowing (archiveA, archiveB) { 73 | var archiveAUrl = toArchiveOrigin(archiveA) 74 | var archiveBUrl = toArchiveOrigin(archiveB) 75 | var profileA = await this.db.profiles.get(archiveAUrl + '/profile.json') 76 | return profileA.followUrls.indexOf(archiveBUrl) !== -1 77 | } 78 | 79 | async listFriends (archive) { 80 | var followers = await this.listFollowers(archive) 81 | await Promise.all(followers.map(async follower => { 82 | follower.isFriend = await this.isFollowing(archive, follower.getRecordOrigin()) 83 | })) 84 | return followers.filter(f => f.isFriend) 85 | } 86 | 87 | async countFriends (archive) { 88 | var friends = await this.listFriends(archive) 89 | return friends.length 90 | } 91 | 92 | async isFriendsWith (archiveA, archiveB) { 93 | var [a, b] = await Promise.all([ 94 | this.isFollowing(archiveA, archiveB), 95 | this.isFollowing(archiveB, archiveA) 96 | ]) 97 | return a && b 98 | } 99 | } 100 | 101 | module.exports = LibFritterSocialAPI -------------------------------------------------------------------------------- /lib/util.js: -------------------------------------------------------------------------------- 1 | const slugifyUrl = require('slugify-url') 2 | const URL = (typeof window === 'undefined') ? require('url-parse') : window.URL 3 | exports.URL = URL 4 | 5 | exports.toUrl = function (v) { 6 | if (v) { 7 | if (typeof v === 'string') { 8 | return v 9 | } 10 | if (typeof v.getRecordURL === 'function') { 11 | return v.getRecordURL() 12 | } 13 | if (typeof v.url === 'string') { 14 | return v.url 15 | } 16 | } 17 | } 18 | 19 | exports.toArchiveOrigin = function (v) { 20 | if (v) { 21 | if (typeof v.getRecordOrigin === 'function') { 22 | return v.getRecordOrigin() 23 | } 24 | if (typeof v.url === 'string') { 25 | v = v.url 26 | } 27 | const urlp = new URL(v) 28 | return urlp.protocol + '//' + urlp.hostname 29 | } 30 | throw new Error('Not a valid archive') 31 | } 32 | 33 | exports.urlSlug = function (v) { 34 | v = exports.toUrl(v) 35 | return slugifyUrl(v, {skipProtocol: false}) 36 | } 37 | 38 | exports.normalizeUrl = function (v) { 39 | const urlp = new URL(v) 40 | if (urlp.pathname === '/') urlp.pathname = '' 41 | return urlp.protocol + '//' + urlp.hostname + urlp.pathname + (urlp.search || '') 42 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@beaker/libfritter", 3 | "version": "2.2.6", 4 | "description": "Data definitions and methods for Fritter", 5 | "main": "index.js", 6 | "directories": { 7 | "lib": "lib", 8 | "test": "test" 9 | }, 10 | "scripts": { 11 | "test": "ava -s test/api.js" 12 | }, 13 | "keywords": [ 14 | "dat", 15 | "beaker", 16 | "fritter" 17 | ], 18 | "author": "Paul Frazee ", 19 | "license": "MIT", 20 | "dependencies": { 21 | "@beaker/webdb": "^4.3.0", 22 | "monotonic-timestamp-base36": "^1.0.0", 23 | "slugify-url": "^1.2.0" 24 | }, 25 | "devDependencies": { 26 | "ava": "^0.24.0", 27 | "node-dat-archive": "^1.6.1", 28 | "tempy": "^0.2.1" 29 | }, 30 | "repository": { 31 | "type": "git", 32 | "url": "git+https://github.com/beakerbrowser/libfritter.git" 33 | }, 34 | "bugs": { 35 | "url": "https://github.com/beakerbrowser/libfritter/issues" 36 | }, 37 | "homepage": "https://github.com/beakerbrowser/libfritter#readme" 38 | } 39 | -------------------------------------------------------------------------------- /schemas/post.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-06/schema#", 3 | "type": "object", 4 | "properties": { 5 | "text": {"type": "string"}, 6 | "threadRoot": {"type": ["null", "string"], "format": "uri"}, 7 | "threadParent": {"type": ["null", "string"], "format": "uri"}, 8 | "createdAt": {"type": "number"}, 9 | "mentions": { 10 | "type": "array", 11 | "items": { 12 | "type": "object", 13 | "properties": { 14 | "url": {"type": "string", "format": "uri"}, 15 | "name": {"type": ["null", "string"]} 16 | }, 17 | "required": ["url"] 18 | } 19 | } 20 | }, 21 | "required": ["text", "createdAt"] 22 | } -------------------------------------------------------------------------------- /schemas/profile.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-06/schema#", 3 | "type": "object", 4 | "properties": { 5 | "name": {"type": ["null", "string"]}, 6 | "bio": {"type": ["null", "string"]}, 7 | "avatar": {"type": ["null", "string"]}, 8 | "follows": { 9 | "type": "array", 10 | "items": { 11 | "type": "object", 12 | "properties": { 13 | "url": {"type": "string", "format": "uri"}, 14 | "name": {"type": ["null", "string"]} 15 | }, 16 | "required": ["url"] 17 | } 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /schemas/vote.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-06/schema#", 3 | "type": "object", 4 | "properties": { 5 | "subject": {"type": "string", "format": "uri"}, 6 | "vote": {"type": "number", "enum": [-1, 0, 1]}, 7 | "createdAt": {"type": ["number", "null"]} 8 | }, 9 | "required": ["subject", "vote"] 10 | } -------------------------------------------------------------------------------- /test/api.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const DatArchive = require('node-dat-archive') 3 | const tempy = require('tempy') 4 | const LibFritter = require('../') 5 | const fs = require('fs') 6 | 7 | var fritter 8 | 9 | var alice 10 | var bob 11 | var carla 12 | 13 | test.before('archive creation', async t => { 14 | // create the archives 15 | ;[alice, bob, carla] = await Promise.all([ 16 | DatArchive.create({title: 'Alice', localPath: tempy.directory()}), 17 | DatArchive.create({title: 'Bob', localPath: tempy.directory()}), 18 | DatArchive.create({title: 'Carla', localPath: tempy.directory()}) 19 | ]) 20 | 21 | // setup libfritter 22 | fritter = new LibFritter({mainIndex: tempy.directory(), DatArchive}) 23 | fritter.setUser(alice) 24 | await fritter.db.open() 25 | await fritter.prepareArchive(alice) 26 | await fritter.prepareArchive(bob) 27 | await fritter.prepareArchive(carla) 28 | await fritter.db.indexArchive([alice, bob, carla]) 29 | }) 30 | 31 | test.after('close db', async t => { 32 | await fritter.db.close() 33 | }) 34 | 35 | test('profile data', async t => { 36 | // write profiles 37 | await fritter.social.setProfile(alice, { 38 | name: 'Alice', 39 | bio: 'A cool hacker girl', 40 | avatar: 'alice.png', 41 | follows: [{name: 'Bob', url: bob.url}, {name: 'Carla', url: carla.url}] 42 | }) 43 | await fritter.social.setProfile(bob, { 44 | name: 'Bob', 45 | avatar: 'bob.png', 46 | bio: 'A cool hacker guy' 47 | }) 48 | const avatarBuffer = fs.readFileSync('avatar.jpg').buffer 49 | await fritter.social.setAvatar(bob, avatarBuffer, 'jpg') 50 | await fritter.social.follow(bob, alice, 'Alice') 51 | await fritter.social.setProfile(carla, { 52 | name: 'Carla' 53 | }) 54 | await fritter.social.follow(carla, alice) 55 | 56 | // verify data 57 | t.truthy(await bob.stat('/avatar.jpg')) 58 | t.deepEqual(profileSubset(await fritter.social.getProfile(alice)), { 59 | name: 'Alice', 60 | bio: 'A cool hacker girl', 61 | avatar: 'alice.png', 62 | followUrls: [bob.url, carla.url], 63 | follows: [{name: 'Bob', url: bob.url}, {name: 'Carla', url: carla.url}] 64 | }) 65 | t.deepEqual(profileSubset(await fritter.social.getProfile(bob)), { 66 | name: 'Bob', 67 | bio: 'A cool hacker guy', 68 | avatar: 'avatar.jpg', 69 | followUrls: [alice.url], 70 | follows: [{name: 'Alice', url: alice.url}] 71 | }) 72 | t.deepEqual(profileSubset(await fritter.social.getProfile(carla)), { 73 | name: 'Carla', 74 | bio: undefined, 75 | avatar: undefined, 76 | followUrls: [alice.url], 77 | follows: [{url: alice.url}] 78 | }) 79 | }) 80 | 81 | test('votes', async t => { 82 | // vote 83 | await fritter.feed.vote(alice, {subject: 'https://beakerbrowser.com', vote: 1}) 84 | await fritter.feed.vote(bob, {subject: 'https://beakerbrowser.com', vote: 1}) 85 | await fritter.feed.vote(carla, {subject: 'https://beakerbrowser.com', vote: 1}) 86 | await fritter.feed.vote(alice, {subject: 'dat://beakerbrowser.com', vote: 1}) 87 | await fritter.feed.vote(bob, {subject: 'dat://beakerbrowser.com', vote: 0}) 88 | await fritter.feed.vote(carla, {subject: 'dat://beakerbrowser.com', vote: -1}) 89 | await fritter.feed.vote(alice, {subject: 'dat://bob.com/posts/1.json', vote: -1}) 90 | await fritter.feed.vote(bob, {subject: 'dat://bob.com/posts/1.json', vote: -1}) 91 | await fritter.feed.vote(carla, {subject: 'dat://bob.com/posts/1.json', vote: -1}) 92 | 93 | // listVotesFor 94 | 95 | // simple usage 96 | t.deepEqual(voteSubsets(await fritter.feed.listVotesFor('https://beakerbrowser.com')), [ 97 | { subject: 'https://beakerbrowser.com', 98 | vote: 1, 99 | author: false }, 100 | { subject: 'https://beakerbrowser.com', 101 | vote: 1, 102 | author: false }, 103 | { subject: 'https://beakerbrowser.com', 104 | vote: 1, 105 | author: false } 106 | ]) 107 | // url is normalized 108 | t.deepEqual(voteSubsets(await fritter.feed.listVotesFor('https://beakerbrowser.com/')), [ 109 | { subject: 'https://beakerbrowser.com', 110 | vote: 1, 111 | author: false }, 112 | { subject: 'https://beakerbrowser.com', 113 | vote: 1, 114 | author: false }, 115 | { subject: 'https://beakerbrowser.com', 116 | vote: 1, 117 | author: false } 118 | ]) 119 | // simple usage 120 | t.deepEqual(voteSubsets(await fritter.feed.listVotesFor('dat://beakerbrowser.com')), [ 121 | { subject: 'dat://beakerbrowser.com', 122 | vote: 1, 123 | author: false }, 124 | { subject: 'dat://beakerbrowser.com', 125 | vote: 0, 126 | author: false }, 127 | { subject: 'dat://beakerbrowser.com', 128 | vote: -1, 129 | author: false } 130 | ]) 131 | // simple usage 132 | t.deepEqual(voteSubsets(await fritter.feed.listVotesFor('dat://bob.com/posts/1.json')), [ 133 | { subject: 'dat://bob.com/posts/1.json', 134 | vote: -1, 135 | author: false }, 136 | { subject: 'dat://bob.com/posts/1.json', 137 | vote: -1, 138 | author: false }, 139 | { subject: 'dat://bob.com/posts/1.json', 140 | vote: -1, 141 | author: false } 142 | ]) 143 | 144 | // countVotesFor 145 | 146 | // simple usage 147 | t.deepEqual(await fritter.feed.countVotesFor('https://beakerbrowser.com'), { 148 | up: 3, 149 | down: 0, 150 | value: 3, 151 | upVoters: [alice.url, bob.url, carla.url] 152 | }) 153 | // url is normalized 154 | t.deepEqual(await fritter.feed.countVotesFor('https://beakerbrowser.com/'), { 155 | up: 3, 156 | down: 0, 157 | value: 3, 158 | upVoters: [alice.url, bob.url, carla.url] 159 | }) 160 | // simple usage 161 | t.deepEqual(await fritter.feed.countVotesFor('dat://beakerbrowser.com'), { 162 | up: 1, 163 | down: 1, 164 | value: 0, 165 | upVoters: [alice.url] 166 | }) 167 | // simple usage 168 | t.deepEqual(await fritter.feed.countVotesFor('dat://bob.com/posts/1.json'), { 169 | up: 0, 170 | down: 3, 171 | value: -3, 172 | upVoters: [] 173 | }) 174 | }) 175 | 176 | test('posts', async t => { 177 | // make some posts 178 | var post1Url = await fritter.feed.post(alice, {text: 'First'}) 179 | await fritter.feed.post(bob, {text: 'Second'}) 180 | await fritter.feed.post(carla, {text: 'Third'}) 181 | await fritter.feed.post(bob, {text: '@Alice', mentions: [{ name: 'Alice', url: alice.url }]}) 182 | var reply1Url = await fritter.feed.post(bob, { 183 | text: 'First reply', 184 | threadParent: post1Url, 185 | threadRoot: post1Url 186 | }) 187 | await fritter.feed.post(carla, { 188 | text: 'Second reply', 189 | threadParent: reply1Url, 190 | threadRoot: post1Url 191 | }) 192 | await fritter.feed.post(alice, {text: 'Fourth'}) 193 | 194 | // add some votes 195 | await fritter.feed.vote(bob, {vote: 1, subject: post1Url, subjectType: 'post'}) 196 | await fritter.feed.vote(carla, {vote: 1, subject: post1Url, subjectType: 'post'}) 197 | 198 | // get a thread 199 | t.deepEqual(postSubset(await fritter.feed.getThread(post1Url)), { 200 | author: true, 201 | text: 'First', 202 | threadParent: undefined, 203 | threadRoot: undefined, 204 | votes: {up: 2, down: 0, value: 2, upVoters: [bob.url, carla.url]}, 205 | replies: [ 206 | { 207 | author: true, 208 | text: 'First reply', 209 | threadParent: post1Url, 210 | threadRoot: post1Url, 211 | votes: {up: 0, down: 0, value: 0, upVoters: []}, 212 | replies: [ 213 | { 214 | author: true, 215 | text: 'Second reply', 216 | threadParent: reply1Url, 217 | threadRoot: post1Url, 218 | votes: {up: 0, down: 0, value: 0, upVoters: []}, 219 | replies: undefined 220 | } 221 | ] 222 | } 223 | ] 224 | }) 225 | 226 | // get a thread at the middle 227 | let threadInTheMiddle = await fritter.feed.getThread(reply1Url) 228 | t.deepEqual(postSubset(threadInTheMiddle), { 229 | author: true, 230 | text: 'First reply', 231 | threadParent: post1Url, 232 | threadRoot: post1Url, 233 | votes: {up: 0, down: 0, value: 0, upVoters: []}, 234 | replies: [ 235 | { 236 | author: true, 237 | text: 'Second reply', 238 | threadParent: reply1Url, 239 | threadRoot: post1Url, 240 | votes: {up: 0, down: 0, value: 0, upVoters: []}, 241 | replies: undefined 242 | } 243 | ] 244 | }) 245 | t.deepEqual(postSubset(threadInTheMiddle.parent), { 246 | author: true, 247 | text: 'First', 248 | threadParent: undefined, 249 | threadRoot: undefined, 250 | votes: {up: 2, down: 0, value: 2, upVoters: [bob.url, carla.url]}, 251 | replies: [ 252 | { 253 | author: true, 254 | text: 'First reply', 255 | threadParent: post1Url, 256 | threadRoot: post1Url, 257 | votes: {up: 0, down: 0, value: 0, upVoters: []}, 258 | replies: [ 259 | { 260 | author: true, 261 | text: 'Second reply', 262 | threadParent: reply1Url, 263 | threadRoot: post1Url, 264 | votes: {up: 0, down: 0, value: 0, upVoters: []}, 265 | replies: undefined 266 | } 267 | ] 268 | } 269 | ] 270 | }) 271 | 272 | // list posts 273 | t.deepEqual(postSubsets(await fritter.feed.listPosts()), [ 274 | { author: false, 275 | text: 'First', 276 | threadParent: undefined, 277 | threadRoot: undefined, 278 | votes: undefined, 279 | replies: undefined }, 280 | { author: false, 281 | text: 'Second', 282 | threadParent: undefined, 283 | threadRoot: undefined, 284 | votes: undefined, 285 | replies: undefined }, 286 | { author: false, 287 | text: 'Third', 288 | threadParent: undefined, 289 | threadRoot: undefined, 290 | votes: undefined, 291 | replies: undefined }, 292 | { author: false, 293 | text: '@Alice', 294 | threadParent: undefined, 295 | threadRoot: undefined, 296 | votes: undefined, 297 | replies: undefined }, 298 | { author: false, 299 | text: 'First reply', 300 | threadParent: post1Url, 301 | threadRoot: post1Url, 302 | votes: undefined, 303 | replies: undefined }, 304 | { author: false, 305 | text: 'Second reply', 306 | threadParent: reply1Url, 307 | threadRoot: post1Url, 308 | votes: undefined, 309 | replies: undefined }, 310 | { author: false, 311 | text: 'Fourth', 312 | threadParent: undefined, 313 | threadRoot: undefined, 314 | votes: undefined, 315 | replies: undefined } 316 | ]) 317 | 318 | // list posts (no replies) 319 | t.deepEqual(postSubsets(await fritter.feed.listPosts({rootPostsOnly: true})), [ 320 | { 321 | author: false, 322 | text: 'First', 323 | threadParent: undefined, 324 | threadRoot: undefined, 325 | votes: undefined, 326 | replies: undefined 327 | }, 328 | { 329 | author: false, 330 | text: 'Second', 331 | threadParent: undefined, 332 | threadRoot: undefined, 333 | votes: undefined, 334 | replies: undefined 335 | }, 336 | { 337 | author: false, 338 | text: 'Third', 339 | threadParent: undefined, 340 | threadRoot: undefined, 341 | votes: undefined, 342 | replies: undefined 343 | }, 344 | { 345 | author: false, 346 | text: '@Alice', 347 | threadParent: undefined, 348 | threadRoot: undefined, 349 | votes: undefined, 350 | replies: undefined 351 | }, 352 | { 353 | author: false, 354 | text: 'Fourth', 355 | threadParent: undefined, 356 | threadRoot: undefined, 357 | votes: undefined, 358 | replies: undefined 359 | } 360 | ]) 361 | 362 | // list posts (authors, votes, and replies) 363 | t.deepEqual(postSubsets(await fritter.feed.listPosts({fetchAuthor: true, rootPostsOnly: true, countVotes: true, countReplies: true})), [ 364 | { 365 | author: true, 366 | text: 'First', 367 | threadParent: undefined, 368 | threadRoot: undefined, 369 | votes: {up: 2, down: 0, value: 2, upVoters: [bob.url, carla.url]}, 370 | replies: 2 371 | }, 372 | { 373 | author: true, 374 | text: 'Second', 375 | threadParent: undefined, 376 | threadRoot: undefined, 377 | votes: {up: 0, down: 0, value: 0, upVoters: []}, 378 | replies: 0 379 | }, 380 | { 381 | author: true, 382 | text: 'Third', 383 | threadParent: undefined, 384 | threadRoot: undefined, 385 | votes: {up: 0, down: 0, value: 0, upVoters: []}, 386 | replies: 0 387 | }, 388 | { 389 | author: true, 390 | text: '@Alice', 391 | threadParent: undefined, 392 | threadRoot: undefined, 393 | votes: {up: 0, down: 0, value: 0, upVoters: []}, 394 | replies: 0 395 | }, 396 | { 397 | author: true, 398 | text: 'Fourth', 399 | threadParent: undefined, 400 | threadRoot: undefined, 401 | votes: {up: 0, down: 0, value: 0, upVoters: []}, 402 | replies: 0 403 | } 404 | ]) 405 | 406 | // list posts (limit, offset, reverse) 407 | t.deepEqual(postSubsets(await fritter.feed.listPosts({rootPostsOnly: true, limit: 1, offset: 1, fetchAuthor: true, countVotes: true, countReplies: true})), [ 408 | { 409 | author: true, 410 | text: 'Second', 411 | threadParent: undefined, 412 | threadRoot: undefined, 413 | votes: {up: 0, down: 0, value: 0, upVoters: []}, 414 | replies: 0 415 | } 416 | ]) 417 | t.deepEqual(postSubsets(await fritter.feed.listPosts({rootPostsOnly: true, reverse: true, limit: 1, offset: 1, fetchAuthor: true, countVotes: true, countReplies: true})), [ 418 | { 419 | author: true, 420 | text: '@Alice', 421 | threadParent: undefined, 422 | threadRoot: undefined, 423 | votes: {up: 0, down: 0, value: 0, upVoters: []}, 424 | replies: 0 425 | } 426 | ]) 427 | }) 428 | 429 | test('notifications', async (t) => { 430 | var notifications = await fritter.notifications.listNotifications({fetchPost: true, fetchAuthor: true}) 431 | 432 | t.is(notifications.length, 5) 433 | t.is(notifications[0].type, 'mention') 434 | t.is(notifications[0].post.mentions[0].url, alice.url) 435 | t.is(notifications[0].author.getRecordOrigin(), bob.url) 436 | t.is(notifications[1].type, 'reply') 437 | t.truthy(notifications[1].url.startsWith(bob.url)) 438 | t.is(notifications[1].author.getRecordOrigin(), bob.url) 439 | t.is(notifications[1].post.author.getRecordOrigin(), bob.url) 440 | t.is(notifications[1].post.text, 'First reply') 441 | t.is(notifications[2].type, 'reply') 442 | t.truthy(notifications[2].url.startsWith(carla.url)) 443 | t.is(notifications[2].author.getRecordOrigin(), carla.url) 444 | t.is(notifications[2].post.author.getRecordOrigin(), carla.url) 445 | t.is(notifications[2].post.text, 'Second reply') 446 | t.is(notifications[3].type, 'vote') 447 | t.is(notifications[3].origin, bob.url) 448 | t.truthy(notifications[3].subject.startsWith(alice.url)) 449 | t.is(notifications[3].author.getRecordOrigin(), bob.url) 450 | t.is(notifications[4].type, 'vote') 451 | t.is(notifications[4].origin, carla.url) 452 | t.truthy(notifications[4].subject.startsWith(alice.url)) 453 | t.is(notifications[4].author.getRecordOrigin(), carla.url) 454 | 455 | var notifications = await fritter.notifications.listNotifications({offset: 1, limit: 2, reverse: true}) 456 | 457 | t.is(notifications.length, 2) 458 | t.is(notifications[1].type, 'reply') 459 | t.truthy(notifications[1].url.startsWith(carla.url)) 460 | t.is(notifications[0].type, 'vote') 461 | t.is(notifications[0].origin, bob.url) 462 | t.truthy(notifications[0].subject.startsWith(alice.url)) 463 | }) 464 | 465 | function profileSubset (p) { 466 | return { 467 | name: p.name, 468 | bio: p.bio, 469 | avatar: p.avatar, 470 | followUrls: p.followUrls, 471 | follows: p.follows 472 | } 473 | } 474 | 475 | function voteSubsets (vs) { 476 | vs = vs.map(voteSubset) 477 | vs.sort((a, b) => b.vote - a.vote) 478 | return vs 479 | } 480 | 481 | function voteSubset (v) { 482 | return { 483 | subject: v.subject, 484 | vote: v.vote, 485 | author: !!v.author 486 | } 487 | } 488 | 489 | function postSubsets (ps) { 490 | ps = ps.map(postSubset) 491 | return ps 492 | } 493 | 494 | function postSubset (p) { 495 | return { 496 | author: !!p.author, 497 | text: p.text, 498 | threadParent: p.threadParent, 499 | threadRoot: p.threadRoot, 500 | votes: p.votes, 501 | replies: Array.isArray(p.replies) ? postSubsets(p.replies) : p.replies 502 | } 503 | } 504 | --------------------------------------------------------------------------------