├── .github ├── stale.yml └── workflows │ └── node.js.yml ├── .gitignore ├── .npmrc ├── .prettierrc ├── LICENSE ├── package.json ├── readme.md ├── src ├── hashtags.ts ├── index.ts └── types.ts ├── test ├── hashtagCount.test.js ├── hashtagSummary.test.js ├── hashtagUpdates.test.js ├── hashtagsMatching.test.js ├── private.test.js ├── privateUpdates.test.js ├── profile.test.js ├── profileSummary.test.js ├── public.test.js ├── publicSummary.test.js ├── publicUpdates.test.js ├── recentHashtags.test.js ├── testbot.js ├── thread.test.js ├── threadUpdates.test.js └── wait.js └── tsconfig.json /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | # Label to use when marking an issue as stale 10 | staleLabel: stale 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false 18 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: CI 5 | 6 | on: 7 | push: 8 | branches: [master] 9 | pull_request: 10 | branches: [master] 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | timeout-minutes: 10 16 | 17 | strategy: 18 | matrix: 19 | node-version: [16.x, 18.x] 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - run: npm install 28 | - run: npm test 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | /lib 4 | .vscode 5 | .nyc_output 6 | /coverage 7 | shrinkwrap.yaml -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "bracketSpacing": true, 4 | "trailingComma": "all", 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 André Staltz(staltz.com) 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ssb-threads", 3 | "version": "10.8.0", 4 | "description": "Scuttlebot plugin for fetching messages as threads", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/ssbc/ssb-threads.git" 8 | }, 9 | "types": "types.ts", 10 | "main": "lib/index.js", 11 | "author": "staltz.com", 12 | "license": "MIT", 13 | "keywords": [ 14 | "ssb" 15 | ], 16 | "engines": { 17 | "node": ">=8" 18 | }, 19 | "files": [ 20 | "lib/*" 21 | ], 22 | "dependencies": { 23 | "bipf": "^1.9.0", 24 | "pull-cat": "^1.1.11", 25 | "pull-level": "^2.0.4", 26 | "pull-stream": "^3.6.2", 27 | "secret-stack-decorators": "1.1.0", 28 | "ssb-db2": ">=3.4.1", 29 | "ssb-ref": "^2.13.0", 30 | "ssb-sort": "1.1.x", 31 | "ssb-typescript": "^2.5.0" 32 | }, 33 | "devDependencies": { 34 | "@types/node": "^14.14.37", 35 | "c8": "^7.11.0", 36 | "pull-async": "1.0.0", 37 | "pull-notify": "^0.1.1", 38 | "pull-stream": "3.7.0", 39 | "secret-stack": "6.4.1", 40 | "ssb-caps": "^1.1.0", 41 | "ssb-db2": "^6.2.3", 42 | "ssb-friends": "^5.1.7", 43 | "ssb-keys": "8.5.0", 44 | "tap-arc": "~0.3.5", 45 | "tape": "^5.5.3", 46 | "ts-node": "^9.1.1", 47 | "typescript": "~4.7.4" 48 | }, 49 | "scripts": { 50 | "compile": "tsc", 51 | "tape": "tape test/*.test.js | tap-arc --bail", 52 | "test": "npm run compile && npm run tape", 53 | "coverage": "c8 --reporter=lcov npm run test" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # ssb-threads 2 | 3 | > A Scuttlebot plugin for fetching messages as threads 4 | 5 | ## Usage 6 | 7 | This plugin **requires ssb-db2 v3.4.0 or higher** and does not support ssb-db. If the **ssb-friends** plugin is available it will filter the messages of blocked users and provide the option of only retrieving information about threads created by users you follow directly. 8 | 9 | ```diff 10 | SecretStack({appKey: require('ssb-caps').shs}) 11 | .use(require('ssb-master')) 12 | .use(require('ssb-db2')) 13 | .use(require('ssb-conn')) 14 | .use(require('ssb-friends')) 15 | + .use(require('ssb-threads')) 16 | .use(require('ssb-blobs')) 17 | .call(null, config) 18 | ``` 19 | 20 | ```js 21 | pull( 22 | ssb.threads.public({ 23 | reverse: true, // threads sorted from most recent to least recent 24 | threadMaxSize: 3, // at most 3 messages in each thread 25 | }), 26 | pull.drain(thread => { 27 | console.log(thread); 28 | }), 29 | ); 30 | ``` 31 | 32 | ## API 33 | 34 | ### "Thread objects" 35 | 36 | Whenever an API returns a so-called "thread object", it refers to an object of shape `{ messages, full }` where `messages` is an array of SSB messages, and `full` is a boolean indicating whether `messages` array contains all of the possible messages in the thread. Any message that has its `root` field or `branch` field or `fork` field pointing to the root of the thread can be included in this array, but sometimes we may omit a message if the `threadMaxSize` has been reached, in which case the `messages.length` will be equal to the `threadMaxSize` option. 37 | 38 | In TypeScript: 39 | 40 | ```typescript 41 | type Thread = { 42 | messages: Array; 43 | full: boolean; 44 | } 45 | ``` 46 | 47 | ### "Summary objects" 48 | 49 | Whenever an API returns a so-called "thread summary object", it refers to an object of shape `{ root, replyCount }` where `root` is an SSB message for the top-most post in the thread, and `replyCount` is a number indicating how many other messages (besides the root) are in the thread. In TypeScript: 50 | 51 | ```typescript 52 | type ThreadSummary = { 53 | root: Msg; 54 | replyCount: number; 55 | } 56 | ``` 57 | 58 | ### `ssb.threads.public(opts)` 59 | 60 | Returns a pull stream that emits thread objects of public messages. 61 | 62 | * `opts.reverse`: boolean, default `true`. `false` means threads will be delivered from oldest to most recent, `true` means they will be delivered from most recent to oldest. 63 | * `opts.threadMaxSize`: optional number (default: Infinity). Dictates the maximum amount of messages in each returned thread object. Serves for previewing threads, particularly long ones. 64 | * `opts.allowlist`: optional array of strings. Dictates which messages **types** to allow as root messages, while forbidding other types. 65 | * `opts.blocklist`: optional array of strings. Dictates which messages **types** to forbid as root messages, while allowing other types. 66 | - `opts.following`: optional boolean (default: false). `true` means only threads created by those directly followed (and yourself) will be emitted. Requires `ssb-friends`. 67 | 68 | ### `ssb.threads.publicSummary(opts)` 69 | 70 | Returns a pull stream that emits summary objects of public threads. 71 | 72 | * `opts.reverse`: boolean, default `true`. `false` means threads will be delivered from oldest to most recent, `true` means they will be delivered from most recent to oldest. 73 | * `opts.allowlist`: optional array of strings. Dictates which messages **types** to allow as root messages, while forbidding other types. 74 | * `opts.blocklist`: optional array of strings. Dictates which messages **types** to forbid as root messages, while allowing other types. 75 | - `opts.following`: optional boolean (default: false). `true` means only summaries for threads created by those directly followed (and yourself) will be emitted. Requires `ssb-friends`. 76 | 77 | ### `ssb.threads.publicUpdates(opts)` 78 | 79 | Returns a ("live") pull stream that emits a message key (strings) for every new message that passes the (optional) allowlist or blocklist. 80 | 81 | * `opts.includeSelf`: optional boolean that indicates if updates from yourself (the current `ssb.id`) should be included in this stream or not. 82 | * `opts.allowlist`: optional array of strings. Dictates which messages **types** to allow as root messages, while forbidding other types. 83 | * `opts.blocklist`: optional array of strings. Dictates which messages **types** to forbid as root messages, while allowing other types. 84 | - `opts.following`: optional boolean (default: false). `true` means only message keys for threads created by those directly followed will be emitted. Requires `ssb-friends`. 85 | 86 | 87 | ### `ssb.threads.hashtagCount(opts, cb)` 88 | 89 | Gets the number of public threads that match a specific hashtag `opts.hashtag`. "Hashtag" here means `msg.value.content.channel` and `msg.value.content.mentions[].link` (beginning with `#`). 90 | 91 | * `opts.hashtag`: string, required. This is a short hashtag string such as `#animals` that identifies which content categary we are interested in. 92 | * `opts.allowlist`: optional array of strings. Dictates which messages **types** to allow as root messages, while forbidding other types. 93 | * `opts.blocklist`: optional array of strings. Dictates which messages **types** to forbid as root messages, while allowing other types. 94 | 95 | 96 | ### `ssb.threads.hashtagSummary(opts)` 97 | 98 | Similar to `publicSummary` but limits the results to public threads that match a specific hashtag `opts.hashtag`. "Hashtag" here means `msg.value.content.channel` and `msg.value.content.mentions[].link` (beginning with `#`). 99 | 100 | * `opts.hashtag`: string, required unless you have `opts.hashtags`. This is a short hashtag string such as `#animals` that identifies which content category we are interested in. 101 | * `opts.hashtags`: array of strings, optional. Like `opts.hashtag` but allows you to specify multiple hashtags such that summaries returned will match *any* of the hashtags. 102 | * `opts.reverse`: boolean, default `true`. `false` means threads will be delivered from oldest to most recent, `true` means they will be delivered from most recent to oldest. 103 | * `opts.allowlist`: optional array of strings. Dictates which messages **types** to allow as root messages, while forbidding other types. 104 | * `opts.blocklist`: optional array of strings. Dictates which messages **types** to forbid as root messages, while allowing other types. 105 | 106 | ### `ssb.threads.hashtagUpdates(opts)` 107 | 108 | Returns a ("live") pull stream that emits the message key (string) for thread roots every time there is a new reply or root tagged with the given `opts.hashtag`, and that passes the (optional) allowlist or blocklist. 109 | 110 | * `opts.hashtag`: string, required unless you have `opts.hashtags`. This is a short hashtag string such as `#animals` that identifies which content category we are interested in. 111 | * `opts.hashtags`: array of strings, optional. Like `opts.hashtag` but allows you to specify multiple hashtags such that summaries returned will match *any* of the hashtags. 112 | * `opts.allowlist`: optional array of strings. Dictates which messages **types** to allow as root messages, while forbidding other types. 113 | * `opts.blocklist`: optional array of strings. Dictates which messages **types** to forbid as root messages, while allowing other types. 114 | 115 | ### `ssb.threads.hashtagsMatching(opts, cb)` 116 | 117 | Call the callback with an array of `[hashtagLabel, count]` tuples where `hashtagLabel` begins with `opts.query`. "hashtagLabel" here means `msg.value.content.channel` and `msg.value.content.mentions[].link` (omitting the `#`). Results are ordered based on the number of occurrences for the hashtag (`count`) from highest to lowest. 118 | 119 | * `opts.query`: string, required. Prefix string used to identify the hashtags we are interested in. 120 | * `opts.limit`: number, default `10`. Limits the number of results that are returned. 121 | 122 | ### `ssb.threads.private(opts)` 123 | 124 | Returns a pull stream that emits thread objects of private conversations. 125 | 126 | * `opts.reverse`: boolean, default `true`. `false` means threads will be delivered from oldest to most recent, `true` means they will be delivered from most recent to oldest. 127 | * `opts.threadMaxSize`: optional number (default: Infinity). Dictates the maximum amount of messages in each returned thread object. Serves for previewing threads, particularly long ones. 128 | * `opts.allowlist`: optional array of strings. Dictates which messages **types** to allow as root messages, while forbidding other types. 129 | * `opts.blocklist`: optional array of strings. Dictates which messages **types** to forbid as root messages, while allowing other types. 130 | 131 | ### `ssb.threads.recentHashtags(opts, cb)` 132 | 133 | Call the callback with an array of hashtags labels of length `opts.limit` (defaults to 10 if unspecified). "hashtagLabel" here means `msg.value.content.channel` and `msg.value.content.mentions[].link` (omitting the `#`). Results are ordered with the most recent first, as determined by the log. 134 | 135 | * `opts.limit`: number, required. Limits the number of results that are returned. 136 | * `opts.preserveCase`: optional boolean, default `false`. Return the hashtag labels with their original casing. By default, all hashtag labels are converted to lowercase but there are times when it's preferable to preserve the casing as seen in the log. 137 | 138 | ### `ssb.threads.privateUpdates(opts)` 139 | 140 | Returns a ("live") pull stream that emits the message key (string) for thread roots every time there is a new reply or root, and that passes the (optional) allowlist or blocklist. 141 | 142 | * `opts.includeSelf`: optional boolean that indicates if updates from yourself (the current `ssb.id`) should be included in this stream or not. 143 | * `opts.allowlist`: optional array of strings. Dictates which messages **types** to allow as root messages, while forbidding other types. 144 | * `opts.blocklist`: optional array of strings. Dictates which messages **types** to forbid as root messages, while allowing other types. 145 | 146 | ### `ssb.threads.profile(opts)` 147 | 148 | Returns a pull stream that emits thread objects of public messages initiated by a certain profile `id`. 149 | 150 | * `opts.id`: FeedId of some SSB user. 151 | * `opts.reverse`: boolean., default `true`. `false` means threads will be delivered from oldest to most recent, `true` means they will be delivered from most recent to oldest. 152 | * `opts.threadMaxSize`: optional number (default: Infinity). Dictates the maximum amount of messages in each returned thread object. Serves for previewing threads, particularly long ones. 153 | * `opts.allowlist`: optional array of strings. Dictates which messages **types** to allow as root messages, while forbidding other types. 154 | * `opts.blocklist`: optional array of strings. Dictates which messages **types** to forbid as root messages, while allowing other types. 155 | 156 | ### `ssb.threads.profileSummary(opts)` 157 | 158 | Returns a pull stream that emits summary objects of public messages where the profile `id` participated in. 159 | 160 | * `opts.id`: FeedId of some SSB user. 161 | * `opts.reverse`: boolean, default `true`. `false` means threads will be delivered from oldest to most recent, `true` means they will be delivered from most recent to oldest. 162 | * `opts.allowlist`: optional array of strings. Dictates which messages **types** to allow as root messages, while forbidding other types. 163 | * `opts.blocklist`: optional array of strings. Dictates which messages **types** to forbid as root messages, while allowing other types. 164 | 165 | ### `ssb.threads.thread(opts)` 166 | 167 | Returns a pull stream that emits one thread object of messages under the root identified by `opts.root` (MsgId). 168 | 169 | * `opts.root`: a MsgId that identifies the root of the thread. 170 | * `opts.private`: optional boolean indicating that (when `true`) you want to get only private messages, or (when `false`) only public messages; **⚠️ Warning: you should only use this locally, do not allow remote peers to call `ssb.threads.thread`, you don't want them to see your encrypted messages.** 171 | * `opts.threadMaxSize`: optional number (default: Infinity). Dictates the maximum amount of messages in each returned thread object. Serves for previewing threads, particularly long ones. 172 | * `opts.allowlist`: optional array of strings. Dictates which messages **types** to allow as root messages, while forbidding other types. 173 | * `opts.blocklist`: optional array of strings. Dictates which messages **types** to forbid as root messages, while allowing other types. 174 | 175 | If `opts.allowlist` and `opts.blocklist` are not defined, 176 | only messages of type **post** will be returned. 177 | 178 | ### `ssb.threads.threadUpdates(opts)` 179 | 180 | Returns a ("live") pull stream that emits every new message which is a reply to this thread, and which passes the (optional) allowlist or blocklist. 181 | 182 | * `opts.root`: a MesgId that identifies the root of the thread. 183 | * `opts.private`: optional boolean indicating that (when `true`) you want to get only private messages, or (when `false`) only public messages; **⚠️ Warning: you should only use this locally, do not allow remote peers to call `ssb.threads.thread`, you don't want them to see your encrypted messages.** 184 | * `opts.allowlist`: optional array of strings. Dictates which messages **types** to allow as root messages, while forbidding other types. 185 | * `opts.blocklist`: optional array of strings. Dictates which messages **types** to forbid as root messages, while allowing other types. 186 | 187 | ## Install 188 | 189 | ``` 190 | npm install --save ssb-threads 191 | ``` 192 | 193 | ## License 194 | 195 | MIT 196 | -------------------------------------------------------------------------------- /src/hashtags.ts: -------------------------------------------------------------------------------- 1 | const bipf = require('bipf'); 2 | const pl = require('pull-level'); 3 | const pull = require('pull-stream'); 4 | const DB2Plugin = require('ssb-db2/indexes/plugin'); 5 | const { seqs, liveSeqs, deferred } = require('ssb-db2/operators'); 6 | 7 | const B_0 = Buffer.alloc(0); 8 | const BIPF_CONTENT = bipf.allocAndEncode('content'); 9 | const BIPF_CHANNEL = bipf.allocAndEncode('channel'); 10 | const BIPF_MENTIONS = bipf.allocAndEncode('mentions'); 11 | 12 | function sanitize(hashtag: string) { 13 | return hashtag.startsWith('#') 14 | ? hashtag.slice(1).toLocaleLowerCase() 15 | : hashtag.toLocaleLowerCase(); 16 | } 17 | 18 | type LevelKey = [string, number]; 19 | type LevelValue = Buffer; 20 | type AAOLRecord = { value: Buffer; offset: number }; 21 | 22 | const INDEX_NAME = 'hashtags'; 23 | const INDEX_VERSION = 2; 24 | 25 | // [hashtagLabel, seq] => B_0 26 | export default class HashtagPlugin extends DB2Plugin { 27 | constructor(log: any, dir: string) { 28 | super(log, dir, INDEX_NAME, INDEX_VERSION, 'json', 'binary'); 29 | } 30 | 31 | static hasHashtagOperator(texts: Array) { 32 | return deferred((meta: any, cb: any, onAbort: any) => { 33 | meta.db.onDrain(INDEX_NAME, () => { 34 | const plugin = meta.db.getIndex(INDEX_NAME) as HashtagPlugin; 35 | plugin.getMessagesByHashtags(texts, meta.live, cb, onAbort); 36 | }); 37 | }); 38 | } 39 | 40 | static hasSomeHashtagOperator() { 41 | return deferred((meta: any, cb: any, onAbort: any) => { 42 | meta.db.onDrain(INDEX_NAME, () => { 43 | const plugin = meta.db.getIndex(INDEX_NAME) as HashtagPlugin; 44 | plugin.getMessagesWithSomeHashtag(meta.live, cb, onAbort); 45 | }); 46 | }); 47 | } 48 | 49 | processRecord(record: AAOLRecord, seq: number, pValue: number) { 50 | const buf = record.value; 51 | const pValueContent = bipf.seekKey2(buf, pValue, BIPF_CONTENT, 0); 52 | if (pValueContent < 0) return; 53 | const pValueContentChannel = bipf.seekKey2( 54 | buf, 55 | pValueContent, 56 | BIPF_CHANNEL, 57 | 0, 58 | ); 59 | const pValueContentMentions = bipf.seekKey2( 60 | buf, 61 | pValueContent, 62 | BIPF_MENTIONS, 63 | 0, 64 | ); 65 | 66 | if (pValueContentChannel >= 0) { 67 | const channel = bipf.decode(buf, pValueContentChannel); 68 | // msg.value.content.channel typically does not have `#` 69 | if (channel && typeof channel === 'string') { 70 | const label = sanitize(channel); 71 | this.batch.push({ 72 | type: 'put', 73 | key: [label, seq] as LevelKey, 74 | value: B_0 as LevelValue, 75 | }); 76 | } 77 | } 78 | 79 | if (pValueContentMentions >= 0) { 80 | const mentions = bipf.decode(buf, pValueContentMentions); 81 | if (Array.isArray(mentions)) { 82 | for (const { link } of mentions) { 83 | // msg.value.content.mentions[].link SHOULD have `#` 84 | if (link && typeof link === 'string' && link.startsWith('#')) { 85 | const label = sanitize(link); 86 | this.batch.push({ 87 | type: 'put', 88 | key: [label, seq] as LevelKey, 89 | value: B_0 as LevelValue, 90 | }); 91 | } 92 | } 93 | } 94 | } 95 | } 96 | 97 | /** 98 | * Gets the `seq` of all messages that have *any* of the given hashtags. 99 | */ 100 | getMessagesByHashtags( 101 | hashtags: Array, 102 | live: 'liveOnly' | 'liveAndOld' | undefined, 103 | cb: (err: any, opData?: any) => void, 104 | onAbort: (listener: () => void) => void, 105 | ) { 106 | if (!hashtags || !Array.isArray(hashtags)) return cb(null, seqs([])); 107 | if (live === 'liveAndOld') return cb(new Error('unimplemented liveAndOld')); 108 | 109 | const labels = hashtags.map(sanitize); 110 | const sortedLabels = labels.sort((a, b) => a.localeCompare(b)); 111 | const minLabel = sortedLabels[0]; 112 | const maxLabel = sortedLabels[sortedLabels.length - 1]; 113 | 114 | if (live) { 115 | const ps = pull( 116 | pl.read(this.level, { 117 | gte: [minLabel, ''], 118 | lte: [maxLabel, undefined], 119 | keys: true, 120 | keyEncoding: this.keyEncoding, 121 | values: false, 122 | live: true, 123 | old: false, 124 | }), 125 | pull.filter(([label, _seq]: LevelKey) => labels.includes(label)), 126 | pull.map(([_label, seq]: LevelKey) => seq), 127 | ); 128 | return cb(null, liveSeqs(ps)); 129 | } else { 130 | let drainer: { abort: () => void } | undefined; 131 | 132 | onAbort(() => { 133 | drainer?.abort(); 134 | }); 135 | 136 | const seqArr: Array = []; 137 | pull( 138 | pull.values(sortedLabels), 139 | pull.map((label: string) => 140 | pl.read(this.level, { 141 | gte: [label, ''], 142 | lte: [label, undefined], 143 | keys: true, 144 | keyEncoding: this.keyEncoding, 145 | values: false, 146 | }), 147 | ), 148 | pull.flatten(), 149 | (drainer = pull.drain( 150 | ([, seq]: LevelKey) => seqArr.push(seq), 151 | (err: any) => { 152 | drainer = undefined; 153 | if (err) cb(err); 154 | else cb(null, seqs(seqArr)); 155 | }, 156 | )), 157 | ); 158 | } 159 | } 160 | 161 | /** 162 | * Generalized version of `getMesssagesByHashtag`. 163 | * Gets the `seq` of all messages that have *some* hashtag. 164 | */ 165 | getMessagesWithSomeHashtag( 166 | live: 'liveOnly' | 'liveAndOld' | undefined, 167 | cb: (err: any, opData?: any) => void, 168 | onAbort: (listener: () => void) => void, 169 | ) { 170 | if (live === 'liveAndOld') return cb(new Error('unimplemented liveAndOld')); 171 | 172 | const sharedReadOpts = { 173 | gt: ['', ''], 174 | lt: [undefined, undefined], 175 | keys: true, 176 | keyEncoding: this.keyEncoding, 177 | values: false, 178 | }; 179 | 180 | if (live) { 181 | const ps = pull( 182 | pl.read(this.level, { 183 | ...sharedReadOpts, 184 | live: true, 185 | old: false, 186 | }), 187 | pull.map(([, seq]: LevelKey) => seq), 188 | ); 189 | return cb(null, liveSeqs(ps)); 190 | } else { 191 | let drainer: { abort: () => void } | undefined; 192 | 193 | onAbort(() => { 194 | drainer?.abort(); 195 | }); 196 | 197 | const seqArr: Array = []; 198 | pull( 199 | pl.read(this.level, sharedReadOpts), 200 | (drainer = pull.drain( 201 | ([, seq]: LevelKey) => seqArr.push(seq), 202 | (err: any) => { 203 | drainer = undefined; 204 | if (err) cb(err); 205 | else cb(null, seqs(seqArr)); 206 | }, 207 | )), 208 | ); 209 | } 210 | } 211 | 212 | /** 213 | * Gets a list of hashtags of length `limit` that start with the `query` 214 | */ 215 | getMatchingHashtags( 216 | query: string, 217 | limit: number, 218 | cb: (err: any, data?: any) => void, 219 | ) { 220 | // hashtag -> count 221 | const result = new Map(); 222 | 223 | // Upperbound is determined by replacing the last character of the query with the one whose char code is +1 greater 224 | const lessThan = 225 | query.slice(0, query.length - 1) + 226 | String.fromCharCode(query.charCodeAt(query.length - 1) + 1); 227 | 228 | pull( 229 | pl.read(this.level, { 230 | gte: [query, ''], 231 | lt: [lessThan, ''], 232 | keys: true, 233 | keyEncoding: this.keyEncoding, 234 | values: false, 235 | }), 236 | pull.drain( 237 | ([label]: LevelKey) => { 238 | const count = result.get(label) || 0; 239 | result.set(label, count + 1); 240 | }, 241 | (err: any) => { 242 | if (err) cb(err); 243 | else { 244 | // Order by count from highest to lowest 245 | const sorted = Array.from(result.entries()).sort( 246 | ([, c1], [, c2]) => c2 - c1, 247 | ); 248 | 249 | cb(null, limit > 0 ? sorted.slice(0, limit) : sorted); 250 | } 251 | }, 252 | ), 253 | ); 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Msg, MsgId, UnboxedMsg } from 'ssb-typescript'; 2 | import { 3 | isPublic as isPublicType, 4 | isPrivate as isPrivateType, 5 | } from 'ssb-typescript/utils'; 6 | import { plugin, muxrpc } from 'secret-stack-decorators'; 7 | import { 8 | Opts, 9 | Thread, 10 | ProfileOpts, 11 | ThreadOpts, 12 | UpdatesOpts, 13 | FilterOpts, 14 | ThreadUpdatesOpts, 15 | ThreadSummary, 16 | HashtagOpts, 17 | HashtagUpdatesOpts, 18 | HashtagsMatchingOpts, 19 | RecentHashtagsOpts, 20 | } from './types'; 21 | const pull = require('pull-stream'); 22 | const cat = require('pull-cat'); 23 | const sort = require('ssb-sort'); 24 | const Ref = require('ssb-ref'); 25 | const { 26 | where, 27 | and, 28 | or, 29 | not, 30 | type, 31 | author, 32 | descending, 33 | live, 34 | batch, 35 | isPrivate, 36 | isPublic, 37 | hasRoot, 38 | hasFork, 39 | toPullStream, 40 | } = require('ssb-db2/operators'); 41 | 42 | import HashtagsPlugin from './hashtags'; 43 | 44 | const hasHashtag = HashtagsPlugin.hasHashtagOperator; 45 | const hasSomeHashtag = HashtagsPlugin.hasSomeHashtagOperator; 46 | 47 | type CB = (err: any, val?: T) => void; 48 | 49 | const IS_BLOCKING_NEVER = (obj: any, cb: CB) => { 50 | cb(null, false); 51 | }; 52 | 53 | /** 54 | * 100 msgs kept in memory is rather small (~50kB), but this is large enough to 55 | * have good performance in JITDB pagination, see 56 | * https://github.com/ssb-ngi-pointer/jitdb/pull/123#issuecomment-782734363 57 | */ 58 | const BATCH_SIZE = 100; 59 | 60 | function getTimestamp(msg: Msg): number { 61 | const arrivalTimestamp = msg.timestamp; 62 | const declaredTimestamp = msg.value.timestamp; 63 | return Math.min(arrivalTimestamp, declaredTimestamp); 64 | } 65 | 66 | function getRootMsgId(msg: Msg): MsgId { 67 | if (msg?.value?.content) { 68 | const fork = msg.value.content.fork; 69 | const root = msg.value.content.root; 70 | if (fork && Ref.isMsgId(fork)) return fork; 71 | if (root && Ref.isMsgId(root)) return root; 72 | } 73 | // this msg has no root so we assume this is a root 74 | return msg.key; 75 | } 76 | 77 | function isUniqueMsgId(uniqueRoots: Set) { 78 | return function checkIsUnique_id(id: MsgId) { 79 | if (uniqueRoots.has(id)) { 80 | return false; 81 | } else { 82 | uniqueRoots.add(id); 83 | return true; 84 | } 85 | }; 86 | } 87 | 88 | function hasNoBacklinks(msg: Msg): boolean { 89 | return ( 90 | !msg?.value?.content?.root && 91 | !msg?.value?.content?.branch && 92 | !msg?.value?.content?.fork 93 | ); 94 | } 95 | 96 | function notNull(x: any): boolean { 97 | return x !== null; 98 | } 99 | 100 | // Strip leading # from hashtag string 101 | function withoutLeadingHashtag(s: string) { 102 | return s.startsWith('#') ? s.slice(1) : s; 103 | } 104 | 105 | function makeFilterOperator(opts: FilterOpts): any { 106 | if (opts.allowlist) { 107 | const allowedTypes = opts.allowlist.map((x) => type(x)); 108 | return or(...allowedTypes); 109 | } 110 | if (opts.blocklist) { 111 | const blockedTypes = opts.blocklist.map((x) => not(type(x))); 112 | return and(...blockedTypes); 113 | } 114 | return null; 115 | } 116 | 117 | function makePassesFilter(opts: FilterOpts): (msg: Msg) => boolean { 118 | if (opts.allowlist) { 119 | return (msg: Msg) => 120 | opts.allowlist!.some((type) => msg?.value?.content?.type === type); 121 | } 122 | if (opts.blocklist) { 123 | return (msg: Msg) => 124 | opts.blocklist!.every((type) => msg?.value?.content?.type !== type); 125 | } 126 | return () => true; 127 | } 128 | 129 | function assertFollowingOnlyUsability< 130 | T extends ((obj: any, cb: CB) => void) | null, 131 | >(enabled: boolean, isFollowing: T): asserts isFollowing is NonNullable { 132 | if (enabled && !isFollowing) { 133 | throw new Error('ssb-threads requires ssb-friends installed'); 134 | } 135 | } 136 | 137 | @plugin('2.0.0') 138 | class threads { 139 | private readonly ssb: Record; 140 | private readonly isBlocking: (obj: any, cb: CB) => void; 141 | private readonly isFollowing: ((obj: any, cb: CB) => void) | null; 142 | 143 | constructor(ssb: Record, _config: any) { 144 | this.ssb = ssb; 145 | this.isBlocking = ssb.friends?.isBlocking 146 | ? ssb.friends.isBlocking 147 | : IS_BLOCKING_NEVER; 148 | this.isFollowing = ssb.friends?.isFollowing; 149 | this.ssb.db.registerIndex(HashtagsPlugin); 150 | } 151 | 152 | //#region PRIVATE 153 | 154 | // Make sure you're using this on a source that only returns root messages 155 | private onlyKeepFollowingRootMessages = 156 | (isFollowing: NonNullable) => (source: any) => 157 | pull( 158 | source, 159 | pull.asyncMap((rootMsg: Msg, cb: CB) => { 160 | if (rootMsg.value.author === this.ssb.id) { 161 | return cb(null, rootMsg); 162 | } 163 | 164 | isFollowing( 165 | { source: this.ssb.id, dest: rootMsg.value.author }, 166 | (err: any, following: boolean) => { 167 | if (err) cb(err); 168 | else if (!following) cb(null, null); 169 | else cb(null, rootMsg); 170 | }, 171 | ); 172 | }), 173 | pull.filter(notNull), 174 | ); 175 | 176 | private onlyKeepMessageIfFollowingRoot = 177 | (isFollowing: NonNullable, includeSelf: boolean) => 178 | (source: any) => 179 | pull( 180 | source, 181 | pull.asyncMap((msg: Msg, cb: CB) => { 182 | const rootMsgKey = getRootMsgId(msg); 183 | 184 | if (includeSelf && msg.value.author === this.ssb.id) { 185 | return cb(null, msg); 186 | } 187 | 188 | this.ssb.db.getMsg(rootMsgKey, (err: any, rootMsg: Msg) => { 189 | if (err) cb(null, null); 190 | 191 | if (rootMsg.value.author === this.ssb.id) { 192 | return cb(null, msg); 193 | } 194 | 195 | isFollowing( 196 | { source: this.ssb.id, dest: rootMsg.value.author }, 197 | (err: any, following: boolean) => { 198 | if (err) cb(err); 199 | else if (!following) cb(null, null); 200 | else cb(null, msg); 201 | }, 202 | ); 203 | }); 204 | }), 205 | pull.filter(notNull), 206 | ); 207 | 208 | private removeMessagesFromBlocked = (source: any) => 209 | pull( 210 | source, 211 | pull.asyncMap((msg: Msg, cb: CB) => { 212 | this.isBlocking( 213 | { source: this.ssb.id, dest: msg.value.author }, 214 | (err: any, blocking: boolean) => { 215 | if (err) cb(err); 216 | else if (blocking) cb(null, null); 217 | else cb(null, msg); 218 | }, 219 | ); 220 | }), 221 | pull.filter(notNull), 222 | ); 223 | 224 | private removeMessagesWhereRootIsMissingOrBlocked = 225 | (passesFilter: (msg: Msg) => boolean) => (source: any) => 226 | pull( 227 | source, 228 | pull.asyncMap((msg: Msg, cb: CB) => { 229 | const rootMsgKey = getRootMsgId(msg); 230 | if (rootMsgKey === msg.key) return cb(null, msg); 231 | this.ssb.db.getMsg(rootMsgKey, (err: any, rootMsg: Msg) => { 232 | if (err) cb(null, null); 233 | else if (!passesFilter(rootMsg)) cb(null, null); 234 | else { 235 | this.isBlocking( 236 | { source: this.ssb.id, dest: rootMsg.value.author }, 237 | (err, blocking: boolean) => { 238 | if (err) cb(null, null); 239 | else if (blocking) cb(null, null); 240 | else cb(null, msg); 241 | }, 242 | ); 243 | } 244 | }); 245 | }), 246 | pull.filter(notNull), 247 | ); 248 | 249 | private nonBlockedRootToThread = ( 250 | maxSize: number, 251 | filter: any, 252 | privately: boolean = false, 253 | ) => { 254 | return (root: Msg, cb: CB) => { 255 | pull( 256 | cat([ 257 | pull.values([root]), 258 | pull( 259 | this.ssb.db.query( 260 | where( 261 | and( 262 | hasRoot(root.key), 263 | filter, 264 | privately ? isPrivate() : isPublic(), 265 | ), 266 | ), 267 | batch(BATCH_SIZE), 268 | descending(), 269 | toPullStream(), 270 | ), 271 | this.removeMessagesFromBlocked, 272 | pull.take(maxSize), 273 | ), 274 | ]), 275 | pull.take(maxSize + 1), 276 | pull.collect((err: any, arr: Array) => { 277 | if (err) return cb(err); 278 | const full = arr.length <= maxSize; 279 | sort(arr); 280 | if (arr.length > maxSize && arr.length >= 3) arr.splice(1, 1); 281 | cb(null, { messages: arr, full }); 282 | }), 283 | ); 284 | }; 285 | }; 286 | 287 | private nonBlockedRootToSummary = (filter: any) => { 288 | return (root: Msg, cb: CB) => { 289 | pull( 290 | this.ssb.db.query( 291 | where(and(or(hasRoot(root.key), hasFork(root.key)), filter)), 292 | batch(BATCH_SIZE), 293 | descending(), 294 | toPullStream(), 295 | ), 296 | this.removeMessagesFromBlocked, 297 | pull.collect((err: any, arr: Array) => { 298 | if (err) return cb(err); 299 | const timestamp = Math.max(...arr.concat(root).map(getTimestamp)); 300 | cb(null, { root, replyCount: arr.length, timestamp }); 301 | }), 302 | ); 303 | }; 304 | }; 305 | 306 | /** 307 | * Returns a pull-stream operator that maps the source of message keys 308 | * to their respective root messages, if the roots are in the database. 309 | */ 310 | private fetchMsgFromIdIfItExists = (source: any) => 311 | pull( 312 | source, 313 | pull.asyncMap((id: MsgId, cb: CB>) => { 314 | this.ssb.db.getMsg(id, (err: any, msg: Msg) => { 315 | if (err) cb(null, null as any /* missing msg */); 316 | else cb(err, msg); 317 | }); 318 | }), 319 | pull.filter(notNull), 320 | ); 321 | 322 | private rootToThread = (maxSize: number, filter: any, privately: boolean) => { 323 | return pull.asyncMap((root: UnboxedMsg, cb: CB) => { 324 | this.isBlocking( 325 | { source: this.ssb.id, dest: root.value.author }, 326 | (err: any, blocking: boolean) => { 327 | if (err) { 328 | cb(err); 329 | } else if (blocking) { 330 | cb(new Error('Author Blocked:' + root.value.author)); 331 | } else { 332 | this.nonBlockedRootToThread(maxSize, filter, privately)(root, cb); 333 | } 334 | }, 335 | ); 336 | }); 337 | }; 338 | 339 | private rootMsgIdForHashtagMatch = ( 340 | hashtags: Array, 341 | opts: { 342 | needsDescending: boolean; 343 | msgPassesFilter: (msg: Msg) => boolean; 344 | queryFilter: any; 345 | }, 346 | ) => { 347 | return pull( 348 | this.ssb.db.query( 349 | where(and(isPublic(), hasHashtag(hashtags), opts.queryFilter)), 350 | opts.needsDescending ? descending() : null, 351 | batch(BATCH_SIZE), 352 | toPullStream(), 353 | ), 354 | pull.map(getRootMsgId), 355 | pull.filter(isUniqueMsgId(new Set())), 356 | this.fetchMsgFromIdIfItExists, 357 | pull.filter(opts.msgPassesFilter), 358 | pull.filter(isPublicType), 359 | pull.filter(hasNoBacklinks), 360 | this.removeMessagesFromBlocked, 361 | ); 362 | }; 363 | 364 | //#endregion 365 | 366 | //#region PUBLIC API 367 | 368 | @muxrpc('source') 369 | public public = (opts: Opts) => { 370 | const needsDescending = opts.reverse ?? true; 371 | const threadMaxSize = opts.threadMaxSize ?? Infinity; 372 | const followingOnly = opts.following ?? false; 373 | const filterOperator = makeFilterOperator(opts); 374 | const passesFilter = makePassesFilter(opts); 375 | 376 | try { 377 | assertFollowingOnlyUsability(followingOnly, this.isFollowing); 378 | } catch (err) { 379 | return pull.error(err); 380 | } 381 | 382 | return pull( 383 | this.ssb.db.query( 384 | where(and(isPublic(), filterOperator)), 385 | needsDescending ? descending() : null, 386 | batch(BATCH_SIZE), 387 | toPullStream(), 388 | ), 389 | pull.map(getRootMsgId), 390 | pull.filter(isUniqueMsgId(new Set())), 391 | this.fetchMsgFromIdIfItExists, 392 | pull.filter(passesFilter), 393 | pull.filter(isPublicType), 394 | pull.filter(hasNoBacklinks), 395 | this.removeMessagesFromBlocked, 396 | followingOnly 397 | ? this.onlyKeepFollowingRootMessages(this.isFollowing) 398 | : pull.through(), 399 | pull.asyncMap(this.nonBlockedRootToThread(threadMaxSize, filterOperator)), 400 | ); 401 | }; 402 | 403 | @muxrpc('source') 404 | public publicSummary = (opts: Omit) => { 405 | const needsDescending = opts.reverse ?? true; 406 | const followingOnly = opts.following ?? false; 407 | const filterOperator = makeFilterOperator(opts); 408 | const passesFilter = makePassesFilter(opts); 409 | 410 | try { 411 | assertFollowingOnlyUsability(followingOnly, this.isFollowing); 412 | } catch (err) { 413 | return pull.error(err); 414 | } 415 | 416 | return pull( 417 | this.ssb.db.query( 418 | where(and(isPublic(), filterOperator)), 419 | needsDescending ? descending() : null, 420 | batch(BATCH_SIZE), 421 | toPullStream(), 422 | ), 423 | pull.map(getRootMsgId), 424 | pull.filter(isUniqueMsgId(new Set())), 425 | this.fetchMsgFromIdIfItExists, 426 | pull.filter(passesFilter), 427 | pull.filter(isPublicType), 428 | pull.filter(hasNoBacklinks), 429 | this.removeMessagesFromBlocked, 430 | followingOnly 431 | ? this.onlyKeepFollowingRootMessages(this.isFollowing) 432 | : pull.through(), 433 | pull.asyncMap(this.nonBlockedRootToSummary(filterOperator)), 434 | ); 435 | }; 436 | 437 | @muxrpc('source') 438 | public publicUpdates = (opts: UpdatesOpts) => { 439 | const filterOperator = makeFilterOperator(opts); 440 | const passesFilter = makePassesFilter(opts); 441 | const includeSelf = opts.includeSelf ?? false; 442 | const followingOnly = opts.following ?? false; 443 | 444 | try { 445 | assertFollowingOnlyUsability(followingOnly, this.isFollowing); 446 | } catch (err) { 447 | return pull.error(err); 448 | } 449 | 450 | return pull( 451 | this.ssb.db.query( 452 | where( 453 | and( 454 | isPublic(), 455 | filterOperator, 456 | includeSelf ? null : not(author(this.ssb.id, { dedicated: true })), 457 | ), 458 | ), 459 | live({ old: false }), 460 | toPullStream(), 461 | ), 462 | this.removeMessagesFromBlocked, 463 | this.removeMessagesWhereRootIsMissingOrBlocked(passesFilter), 464 | followingOnly 465 | ? this.onlyKeepMessageIfFollowingRoot(this.isFollowing, includeSelf) 466 | : pull.through(), 467 | pull.map((msg: Msg) => msg.key), 468 | ); 469 | }; 470 | 471 | @muxrpc('async') 472 | public hashtagCount = ( 473 | opts: Omit, 474 | cb: CB, 475 | ) => { 476 | if (!opts.hashtag || typeof opts.hashtag !== 'string') { 477 | cb(new Error('opts.hashtag is required')); 478 | return; 479 | } 480 | 481 | pull( 482 | this.rootMsgIdForHashtagMatch([opts.hashtag], { 483 | needsDescending: false, 484 | msgPassesFilter: makePassesFilter(opts), 485 | queryFilter: makeFilterOperator(opts), 486 | }), 487 | pull.reduce((count: number) => count + 1, 0, cb), 488 | ); 489 | }; 490 | 491 | @muxrpc('source') 492 | public hashtagSummary = ( 493 | opts: Omit, 494 | ) => { 495 | const filterOperator = makeFilterOperator(opts); 496 | let hashtags: Array | null = null; 497 | if (opts.hashtags && Array.isArray(opts.hashtags)) { 498 | if (opts.hashtags.length === 0) { 499 | return pull.error(new Error('opts.hashtags must have at least one')); 500 | } 501 | if (opts.hashtags.some((h) => !h || typeof h !== 'string')) { 502 | return pull.error( 503 | new Error( 504 | 'opts.hashtags must be an array of strings, but got: ' + 505 | opts.hashtags, 506 | ), 507 | ); 508 | } 509 | hashtags = opts.hashtags; 510 | } else if (opts.hashtag && typeof opts.hashtag === 'string') { 511 | hashtags = [opts.hashtag]; 512 | } else { 513 | return pull.error(new Error('opts.hashtag or opts.hashtags is required')); 514 | } 515 | 516 | return pull( 517 | this.rootMsgIdForHashtagMatch(hashtags, { 518 | needsDescending: opts.reverse ?? true, 519 | msgPassesFilter: makePassesFilter(opts), 520 | queryFilter: filterOperator, 521 | }), 522 | pull.asyncMap(this.nonBlockedRootToSummary(filterOperator)), 523 | ); 524 | }; 525 | 526 | @muxrpc('source') 527 | public hashtagUpdates = (opts: HashtagUpdatesOpts) => { 528 | const filterOperator = makeFilterOperator(opts); 529 | let hashtags: Array | null = null; 530 | if (opts.hashtags && Array.isArray(opts.hashtags)) { 531 | if (opts.hashtags.length === 0) { 532 | return pull.error(new Error('opts.hashtags must have at least one')); 533 | } 534 | if (opts.hashtags.some((h) => !h || typeof h !== 'string')) { 535 | return pull.error( 536 | new Error( 537 | 'opts.hashtags must be an array of strings, but got: ' + 538 | opts.hashtags, 539 | ), 540 | ); 541 | } 542 | hashtags = opts.hashtags; 543 | } else if (opts.hashtag && typeof opts.hashtag === 'string') { 544 | hashtags = [opts.hashtag]; 545 | } else { 546 | return pull.error(new Error('opts.hashtag or opts.hashtags is required')); 547 | } 548 | 549 | return pull( 550 | this.ssb.db.query( 551 | where(and(isPublic(), hasHashtag(hashtags), filterOperator)), 552 | live({ old: false }), 553 | toPullStream(), 554 | ), 555 | this.removeMessagesFromBlocked, 556 | pull.map(getRootMsgId), 557 | ); 558 | }; 559 | 560 | @muxrpc('async') 561 | public hashtagsMatching = ( 562 | opts: HashtagsMatchingOpts, 563 | cb: CB>, 564 | ) => { 565 | if (typeof opts.query !== 'string' || opts.query.length === 0) 566 | return cb(new Error('opts.query must be non-empty string')); 567 | if (opts.limit && opts.limit <= 0) 568 | return cb(new Error('opts.limit must be number greater than 0')); 569 | 570 | const query = opts.query.toLocaleLowerCase(); 571 | const limit = opts.limit || 10; 572 | this.ssb.db.onDrain('hashtags', () => { 573 | const hashtagsPlugin: HashtagsPlugin = this.ssb.db.getIndex('hashtags'); 574 | hashtagsPlugin.getMatchingHashtags(query, limit, cb); 575 | }); 576 | }; 577 | 578 | @muxrpc('async') 579 | public recentHashtags = (opts: RecentHashtagsOpts, cb: CB>) => { 580 | if (typeof opts.limit !== 'number' || opts.limit <= 0) 581 | return cb(new Error('Limit must be number greater than 0')); 582 | 583 | const preserveCase = !!opts.preserveCase; 584 | 585 | // completely normalized hashtag (no leading # and lowercase) -> partially normalized (no leading #) 586 | const result = new Map(); 587 | 588 | return pull( 589 | this.ssb.db.query( 590 | where(and(isPublic(), hasSomeHashtag())), 591 | descending(), 592 | batch(BATCH_SIZE), 593 | toPullStream(), 594 | ), 595 | this.removeMessagesFromBlocked, 596 | pull.through((msg: Msg) => { 597 | const { channel, mentions } = msg.value.content; 598 | 599 | if (channel) { 600 | const withoutHashtag = withoutLeadingHashtag(channel); 601 | const lowercaseWithoutHashtag = withoutHashtag.toLocaleLowerCase(); 602 | 603 | // Since the messages received are descending, 604 | // we don't want to update the value for 605 | // associated key if it already exists 606 | // because we want the keep the most recent 607 | // variation of the hashtag (accounts for casing) 608 | if (!result.has(lowercaseWithoutHashtag)) { 609 | result.set(lowercaseWithoutHashtag, withoutHashtag); 610 | } 611 | } else if (Array.isArray(mentions)) { 612 | for (const { link } of mentions) { 613 | // msg.value.content.mentions[].link SHOULD have `#` 614 | if (link && typeof link === 'string' && link.startsWith('#')) { 615 | const withoutHashtag = withoutLeadingHashtag(link); 616 | const lowercaseWithoutHashtag = 617 | withoutHashtag.toLocaleLowerCase(); 618 | 619 | // Since the messages received are descending, 620 | // we don't want to update the value for 621 | // associated key if it already exists 622 | // because we want the keep the most recent 623 | // variation of the hashtag (accounts for casing) 624 | if (!result.has(lowercaseWithoutHashtag)) { 625 | result.set(lowercaseWithoutHashtag, withoutHashtag); 626 | } 627 | 628 | if (result.size === opts.limit) break; 629 | } 630 | } 631 | } 632 | }), 633 | // Keep taking values until the result size === limit 634 | pull.take(() => result.size < opts.limit), 635 | pull.onEnd(() => { 636 | cb(null, Array.from(preserveCase ? result.values() : result.keys())); 637 | }), 638 | ); 639 | }; 640 | 641 | @muxrpc('source') 642 | public private = (opts: Opts) => { 643 | const needsDescending = opts.reverse ?? true; 644 | const threadMaxSize = opts.threadMaxSize ?? Infinity; 645 | const filterOperator = makeFilterOperator(opts); 646 | const passesFilter = makePassesFilter(opts); 647 | 648 | return pull( 649 | this.ssb.db.query( 650 | where(and(isPrivate(), filterOperator)), 651 | needsDescending ? descending() : null, 652 | batch(BATCH_SIZE), 653 | toPullStream(), 654 | ), 655 | pull.map(getRootMsgId), 656 | pull.filter(isUniqueMsgId(new Set())), 657 | this.fetchMsgFromIdIfItExists, 658 | pull.filter(passesFilter), 659 | pull.filter(isPrivateType), 660 | pull.filter(hasNoBacklinks), 661 | this.removeMessagesFromBlocked, 662 | pull.asyncMap( 663 | this.nonBlockedRootToThread(threadMaxSize, filterOperator, true), 664 | ), 665 | ); 666 | }; 667 | 668 | @muxrpc('source') 669 | public privateUpdates = (opts: UpdatesOpts) => { 670 | const filterOperator = makeFilterOperator(opts); 671 | const includeSelf = opts.includeSelf ?? false; 672 | 673 | return pull( 674 | this.ssb.db.query( 675 | where( 676 | and( 677 | isPrivate(), 678 | filterOperator, 679 | includeSelf ? null : not(author(this.ssb.id, { dedicated: true })), 680 | ), 681 | ), 682 | live({ old: false }), 683 | toPullStream(), 684 | ), 685 | this.removeMessagesFromBlocked, 686 | pull.map(getRootMsgId), 687 | ); 688 | }; 689 | 690 | @muxrpc('source') 691 | public profile = (opts: ProfileOpts) => { 692 | const id = opts.id; 693 | const needsDescending = opts.reverse ?? true; 694 | const threadMaxSize = opts.threadMaxSize ?? Infinity; 695 | const filterOperator = makeFilterOperator(opts); 696 | const passesFilter = makePassesFilter(opts); 697 | 698 | return pull( 699 | this.ssb.db.query( 700 | where(and(author(id), isPublic(), filterOperator)), 701 | needsDescending ? descending() : null, 702 | batch(BATCH_SIZE), 703 | toPullStream(), 704 | ), 705 | pull.map(getRootMsgId), 706 | pull.filter(isUniqueMsgId(new Set())), 707 | this.fetchMsgFromIdIfItExists, 708 | pull.filter(passesFilter), 709 | pull.filter(isPublicType), 710 | this.removeMessagesFromBlocked, 711 | pull.asyncMap(this.nonBlockedRootToThread(threadMaxSize, filterOperator)), 712 | ); 713 | }; 714 | 715 | @muxrpc('source') 716 | public profileSummary = (opts: Omit) => { 717 | const id = opts.id; 718 | const needsDescending = opts.reverse ?? true; 719 | const filterOperator = makeFilterOperator(opts); 720 | const passesFilter = makePassesFilter(opts); 721 | 722 | return pull( 723 | this.ssb.db.query( 724 | where(and(author(id), isPublic(), filterOperator)), 725 | needsDescending ? descending() : null, 726 | batch(BATCH_SIZE), 727 | toPullStream(), 728 | ), 729 | pull.map(getRootMsgId), 730 | pull.filter(isUniqueMsgId(new Set())), 731 | this.fetchMsgFromIdIfItExists, 732 | pull.filter(passesFilter), 733 | pull.filter(isPublicType), 734 | pull.filter(hasNoBacklinks), 735 | this.removeMessagesFromBlocked, 736 | pull.asyncMap(this.nonBlockedRootToSummary(filterOperator)), 737 | ); 738 | }; 739 | 740 | @muxrpc('source') 741 | public thread = (opts: ThreadOpts) => { 742 | const privately = !!opts.private; 743 | const threadMaxSize = opts.threadMaxSize ?? Infinity; 744 | const optsOk = 745 | !opts.allowlist && !opts.blocklist 746 | ? { ...opts, allowlist: ['post'] } 747 | : opts; 748 | const filterOperator = makeFilterOperator(optsOk); 749 | 750 | return pull( 751 | pull.values([opts.root]), 752 | this.fetchMsgFromIdIfItExists, 753 | privately ? pull.through() : pull.filter(isPublicType), 754 | this.rootToThread(threadMaxSize, filterOperator, privately), 755 | ); 756 | }; 757 | 758 | @muxrpc('source') 759 | public threadUpdates = (opts: ThreadUpdatesOpts) => { 760 | const privately = !!opts.private; 761 | const filterOperator = makeFilterOperator(opts); 762 | 763 | return pull( 764 | this.ssb.db.query( 765 | where( 766 | and( 767 | hasRoot(opts.root), 768 | filterOperator, 769 | privately ? isPrivate() : isPublic(), 770 | ), 771 | ), 772 | live({ old: false }), 773 | toPullStream(), 774 | ), 775 | this.removeMessagesFromBlocked, 776 | ); 777 | }; 778 | 779 | //#endregion 780 | } 781 | 782 | export = threads; 783 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { Msg, MsgId } from 'ssb-typescript'; 2 | 3 | export type Thread = { 4 | messages: Array; 5 | full: boolean; 6 | }; 7 | 8 | export type ThreadSummary = { 9 | root: Msg; 10 | replyCount: number; 11 | 12 | /** 13 | * Timestamp of the latest post in this thread 14 | */ 15 | timestamp: number; 16 | }; 17 | 18 | export type FilterOpts = { 19 | allowlist?: Array; 20 | blocklist?: Array; 21 | following?: boolean; 22 | }; 23 | 24 | export type Opts = { 25 | reverse?: boolean; 26 | threadMaxSize?: number; 27 | } & FilterOpts; 28 | 29 | export type HashtagOpts = { 30 | reverse?: boolean; 31 | hashtag: string; 32 | hashtags?: Array; 33 | threadMaxSize?: number; 34 | } & FilterOpts; 35 | 36 | export type UpdatesOpts = { 37 | includeSelf?: boolean; 38 | } & FilterOpts; 39 | 40 | export type HashtagUpdatesOpts = 41 | | ({ hashtag: string; hashtags: undefined } & FilterOpts) 42 | | ({ hashtag: undefined; hashtags: Array } & FilterOpts); 43 | 44 | export type ThreadOpts = { 45 | root: MsgId; 46 | private?: boolean; 47 | threadMaxSize?: number; 48 | } & FilterOpts; 49 | 50 | export type ThreadUpdatesOpts = { 51 | root: MsgId; 52 | private?: boolean; 53 | } & FilterOpts; 54 | 55 | export type ProfileOpts = Opts & { 56 | id: string; 57 | }; 58 | 59 | export type HashtagsMatchingOpts = { 60 | query: string; 61 | limit?: number; 62 | }; 63 | 64 | export type RecentHashtagsOpts = { 65 | limit: number; 66 | preserveCase?: boolean; 67 | }; 68 | -------------------------------------------------------------------------------- /test/hashtagCount.test.js: -------------------------------------------------------------------------------- 1 | const test = require('tape'); 2 | const pull = require('pull-stream'); 3 | const ssbKeys = require('ssb-keys'); 4 | const pullAsync = require('pull-async'); 5 | const Testbot = require('./testbot'); 6 | const wait = require('./wait'); 7 | 8 | const lucyKeys = ssbKeys.generate(null, 'lucy'); 9 | 10 | test('threads.hashtagCount understands msg.value.content.channel', (t) => { 11 | const ssb = Testbot({ keys: lucyKeys }); 12 | 13 | pull( 14 | pullAsync((cb) => { 15 | ssb.db.create( 16 | { 17 | keys: lucyKeys, 18 | content: { type: 'post', text: 'My favorite animals (thread 1)' }, 19 | }, 20 | wait(cb, 100), 21 | ); 22 | }), 23 | pull.asyncMap((rootMsg, cb) => { 24 | ssb.db.create( 25 | { 26 | keys: lucyKeys, 27 | content: { 28 | type: 'post', 29 | text: 'wombat (reply to thread 1)', 30 | channel: 'animals', 31 | root: rootMsg.key, 32 | }, 33 | }, 34 | wait(cb, 100), 35 | ); 36 | }), 37 | pull.asyncMap((_, cb) => { 38 | ssb.db.create( 39 | { 40 | keys: lucyKeys, 41 | content: { 42 | type: 'post', 43 | text: 'My favorite animal is the wombat (thread 2)', 44 | channel: 'animals', 45 | }, 46 | }, 47 | wait(cb, 100), 48 | ); 49 | }), 50 | pull.drain(null, (err) => { 51 | t.error(err); 52 | 53 | ssb.threads.hashtagCount({ hashtag: 'animals' }, (err2, count) => { 54 | t.error(err2); 55 | t.isEqual(count, 2); 56 | ssb.close(t.end); 57 | }); 58 | }), 59 | ); 60 | }); 61 | 62 | test('threads.hashtagCount understands msg.value.content.mentions', (t) => { 63 | const ssb = Testbot({ keys: lucyKeys }); 64 | 65 | pull( 66 | pullAsync((cb) => { 67 | ssb.db.create( 68 | { 69 | keys: lucyKeys, 70 | content: { type: 'post', text: 'My favorite animals (thread 1)' }, 71 | }, 72 | wait(cb, 100), 73 | ); 74 | }), 75 | pull.asyncMap((rootMsg, cb) => { 76 | ssb.db.create( 77 | { 78 | keys: lucyKeys, 79 | content: { 80 | type: 'post', 81 | text: 'wombat (reply to thread 1)', 82 | mentions: [{ link: '#animals' }], 83 | root: rootMsg.key, 84 | }, 85 | }, 86 | wait(cb, 100), 87 | ); 88 | }), 89 | pull.asyncMap((_, cb) => { 90 | ssb.db.create( 91 | { 92 | keys: lucyKeys, 93 | content: { 94 | type: 'post', 95 | text: 'My favorite animal is the wombat (thread 2)', 96 | mentions: [{ link: '#animals' }], 97 | }, 98 | }, 99 | wait(cb, 100), 100 | ); 101 | }), 102 | pull.drain(null, (err) => { 103 | t.error(err); 104 | 105 | ssb.threads.hashtagCount({ hashtag: 'animals' }, (err2, count) => { 106 | t.error(err2); 107 | t.isEqual(count, 2); 108 | ssb.close(t.end); 109 | }); 110 | }), 111 | ); 112 | }); 113 | 114 | test('threads.hashtagCount input is case-insensitive', (t) => { 115 | const ssb = Testbot({ keys: lucyKeys }); 116 | 117 | pull( 118 | pullAsync((cb) => { 119 | ssb.db.create( 120 | { 121 | keys: lucyKeys, 122 | content: { 123 | type: 'post', 124 | text: 'My favorite animal is the wombat', 125 | mentions: [{ link: '#animals' }], 126 | }, 127 | }, 128 | wait(cb, 100), 129 | ); 130 | }), 131 | pull.drain(null, (err) => { 132 | t.error(err); 133 | 134 | ssb.threads.hashtagCount({ hashtag: 'ANIMALS' }, (err2, count) => { 135 | t.error(err2); 136 | t.isEqual(count, 1); 137 | ssb.close(t.end); 138 | }); 139 | }), 140 | ); 141 | }); 142 | -------------------------------------------------------------------------------- /test/hashtagSummary.test.js: -------------------------------------------------------------------------------- 1 | const test = require('tape'); 2 | const pull = require('pull-stream'); 3 | const ssbKeys = require('ssb-keys'); 4 | const pullAsync = require('pull-async'); 5 | const p = require('util').promisify; 6 | const Testbot = require('./testbot'); 7 | const wait = require('./wait'); 8 | 9 | const lucyKeys = ssbKeys.generate(null, 'lucy'); 10 | 11 | test('threads.hashtagSummary understands msg.value.content.channel', (t) => { 12 | const ssb = Testbot({ keys: lucyKeys }); 13 | 14 | pull( 15 | pullAsync((cb) => { 16 | ssb.db.create( 17 | { 18 | keys: lucyKeys, 19 | content: { type: 'post', text: 'Pizza', channel: 'food' }, 20 | }, 21 | wait(cb, 100), 22 | ); 23 | }), 24 | pull.asyncMap((rootMsg, cb) => { 25 | ssb.db.create( 26 | { 27 | keys: lucyKeys, 28 | content: { type: 'post', text: 'pepperoni', root: rootMsg.key }, 29 | }, 30 | wait(cb, 100), 31 | ); 32 | }), 33 | pull.asyncMap((prevMsg, cb) => { 34 | ssb.db.create( 35 | { 36 | keys: lucyKeys, 37 | content: { type: 'post', text: 'Third message' }, 38 | }, 39 | wait(cb, 100), 40 | ); 41 | }), 42 | pull.map(() => ssb.threads.hashtagSummary({ hashtag: 'food' })), 43 | pull.flatten(), 44 | 45 | pull.collect((err, summaries) => { 46 | t.error(err); 47 | t.equals(summaries.length, 1, 'only one summary'); 48 | const summary = summaries[0]; 49 | t.equals(summary.replyCount, 1, 'summary counts 1 reply'); 50 | t.true( 51 | summary.timestamp > summary.root.timestamp, 52 | 'summary timestamp greater than root timestamp', 53 | ); 54 | t.equals( 55 | summary.root.value.content.root, 56 | undefined, 57 | 'root message is root', 58 | ); 59 | t.equals(summary.root.value.content.text, 'Pizza'); 60 | 61 | ssb.close(t.end); 62 | }), 63 | ); 64 | }); 65 | 66 | test('threads.hashtagSummary input is case-insensitive', (t) => { 67 | const ssb = Testbot({ keys: lucyKeys }); 68 | 69 | pull( 70 | pullAsync((cb) => { 71 | ssb.db.publish( 72 | { type: 'post', text: 'Pizza', channel: 'Food' }, 73 | wait(cb, 100), 74 | ); 75 | }), 76 | pull.asyncMap((rootMsg, cb) => { 77 | ssb.db.publish( 78 | { type: 'post', text: 'pepperoni', root: rootMsg.key }, 79 | wait(cb, 100), 80 | ); 81 | }), 82 | pull.asyncMap((prevMsg, cb) => { 83 | ssb.db.publish({ type: 'post', text: 'Third message' }, wait(cb, 100)); 84 | }), 85 | pull.map(() => ssb.threads.hashtagSummary({ hashtag: 'food' })), 86 | pull.flatten(), 87 | 88 | pull.collect((err, summaries) => { 89 | t.error(err); 90 | t.equals(summaries.length, 1, 'only one summary'); 91 | const summary = summaries[0]; 92 | t.equals(summary.replyCount, 1, 'summary counts 1 reply'); 93 | t.true( 94 | summary.timestamp > summary.root.timestamp, 95 | 'summary timestamp greater than root timestamp', 96 | ); 97 | t.equals( 98 | summary.root.value.content.root, 99 | undefined, 100 | 'root message is root', 101 | ); 102 | t.equals(summary.root.value.content.text, 'Pizza'); 103 | 104 | ssb.close(t.end); 105 | }), 106 | ); 107 | }); 108 | 109 | test('threads.hashtagSummary understands msg.value.content.mentions', (t) => { 110 | const ssb = Testbot({ keys: lucyKeys }); 111 | 112 | pull( 113 | pullAsync((cb) => { 114 | ssb.db.publish( 115 | { type: 'post', text: 'Dog', mentions: [{ link: '#animals' }] }, 116 | wait(cb, 100), 117 | ); 118 | }), 119 | pull.asyncMap((rootMsg, cb) => { 120 | ssb.db.publish( 121 | { type: 'post', text: 'poodle', root: rootMsg.key }, 122 | wait(cb, 100), 123 | ); 124 | }), 125 | pull.asyncMap((prevMsg, cb) => { 126 | ssb.db.publish({ type: 'post', text: 'Cat' }, wait(cb, 100)); 127 | }), 128 | pull.map(() => ssb.threads.hashtagSummary({ hashtag: 'animals' })), 129 | pull.flatten(), 130 | 131 | pull.collect((err, summaries) => { 132 | t.error(err); 133 | t.equals(summaries.length, 1, 'only one summary'); 134 | const summary = summaries[0]; 135 | t.equals(summary.replyCount, 1, 'summary counts 1 reply'); 136 | t.true( 137 | summary.timestamp > summary.root.timestamp, 138 | 'summary timestamp greater than root timestamp', 139 | ); 140 | t.equals( 141 | summary.root.value.content.root, 142 | undefined, 143 | 'root message is root', 144 | ); 145 | t.equals(summary.root.value.content.text, 'Dog'); 146 | 147 | ssb.close(t.end); 148 | }), 149 | ); 150 | }); 151 | 152 | test('threads.hashtagSummary accepts array of hashtags', async (t) => { 153 | const ssb = Testbot({ keys: lucyKeys }); 154 | 155 | const rootMsg = await p(ssb.db.publish)({ 156 | type: 'post', 157 | text: 'Dog', 158 | mentions: [{ link: '#animals' }], 159 | }); 160 | 161 | await p(ssb.db.publish)({ 162 | type: 'post', 163 | text: 'poodle', 164 | root: rootMsg.key, 165 | }); 166 | 167 | await p(ssb.db.publish)({ 168 | type: 'post', 169 | text: 'Pizza', 170 | mentions: [{ link: '#food' }], 171 | }); 172 | 173 | await p(ssb.db.publish)({ 174 | type: 'post', 175 | text: 'Cat', 176 | }); 177 | 178 | await p(setTimeout)(100); 179 | 180 | const summaries = await pull( 181 | ssb.threads.hashtagSummary({ hashtags: ['animals', 'food'] }), 182 | pull.collectAsPromise(), 183 | ); 184 | 185 | t.equals(summaries.length, 2, 'two summaries'); 186 | const [s1, s2] = summaries; 187 | t.equals(s1.root.value.content.text, 'Pizza', '1st summary is Pizza'); 188 | t.equals(s2.root.value.content.text, 'Dog', '2nd summary is Dog'); 189 | 190 | await p(ssb.close)(); 191 | }); 192 | 193 | test('threads.hashtagSummary opts.hashtags not an array', (t) => { 194 | const ssb = Testbot({ keys: lucyKeys }); 195 | 196 | pull( 197 | ssb.threads.hashtagSummary({ hashtags: 1234 }), 198 | pull.collect((err, ary) => { 199 | t.ok(err, 'throws error'); 200 | t.equals(ary.length, 0, 'no summaries'); 201 | ssb.close(t.end); 202 | }), 203 | ); 204 | }); 205 | 206 | test('threads.hashtagSummary opts.hashtags invalid array', (t) => { 207 | const ssb = Testbot({ keys: lucyKeys }); 208 | 209 | pull( 210 | ssb.threads.hashtagSummary({ hashtags: [10, 'twenty', 'thirty'] }), 211 | pull.collect((err, ary) => { 212 | t.ok(err, 'throws error'); 213 | t.equals(ary.length, 0, 'no summaries'); 214 | ssb.close(t.end); 215 | }), 216 | ); 217 | }); 218 | -------------------------------------------------------------------------------- /test/hashtagUpdates.test.js: -------------------------------------------------------------------------------- 1 | const test = require('tape'); 2 | const pull = require('pull-stream'); 3 | const ssbKeys = require('ssb-keys'); 4 | const p = require('util').promisify; 5 | const Testbot = require('./testbot'); 6 | 7 | const lucyKeys = ssbKeys.generate(null, 'lucy'); 8 | 9 | test('threads.hashtagUpdates notifies of new thread', async (t) => { 10 | const ssb = Testbot({ keys: lucyKeys }); 11 | 12 | let actual = []; 13 | let liveDrainer; 14 | pull( 15 | ssb.threads.hashtagUpdates({ hashtags: ['food', 'animals'] }), 16 | (liveDrainer = pull.drain((msgId) => { 17 | actual.push(msgId); 18 | })), 19 | ); 20 | t.pass('listening to live stream'); 21 | 22 | const animalMsg = await p(ssb.db.publish)({ 23 | type: 'post', 24 | text: 'Dog', 25 | mentions: [{ link: '#animals' }], 26 | }); 27 | t.pass('published animalMsg'); 28 | await p(setTimeout)(800); 29 | 30 | await p(ssb.db.publish)({ 31 | type: 'post', 32 | text: 'Ferrari', 33 | channel: 'cars', 34 | }); 35 | t.pass('published carMsg'); 36 | await p(setTimeout)(800); 37 | 38 | const foodMsg = await p(ssb.db.publish)({ 39 | type: 'post', 40 | text: 'Pizza', 41 | mentions: [{ link: '#food' }], 42 | }); 43 | t.pass('published foodMsg'); 44 | await p(setTimeout)(800); 45 | 46 | t.equals(actual.length, 2); 47 | const [msgId1, msgId2] = actual; 48 | t.equals(msgId1, animalMsg.key); 49 | t.equals(msgId2, foodMsg.key); 50 | 51 | liveDrainer.abort(); 52 | 53 | await p(ssb.close)(); 54 | }); 55 | -------------------------------------------------------------------------------- /test/hashtagsMatching.test.js: -------------------------------------------------------------------------------- 1 | const test = require('tape'); 2 | const pull = require('pull-stream'); 3 | const ssbKeys = require('ssb-keys'); 4 | const pullAsync = require('pull-async'); 5 | const Testbot = require('./testbot'); 6 | const wait = require('./wait'); 7 | 8 | const andrewKeys = ssbKeys.generate(null, 'andrew'); 9 | 10 | test('threads.hashtagsMatching handles invalid opts.query', (t) => { 11 | const ssb = Testbot({ keys: andrewKeys }); 12 | 13 | ssb.threads.hashtagsMatching({ query: '' }, (err, matches) => { 14 | t.ok(err, 'throws error'); 15 | ssb.close(t.end); 16 | }); 17 | }); 18 | 19 | test('threads.hashtagsMatching handles invalid opts.limit', (t) => { 20 | const ssb = Testbot({ keys: andrewKeys }); 21 | 22 | ssb.threads.hashtagsMatching({ query: 'a', limit: -1 }, (err, matches) => { 23 | t.ok(err, 'throws error'); 24 | ssb.close(t.end); 25 | }); 26 | }); 27 | 28 | test('threads.hashtagsMatching respects opts.query', (t) => { 29 | const ssb = Testbot({ keys: andrewKeys }); 30 | 31 | pull( 32 | pullAsync((cb) => { 33 | ssb.db.create( 34 | { 35 | keys: andrewKeys, 36 | content: { 37 | type: 'post', 38 | text: 'My favorite animals (thread 1)', 39 | channel: 'animals', 40 | }, 41 | }, 42 | wait(cb, 100), 43 | ); 44 | }), 45 | pull.asyncMap((_, cb) => { 46 | ssb.db.create( 47 | { 48 | keys: andrewKeys, 49 | content: { 50 | type: 'post', 51 | text: 'My favorite animal is the beaver (thread 2)', 52 | channel: 'animals', 53 | }, 54 | }, 55 | wait(cb, 100), 56 | ); 57 | }), 58 | pull.drain(null, (err) => { 59 | t.error(err); 60 | 61 | ssb.threads.hashtagsMatching({ query: 'a' }, (err2, matches) => { 62 | t.error(err2); 63 | t.deepEquals(matches, [['animals', 2]]); 64 | ssb.close(t.end); 65 | }); 66 | }), 67 | ); 68 | }); 69 | 70 | test('threads.hashtagsMatching returns results sorted by number of occurrences (highest to lowest)', (t) => { 71 | const ssb = Testbot({ keys: andrewKeys }); 72 | 73 | pull( 74 | pullAsync((cb) => { 75 | ssb.db.create( 76 | { 77 | keys: andrewKeys, 78 | content: { 79 | type: 'post', 80 | text: 'My favorite animals (thread 1)', 81 | channel: 'animals', 82 | }, 83 | }, 84 | wait(cb, 100), 85 | ); 86 | }), 87 | pull.asyncMap((rootMsg, cb) => { 88 | ssb.db.create( 89 | { 90 | keys: andrewKeys, 91 | content: { 92 | type: 'post', 93 | text: 'My favorite animal is the #antelope (reply to thread 1)', 94 | mentions: [{ link: '#antelope' }], 95 | root: rootMsg, 96 | }, 97 | }, 98 | wait(cb, 100), 99 | ); 100 | }), 101 | pull.asyncMap((_, cb) => { 102 | ssb.db.create( 103 | { 104 | keys: andrewKeys, 105 | content: { 106 | type: 'post', 107 | text: 'My favorite animal is the beaver (thread 2)', 108 | channel: 'animals', 109 | }, 110 | }, 111 | wait(cb, 100), 112 | ); 113 | }), 114 | pull.drain(null, (err) => { 115 | t.error(err); 116 | 117 | ssb.threads.hashtagsMatching({ query: 'a' }, (err2, matches) => { 118 | t.error(err2); 119 | t.deepEquals(matches, [ 120 | ['animals', 2], 121 | ['antelope', 1], 122 | ]); 123 | ssb.close(t.end); 124 | }); 125 | }), 126 | ); 127 | }); 128 | 129 | test('threads.hashtagsMatching filters out lexigraphically close (but non-matching) hashtags', (t) => { 130 | const ssb = Testbot({ keys: andrewKeys }); 131 | 132 | pull( 133 | pullAsync((cb) => { 134 | ssb.db.create( 135 | { 136 | keys: andrewKeys, 137 | content: { 138 | type: 'post', 139 | text: 'p2p', 140 | channel: 'p2p', 141 | }, 142 | }, 143 | wait(cb, 100), 144 | ); 145 | }), 146 | pull.asyncMap((_, cb) => { 147 | ssb.db.create( 148 | { 149 | keys: andrewKeys, 150 | content: { 151 | type: 'post', 152 | text: '#p4p', 153 | mentions: [{ link: '#p4p' }], 154 | }, 155 | }, 156 | wait(cb, 100), 157 | ); 158 | }), 159 | pull.drain(null, (err) => { 160 | t.error(err); 161 | 162 | ssb.threads.hashtagsMatching({ query: 'p4' }, (err2, matches) => { 163 | t.error(err2); 164 | t.deepEquals(matches, [['p4p', 1]]); 165 | ssb.close(t.end); 166 | }); 167 | }), 168 | ); 169 | }); 170 | 171 | test('threads.hashtagsMatching is case insensitive', (t) => { 172 | const ssb = Testbot({ keys: andrewKeys }); 173 | 174 | pull( 175 | pullAsync((cb) => { 176 | ssb.db.create( 177 | { 178 | keys: andrewKeys, 179 | content: { 180 | type: 'post', 181 | text: 'p2p', 182 | channel: 'p2p', 183 | }, 184 | }, 185 | wait(cb, 100), 186 | ); 187 | }), 188 | pull.asyncMap((_, cb) => { 189 | ssb.db.create( 190 | { 191 | keys: andrewKeys, 192 | content: { 193 | type: 'post', 194 | text: '#p4p', 195 | mentions: [{ link: '#p4p' }], 196 | }, 197 | }, 198 | wait(cb, 100), 199 | ); 200 | }), 201 | pull.drain(null, (err) => { 202 | t.error(err); 203 | 204 | ssb.threads.hashtagsMatching({ query: 'P' }, (err2, matches) => { 205 | t.error(err2); 206 | t.deepEquals(matches, [['p2p', 1], ['p4p', 1]]); 207 | ssb.close(t.end); 208 | }); 209 | }), 210 | ); 211 | }); 212 | 213 | test('threads.hashtagsMatching respects default limit of 10 results', (t) => { 214 | const ssb = Testbot({ keys: andrewKeys }); 215 | 216 | function createAnimalsPost(n) { 217 | function dbCreate(cb) { 218 | return ssb.db.create( 219 | { 220 | keys: andrewKeys, 221 | content: { 222 | type: 'post', 223 | text: `${n} #animals`, 224 | mentions: [{ link: `#animals${n}` }], 225 | }, 226 | }, 227 | wait(cb, 100), 228 | ); 229 | } 230 | 231 | return n === 1 232 | ? pullAsync(dbCreate) 233 | : pull.asyncMap((_, cb) => dbCreate(cb)); 234 | } 235 | 236 | pull( 237 | createAnimalsPost(1), 238 | createAnimalsPost(2), 239 | createAnimalsPost(3), 240 | createAnimalsPost(4), 241 | createAnimalsPost(5), 242 | createAnimalsPost(6), 243 | createAnimalsPost(7), 244 | createAnimalsPost(8), 245 | createAnimalsPost(9), 246 | createAnimalsPost(10), 247 | createAnimalsPost(11), 248 | pull.drain(null, (err) => { 249 | t.error(err); 250 | 251 | ssb.threads.hashtagsMatching({ query: 'a' }, (err2, matches) => { 252 | t.error(err2); 253 | t.equals(matches.length, 10); 254 | ssb.close(t.end); 255 | }); 256 | }), 257 | ); 258 | }); 259 | 260 | test('threads.hashtagsMatching respects opts.limit when provided', (t) => { 261 | const ssb = Testbot({ keys: andrewKeys }); 262 | 263 | pull( 264 | pullAsync((cb) => { 265 | ssb.db.create( 266 | { 267 | keys: andrewKeys, 268 | content: { 269 | type: 'post', 270 | text: 'My favorite #animals (thread 1)', 271 | channel: 'animals', 272 | }, 273 | }, 274 | wait(cb, 100), 275 | ); 276 | }), 277 | pull.asyncMap((_, cb) => { 278 | ssb.db.create( 279 | { 280 | keys: andrewKeys, 281 | content: { 282 | type: 'post', 283 | text: 'My favorite animal is the #antelope (thread 2)', 284 | mentions: [{ link: '#antelope' }], 285 | }, 286 | }, 287 | wait(cb, 100), 288 | ); 289 | }), 290 | pull.asyncMap((rootMsg, cb) => { 291 | ssb.db.create( 292 | { 293 | keys: andrewKeys, 294 | content: { 295 | type: 'post', 296 | text: 'I also like the #antelope (reply to thread 2)', 297 | mentions: [{ link: '#antelope' }], 298 | root: rootMsg.key, 299 | }, 300 | }, 301 | wait(cb, 100), 302 | ); 303 | }), 304 | pull.drain(null, (err) => { 305 | t.error(err); 306 | 307 | ssb.threads.hashtagsMatching( 308 | { query: 'a', limit: 1 }, 309 | (err2, matches) => { 310 | t.error(err2); 311 | t.deepEquals(matches, [['antelope', 2]]); 312 | ssb.close(t.end); 313 | }, 314 | ); 315 | }), 316 | ); 317 | }); 318 | -------------------------------------------------------------------------------- /test/private.test.js: -------------------------------------------------------------------------------- 1 | const test = require('tape'); 2 | const pull = require('pull-stream'); 3 | const ssbKeys = require('ssb-keys'); 4 | const pullAsync = require('pull-async'); 5 | const Testbot = require('./testbot'); 6 | 7 | const lucyKeys = ssbKeys.generate(null, 'lucy'); 8 | 9 | test('threads.private gives a simple well-formed thread', (t) => { 10 | const ssb = Testbot({ 11 | keys: lucyKeys, 12 | }); 13 | 14 | let rootKey; 15 | pull( 16 | pullAsync((cb) => { 17 | ssb.db.create( 18 | { 19 | keys: lucyKeys, 20 | content: { type: 'post', text: 'Secret thread root' }, 21 | recps: [ssb.id], 22 | encryptionFormat: 'box', 23 | }, 24 | cb, 25 | ); 26 | }), 27 | pull.asyncMap((rootMsg, cb) => { 28 | rootKey = rootMsg.key; 29 | ssb.db.create( 30 | { 31 | keys: lucyKeys, 32 | content: { 33 | type: 'post', 34 | text: 'Second secret message', 35 | root: rootKey, 36 | }, 37 | recps: [ssb.id], 38 | encryptionFormat: 'box', 39 | }, 40 | cb, 41 | ); 42 | }), 43 | pull.asyncMap((_prevMsg, cb) => { 44 | ssb.db.create( 45 | { 46 | keys: lucyKeys, 47 | content: { 48 | type: 'post', 49 | text: 'Third secret message', 50 | root: rootKey, 51 | }, 52 | recps: [ssb.id], 53 | encryptionFormat: 'box', 54 | }, 55 | cb, 56 | ); 57 | }), 58 | pull.map(() => ssb.threads.private({})), 59 | pull.flatten(), 60 | 61 | pull.collect((err, sthreads) => { 62 | t.error(err); 63 | t.equals(sthreads.length, 1, 'only one secret thread'); 64 | const thread = sthreads[0]; 65 | t.equals(thread.full, true, 'thread comes back full'); 66 | t.equals(thread.messages.length, 3, 'thread has 3 messages'); 67 | 68 | const msgs = thread.messages; 69 | const rootKey = msgs[0].key; 70 | t.equals(msgs[0].value.content.root, undefined, '1st message is root'); 71 | t.equals(msgs[0].value.content.text, 'Secret thread root'); 72 | 73 | t.equals(msgs[1].value.content.root, rootKey, '2nd message is not root'); 74 | t.equals(msgs[1].value.content.text, 'Second secret message'); 75 | 76 | t.equals(msgs[2].value.content.root, rootKey, '3rd message is not root'); 77 | t.equals(msgs[2].value.content.text, 'Third secret message'); 78 | 79 | pull( 80 | ssb.threads.public({}), 81 | pull.collect((err, threads) => { 82 | t.error(err); 83 | t.equals(threads.length, 0, 'there are no public threads'); 84 | ssb.close(t.end); 85 | }), 86 | ); 87 | }), 88 | ); 89 | }); 90 | -------------------------------------------------------------------------------- /test/privateUpdates.test.js: -------------------------------------------------------------------------------- 1 | const test = require('tape'); 2 | const pull = require('pull-stream'); 3 | const ssbKeys = require('ssb-keys'); 4 | const pullAsync = require('pull-async'); 5 | const Testbot = require('./testbot'); 6 | const wait = require('./wait'); 7 | 8 | const lucyKeys = ssbKeys.generate(null, 'lucy'); 9 | const maryKeys = ssbKeys.generate(null, 'mary'); 10 | 11 | test('threads.privateUpdates notifies of new thread or new msg', (t) => { 12 | const ssb = Testbot({ keys: lucyKeys }); 13 | 14 | let updates = 0; 15 | let liveDrainer; 16 | pull( 17 | ssb.threads.privateUpdates({}), 18 | (liveDrainer = pull.drain(() => { 19 | updates++; 20 | })), 21 | ); 22 | 23 | let msg1, msg3; 24 | pull( 25 | pullAsync((cb) => { 26 | ssb.db.create( 27 | { 28 | keys: lucyKeys, 29 | content: { type: 'post', text: 'A: root' }, 30 | recps: [lucyKeys.id, maryKeys.id], 31 | encryptionFormat: 'box', 32 | }, 33 | wait(cb, 800), 34 | ); 35 | }), 36 | pull.asyncMap((msg, cb) => { 37 | msg1 = msg; 38 | t.equals(updates, 0); 39 | ssb.db.create( 40 | { 41 | keys: maryKeys, 42 | content: { type: 'post', text: 'A: 2nd', root: msg1.key }, 43 | recps: [lucyKeys.id, maryKeys.id], 44 | encryptionFormat: 'box', 45 | }, 46 | wait(cb, 800), 47 | ); 48 | }), 49 | pull.asyncMap((_, cb) => { 50 | t.equals(updates, 1); 51 | ssb.db.create( 52 | { 53 | keys: maryKeys, 54 | content: { type: 'post', text: 'B: root' }, 55 | recps: [lucyKeys.id, maryKeys.id], 56 | encryptionFormat: 'box', 57 | }, 58 | wait(cb, 800), 59 | ); 60 | }), 61 | pull.asyncMap((msg, cb) => { 62 | msg3 = msg; 63 | t.equals(updates, 2); 64 | ssb.db.create( 65 | { 66 | keys: lucyKeys, 67 | content: { type: 'post', text: 'B: 2nd', root: msg3.key }, 68 | recps: [lucyKeys.id, maryKeys.id], 69 | }, 70 | wait(cb), 71 | ); 72 | }), 73 | 74 | pull.drain(() => { 75 | t.equals(updates, 2); 76 | liveDrainer.abort(); 77 | ssb.close(t.end); 78 | }), 79 | ); 80 | }); 81 | 82 | test('threads.privateUpdates respects includeSelf', (t) => { 83 | const ssb = Testbot({ keys: lucyKeys }); 84 | 85 | let updates = 0; 86 | let liveDrainer; 87 | pull( 88 | ssb.threads.privateUpdates({ includeSelf: true }), 89 | (liveDrainer = pull.drain(() => { 90 | updates++; 91 | })), 92 | ); 93 | 94 | let msg1, msg3; 95 | pull( 96 | pullAsync((cb) => { 97 | ssb.db.create( 98 | { 99 | keys: lucyKeys, 100 | content: { type: 'post', text: 'A: root' }, 101 | recps: [lucyKeys.id, maryKeys.id], 102 | encryptionFormat: 'box', 103 | }, 104 | wait(cb, 800), 105 | ); 106 | }), 107 | pull.asyncMap((msg, cb) => { 108 | msg1 = msg; 109 | t.equals(updates, 1); 110 | ssb.db.create( 111 | { 112 | keys: maryKeys, 113 | content: { type: 'post', text: 'A: 2nd', root: msg1.key }, 114 | recps: [lucyKeys.id, maryKeys.id], 115 | encryptionFormat: 'box', 116 | }, 117 | wait(cb, 800), 118 | ); 119 | }), 120 | pull.asyncMap((_, cb) => { 121 | t.equals(updates, 2); 122 | ssb.db.create( 123 | { 124 | keys: maryKeys, 125 | content: { type: 'post', text: 'B: root' }, 126 | recps: [lucyKeys.id, maryKeys.id], 127 | encryptionFormat: 'box', 128 | }, 129 | wait(cb, 800), 130 | ); 131 | }), 132 | pull.asyncMap((msg, cb) => { 133 | msg3 = msg; 134 | t.equals(updates, 3); 135 | ssb.db.create( 136 | { 137 | keys: lucyKeys, 138 | content: { type: 'post', text: 'B: 2nd', root: msg3.key }, 139 | recps: [lucyKeys.id, maryKeys.id], 140 | encryptionFormat: 'box', 141 | }, 142 | wait(cb, 800), 143 | ); 144 | }), 145 | 146 | pull.drain(() => { 147 | t.equals(updates, 4); 148 | liveDrainer.abort(); 149 | ssb.close(t.end); 150 | }), 151 | ); 152 | }); 153 | -------------------------------------------------------------------------------- /test/profile.test.js: -------------------------------------------------------------------------------- 1 | const test = require('tape'); 2 | const pull = require('pull-stream'); 3 | const ssbKeys = require('ssb-keys'); 4 | const pullAsync = require('pull-async'); 5 | const Testbot = require('./testbot'); 6 | const wait = require('./wait'); 7 | 8 | const lucyKeys = ssbKeys.generate(null, 'lucy'); 9 | const maryKeys = ssbKeys.generate(null, 'mary'); 10 | 11 | test('threads.profile gives threads for lucy not mary', (t) => { 12 | const ssb = Testbot({ keys: lucyKeys }); 13 | 14 | let msg1; 15 | pull( 16 | pullAsync((cb) => { 17 | ssb.db.create( 18 | { 19 | keys: lucyKeys, 20 | content: { type: 'post', text: 'Root from lucy' }, 21 | }, 22 | wait(cb), 23 | ); 24 | }), 25 | pull.asyncMap((msg, cb) => { 26 | msg1 = msg; 27 | ssb.db.create( 28 | { 29 | keys: maryKeys, 30 | content: { type: 'post', text: 'Root from mary' }, 31 | }, 32 | wait(cb), 33 | ); 34 | }), 35 | pull.asyncMap((_, cb) => { 36 | ssb.db.create( 37 | { 38 | keys: lucyKeys, 39 | content: { type: 'post', text: 'Reply from lucy', root: msg1.key }, 40 | }, 41 | wait(cb), 42 | ); 43 | }), 44 | pull.map(() => ssb.threads.profile({ id: lucyKeys.id })), 45 | pull.flatten(), 46 | 47 | pull.collect((err, threads) => { 48 | t.error(err); 49 | t.equals(threads.length, 1, 'only one thread'); 50 | const thread = threads[0]; 51 | t.equals(thread.full, true, 'thread comes back full'); 52 | t.equals(thread.messages.length, 2, 'thread has 2 messages'); 53 | 54 | const msgs = thread.messages; 55 | const rootKey = msgs[0].key; 56 | t.equals(msgs[0].value.content.root, undefined, '1st message is root'); 57 | t.equals(msgs[0].value.content.text, 'Root from lucy'); 58 | 59 | t.equals(msgs[1].value.content.root, rootKey, '2nd message is not root'); 60 | t.equals(msgs[1].value.content.text, 'Reply from lucy'); 61 | 62 | ssb.close(t.end); 63 | }), 64 | ); 65 | }); 66 | -------------------------------------------------------------------------------- /test/profileSummary.test.js: -------------------------------------------------------------------------------- 1 | const test = require('tape'); 2 | const pull = require('pull-stream'); 3 | const ssbKeys = require('ssb-keys'); 4 | const pullAsync = require('pull-async'); 5 | const Testbot = require('./testbot'); 6 | const wait = require('./wait'); 7 | 8 | const lucyKeys = ssbKeys.generate(null, 'lucy'); 9 | const maryKeys = ssbKeys.generate(null, 'mary'); 10 | 11 | test('threads.profileSummary gives threads for lucy not mary', (t) => { 12 | const ssb = Testbot({ keys: lucyKeys }); 13 | 14 | let msg1; 15 | pull( 16 | pullAsync((cb) => { 17 | ssb.db.create( 18 | { keys: lucyKeys, content: { type: 'post', text: 'Root from lucy' } }, 19 | wait(cb), 20 | ); 21 | }), 22 | pull.asyncMap((msg, cb) => { 23 | msg1 = msg; 24 | ssb.db.create( 25 | { 26 | keys: maryKeys, 27 | content: { type: 'post', text: 'Root from mary' }, 28 | }, 29 | wait(cb), 30 | ); 31 | }), 32 | pull.asyncMap((_, cb) => { 33 | ssb.db.create( 34 | { 35 | keys: lucyKeys, 36 | content: { type: 'post', text: 'Reply from lucy', root: msg1.key }, 37 | }, 38 | wait(cb), 39 | ); 40 | }), 41 | pull.map(() => ssb.threads.profileSummary({ id: lucyKeys.id })), 42 | pull.flatten(), 43 | 44 | pull.collect((err, summaries) => { 45 | t.error(err); 46 | t.equals(summaries.length, 1, 'only one summary'); 47 | const summary = summaries[0]; 48 | t.equals(summary.replyCount, 1, 'summary counts 1 reply'); 49 | t.equals( 50 | summary.root.value.content.root, 51 | undefined, 52 | 'root message is root', 53 | ); 54 | t.equals(summary.root.value.content.text, 'Root from lucy'); 55 | 56 | ssb.close(t.end); 57 | }), 58 | ); 59 | }); 60 | 61 | test('threads.profileSummary gives summary with correct timestamp', (t) => { 62 | const ssb = Testbot({ keys: lucyKeys }); 63 | 64 | pull( 65 | pullAsync((cb) => { 66 | ssb.db.publish({ type: 'post', text: 'Root from lucy' }, wait(cb)); 67 | }), 68 | pull.asyncMap((rootMsg, cb) => { 69 | ssb.db.publish( 70 | { type: 'post', text: 'Reply from lucy', root: rootMsg.key }, 71 | wait(cb), 72 | ); 73 | }), 74 | pull.map(() => ssb.threads.profileSummary({ id: lucyKeys.id })), 75 | pull.flatten(), 76 | 77 | pull.collect((err, summaries) => { 78 | t.error(err); 79 | t.equals(summaries.length, 1, 'only one summary'); 80 | const summary = summaries[0]; 81 | t.equals(summary.replyCount, 1, 'summary counts 1 reply'); 82 | t.true( 83 | summary.timestamp > summary.root.timestamp, 84 | 'summary timestamp greater than root timestamp', 85 | ); 86 | t.equals( 87 | summary.root.value.content.root, 88 | undefined, 89 | 'root message is root', 90 | ); 91 | t.equals(summary.root.value.content.text, 'Root from lucy'); 92 | 93 | ssb.close(t.end); 94 | }), 95 | ); 96 | }); 97 | -------------------------------------------------------------------------------- /test/public.test.js: -------------------------------------------------------------------------------- 1 | const test = require('tape'); 2 | const pull = require('pull-stream'); 3 | const ssbKeys = require('ssb-keys'); 4 | const pullAsync = require('pull-async'); 5 | const cat = require('pull-cat'); 6 | const Testbot = require('./testbot'); 7 | const wait = require('./wait'); 8 | 9 | const lucyKeys = ssbKeys.generate(null, 'lucy'); 10 | const maryKeys = ssbKeys.generate(null, 'mary'); 11 | const aliceKeys = ssbKeys.generate(null, 'alice'); 12 | 13 | test('threads.public gives a simple well-formed thread', (t) => { 14 | const ssb = Testbot({ keys: lucyKeys }); 15 | 16 | let msg1; 17 | pull( 18 | pullAsync((cb) => { 19 | ssb.db.create( 20 | { 21 | keys: lucyKeys, 22 | content: { type: 'post', text: 'Thread root' }, 23 | }, 24 | cb, 25 | ); 26 | }), 27 | pull.asyncMap((msg, cb) => { 28 | msg1 = msg; 29 | ssb.db.create( 30 | { 31 | keys: lucyKeys, 32 | content: { type: 'post', text: 'Second message', root: msg1.key }, 33 | }, 34 | cb, 35 | ); 36 | }), 37 | pull.asyncMap((msg, cb) => { 38 | ssb.db.create( 39 | { 40 | keys: lucyKeys, 41 | content: { type: 'post', text: 'Third message', root: msg1.key }, 42 | }, 43 | cb, 44 | ); 45 | }), 46 | pull.map(() => ssb.threads.public({})), 47 | pull.flatten(), 48 | 49 | pull.collect((err, threads) => { 50 | t.error(err); 51 | t.equals(threads.length, 1, 'only one thread'); 52 | const thread = threads[0]; 53 | t.equals(thread.full, true, 'thread comes back full'); 54 | t.equals(thread.messages.length, 3, 'thread has 3 messages'); 55 | 56 | const msgs = thread.messages; 57 | const rootKey = msgs[0].key; 58 | t.equals(msgs[0].value.content.root, undefined, '1st message is root'); 59 | t.equals(msgs[0].value.content.text, 'Thread root'); 60 | 61 | t.equals(msgs[1].value.content.root, rootKey, '2nd message is not root'); 62 | t.equals(msgs[1].value.content.text, 'Second message'); 63 | 64 | t.equals(msgs[2].value.content.root, rootKey, '3rd message is not root'); 65 | t.equals(msgs[2].value.content.text, 'Third message'); 66 | ssb.close(t.end); 67 | }), 68 | ); 69 | }); 70 | 71 | test('threads.public can be called twice consecutively (to use cache)', (t) => { 72 | const ssb = Testbot({ keys: lucyKeys }); 73 | 74 | pull( 75 | pullAsync((cb) => { 76 | ssb.db.publish({ type: 'post', text: 'Thread root' }, cb); 77 | }), 78 | pull.asyncMap((rootMsg, cb) => { 79 | ssb.db.publish( 80 | { type: 'post', text: 'Second message', root: rootMsg.key }, 81 | cb, 82 | ); 83 | }), 84 | pull.asyncMap((prevMsg, cb) => { 85 | const rootKey = prevMsg.value.content.root; 86 | ssb.db.publish( 87 | { type: 'post', text: 'Third message', root: rootKey }, 88 | cb, 89 | ); 90 | }), 91 | pull.map(() => cat([ssb.threads.public({}), ssb.threads.public({})])), 92 | pull.flatten(), 93 | 94 | pull.collect((err, threads) => { 95 | t.error(err); 96 | t.equals(threads.length, 2, 'two threads'); 97 | 98 | const thread1 = threads[0]; 99 | const msgs1 = thread1.messages; 100 | const root1 = msgs1[0].key; 101 | 102 | t.equals(thread1.full, true, 'thread 1 comes back full'); 103 | t.equals(thread1.messages.length, 3, 'thread 1 has 3 messages'); 104 | 105 | t.equals(msgs1[0].value.content.root, undefined, '1st message is root'); 106 | t.equals(msgs1[0].value.content.text, 'Thread root'); 107 | 108 | t.equals(msgs1[1].value.content.root, root1, '2nd message is not root'); 109 | t.equals(msgs1[1].value.content.text, 'Second message'); 110 | 111 | t.equals(msgs1[2].value.content.root, root1, '3rd message is not root'); 112 | t.equals(msgs1[2].value.content.text, 'Third message'); 113 | 114 | const thread2 = threads[1]; 115 | const msgs2 = thread2.messages; 116 | const root2 = msgs2[0].key; 117 | 118 | t.equals(thread2.full, true, 'thread 2 comes back full'); 119 | t.equals(thread2.messages.length, 3, 'thread 2 has 3 messages'); 120 | t.equals(root2, root1, 'same root as before'); 121 | 122 | t.equals(msgs2[0].value.content.root, undefined, '1st message is root'); 123 | t.equals(msgs2[0].value.content.text, 'Thread root'); 124 | 125 | t.equals(msgs2[1].value.content.root, root2, '2nd message is not root'); 126 | t.equals(msgs2[1].value.content.text, 'Second message'); 127 | 128 | t.equals(msgs2[2].value.content.root, root2, '3rd message is not root'); 129 | t.equals(msgs2[2].value.content.text, 'Third message'); 130 | ssb.close(t.end); 131 | }), 132 | ); 133 | }); 134 | 135 | test('threads.public does not show any private threads', (t) => { 136 | const ssb = Testbot({ 137 | keys: lucyKeys, 138 | }); 139 | 140 | pull( 141 | pullAsync((cb) => { 142 | ssb.db.create( 143 | { 144 | keys: lucyKeys, 145 | content: { type: 'post', text: 'Secret thread root' }, 146 | recps: [ssb.id], 147 | encryptionFormat: 'box', 148 | }, 149 | cb, 150 | ); 151 | }), 152 | pull.asyncMap((_, cb) => { 153 | ssb.db.publish({ type: 'post', text: 'Thread root' }, cb); 154 | }), 155 | pull.asyncMap((rootMsg, cb) => { 156 | ssb.db.publish( 157 | { type: 'post', text: 'Second message', root: rootMsg.key }, 158 | cb, 159 | ); 160 | }), 161 | pull.asyncMap((prevMsg, cb) => { 162 | const rootKey = prevMsg.value.content.root; 163 | ssb.db.publish( 164 | { type: 'post', text: 'Third message', root: rootKey }, 165 | cb, 166 | ); 167 | }), 168 | pull.map(() => ssb.threads.public({})), 169 | pull.flatten(), 170 | 171 | pull.collect((err, threads) => { 172 | t.error(err); 173 | t.equals(threads.length, 1, 'only one thread'); 174 | const thread = threads[0]; 175 | t.equals(thread.full, true, 'thread comes back full'); 176 | t.equals(thread.messages.length, 3, 'thread has 3 messages'); 177 | 178 | const msgs = thread.messages; 179 | const rootKey = msgs[0].key; 180 | t.equals(msgs[0].value.content.root, undefined, '1st message is root'); 181 | t.equals(msgs[0].value.content.text, 'Thread root'); 182 | 183 | t.equals(msgs[1].value.content.root, rootKey, '2nd message is not root'); 184 | t.equals(msgs[1].value.content.text, 'Second message'); 185 | 186 | t.equals(msgs[2].value.content.root, rootKey, '3rd message is not root'); 187 | t.equals(msgs[2].value.content.text, 'Third message'); 188 | ssb.close(t.end); 189 | }), 190 | ); 191 | }); 192 | 193 | test('threads.public respects threadMaxSize opt', (t) => { 194 | const ssb = Testbot({ 195 | keys: lucyKeys, 196 | }); 197 | 198 | pull( 199 | pullAsync((cb) => { 200 | ssb.db.publish({ type: 'post', text: 'Thread root' }, cb); 201 | }), 202 | pull.asyncMap((rootMsg, cb) => { 203 | ssb.db.publish( 204 | { type: 'post', text: 'Second message', root: rootMsg.key }, 205 | cb, 206 | ); 207 | }), 208 | pull.asyncMap((prevMsg, cb) => { 209 | const rootKey = prevMsg.value.content.root; 210 | ssb.db.publish( 211 | { type: 'post', text: 'Third message', root: rootKey }, 212 | cb, 213 | ); 214 | }), 215 | pull.map(() => ssb.threads.public({ threadMaxSize: 2 })), 216 | pull.flatten(), 217 | 218 | pull.collect((err, threads) => { 219 | t.error(err); 220 | t.equals(threads.length, 1, 'only one thread'); 221 | const thread = threads[0]; 222 | t.equals(thread.full, false, 'thread comes back NOT full'); 223 | t.equals(thread.messages.length, 2, 'thread has 2 messages'); 224 | 225 | const msgs = thread.messages; 226 | const rootKey = msgs[0].key; 227 | t.equals(msgs[0].value.content.root, undefined, '1st message is root'); 228 | t.equals(msgs[0].value.content.text, 'Thread root'); 229 | 230 | t.equals(msgs[1].value.content.root, rootKey, '2nd message is not root'); 231 | t.equals(msgs[1].value.content.text, 'Third message'); 232 | ssb.close(); 233 | t.end(); 234 | }), 235 | ); 236 | }); 237 | 238 | test('threads.public respects allowlist opt', (t) => { 239 | const ssb = Testbot({ 240 | keys: lucyKeys, 241 | }); 242 | 243 | pull( 244 | pullAsync((cb) => { 245 | ssb.db.publish({ type: 'post', text: 'Thread root' }, cb); 246 | }), 247 | pull.asyncMap((rootMsg, cb) => { 248 | ssb.db.publish( 249 | { 250 | type: 'vote', 251 | vote: { 252 | link: rootMsg.key, 253 | value: 1, 254 | expression: 'like', 255 | }, 256 | }, 257 | cb, 258 | ); 259 | }), 260 | pull.asyncMap((_prevMsg, cb) => { 261 | ssb.db.publish({ type: 'shout', text: 'AAAHHH' }, cb); 262 | }), 263 | pull.map(() => ssb.threads.public({ allowlist: ['shout'] })), 264 | pull.flatten(), 265 | 266 | pull.collect((err, threads) => { 267 | t.error(err); 268 | t.equals(threads.length, 1, 'only one thread'); 269 | const thread = threads[0]; 270 | t.equals(thread.full, true, 'thread comes back full'); 271 | t.equals(thread.messages.length, 1, 'thread has 1 messages'); 272 | const msgs = thread.messages; 273 | t.equals(msgs[0].value.content.root, undefined, '1st message is root'); 274 | t.equals(msgs[0].value.content.text, 'AAAHHH'); 275 | ssb.close(); 276 | t.end(); 277 | }), 278 | ); 279 | }); 280 | 281 | test('threads.public applies allowlist to roots too', (t) => { 282 | const ssb = Testbot({ 283 | keys: lucyKeys, 284 | }); 285 | 286 | pull( 287 | pullAsync((cb) => { 288 | ssb.db.publish({ type: 'post', text: 'Thread root' }, cb); 289 | }), 290 | pull.asyncMap((rootMsg, cb) => { 291 | ssb.db.publish({ type: 'shout', root: rootMsg, text: 'NOOOOOO' }, cb); 292 | }), 293 | pull.asyncMap((_prevMsg, cb) => { 294 | ssb.db.publish({ type: 'shout', text: 'AAAHHH' }, cb); 295 | }), 296 | pull.map(() => ssb.threads.public({ allowlist: ['shout'] })), 297 | pull.flatten(), 298 | 299 | pull.collect((err, threads) => { 300 | t.error(err); 301 | t.equals(threads.length, 1, 'only one thread'); 302 | const thread = threads[0]; 303 | t.equals(thread.full, true, 'thread comes back full'); 304 | t.equals(thread.messages.length, 1, 'thread has 1 messages'); 305 | const msgs = thread.messages; 306 | t.equals(msgs[0].value.content.root, undefined, '1st message is root'); 307 | t.equals(msgs[0].value.content.text, 'AAAHHH'); 308 | ssb.close(); 309 | t.end(); 310 | }), 311 | ); 312 | }); 313 | 314 | test('threads.public respects blocklist opt', (t) => { 315 | const ssb = Testbot({ 316 | keys: lucyKeys, 317 | }); 318 | 319 | pull( 320 | pullAsync((cb) => { 321 | ssb.db.publish({ type: 'post', text: 'Thread root' }, cb); 322 | }), 323 | pull.asyncMap((rootMsg, cb) => { 324 | ssb.db.publish( 325 | { 326 | type: 'vote', 327 | vote: { 328 | link: rootMsg.key, 329 | value: 1, 330 | expression: 'like', 331 | }, 332 | }, 333 | cb, 334 | ); 335 | }), 336 | pull.asyncMap((_prevMsg, cb) => { 337 | ssb.db.publish({ type: 'shout', text: 'AAAHHH' }, cb); 338 | }), 339 | pull.map(() => ssb.threads.public({ blocklist: ['shout', 'vote'] })), 340 | pull.flatten(), 341 | 342 | pull.collect((err, threads) => { 343 | t.error(err); 344 | t.equals(threads.length, 1, 'only one thread'); 345 | const thread = threads[0]; 346 | t.equals(thread.full, true, 'thread comes back full'); 347 | t.equals(thread.messages.length, 1, 'thread has 1 messages'); 348 | const msgs = thread.messages; 349 | t.equals(msgs[0].value.content.root, undefined, '1st message is root'); 350 | t.equals(msgs[0].value.content.text, 'Thread root'); 351 | ssb.close(); 352 | t.end(); 353 | }), 354 | ); 355 | }); 356 | 357 | test('threads.public gives multiple threads', (t) => { 358 | const ssb = Testbot({ 359 | keys: lucyKeys, 360 | }); 361 | 362 | pull( 363 | pullAsync((cb) => { 364 | ssb.db.publish({ type: 'post', text: 'A: root' }, cb); 365 | }), 366 | pull.asyncMap((rootMsg, cb) => { 367 | ssb.db.publish({ type: 'post', text: 'A: 2nd', root: rootMsg.key }, cb); 368 | }), 369 | pull.asyncMap((prevMsg, cb) => { 370 | const rootKey = prevMsg.value.content.root; 371 | ssb.db.publish({ type: 'post', text: 'A: 3rd', root: rootKey }, cb); 372 | }), 373 | pull.asyncMap((_, cb) => { 374 | ssb.db.publish({ type: 'post', text: 'B: root' }, cb); 375 | }), 376 | pull.asyncMap((rootMsg, cb) => { 377 | ssb.db.publish({ type: 'post', text: 'B: 2nd', root: rootMsg.key }, cb); 378 | }), 379 | 380 | pull.map(() => ssb.threads.public({ reverse: true })), 381 | pull.flatten(), 382 | 383 | pull.collect((err, threads) => { 384 | t.error(err); 385 | t.equals(threads.length, 2, 'has two threads'); 386 | const [b, a] = threads; 387 | t.equals(b.full, true, '1st thread comes back full'); 388 | t.equals(b.messages.length, 2, '1st thread has 2 messages'); 389 | t.equals(b.messages[0].value.content.text, 'B: root', '1st thread is B'); 390 | t.equals(a.full, true, '2nd thread comes back full'); 391 | t.equals(a.messages.length, 3, '2nd thread has 3 messages'); 392 | t.equals(a.messages[0].value.content.text, 'A: root', '2nd thread is A'); 393 | ssb.close(); 394 | t.end(); 395 | }), 396 | ); 397 | }); 398 | 399 | test('threads.public sorts threads by recency', (t) => { 400 | const ssb = Testbot({ 401 | keys: lucyKeys, 402 | }); 403 | 404 | let rootAkey; 405 | pull( 406 | pullAsync((cb) => { 407 | ssb.db.publish({ type: 'post', text: 'A: root' }, wait(cb)); 408 | }), 409 | pull.asyncMap((rootMsg, cb) => { 410 | rootAkey = rootMsg.key; 411 | ssb.db.publish( 412 | { type: 'post', text: 'A: 2nd', root: rootMsg.key }, 413 | wait(cb, 800), 414 | ); 415 | }), 416 | pull.asyncMap((_, cb) => { 417 | ssb.db.publish({ type: 'post', text: 'B: root' }, wait(cb)); 418 | }), 419 | pull.asyncMap((rootMsg, cb) => { 420 | ssb.db.publish( 421 | { type: 'post', text: 'B: 2nd', root: rootMsg.key }, 422 | wait(cb), 423 | ); 424 | }), 425 | pull.asyncMap((_, cb) => { 426 | ssb.db.publish( 427 | { type: 'post', text: 'A: 3rd', root: rootAkey }, 428 | wait(cb, 800), 429 | ); 430 | }), 431 | 432 | pull.map(() => ssb.threads.public({ reverse: true })), 433 | pull.flatten(), 434 | 435 | pull.collect((err, threads) => { 436 | t.error(err); 437 | t.equals(threads.length, 2, 'has two threads'); 438 | const [a, b] = threads; 439 | t.equals(a.full, true, '1st thread comes back full'); 440 | t.equals(a.messages.length, 3, '1st thread has 3 messages'); 441 | t.equals(a.messages[0].value.content.text, 'A: root', '1st thread is A'); 442 | t.equals(b.full, true, '2nd thread comes back full'); 443 | t.equals(b.messages.length, 2, '2nd thread has 2 messages'); 444 | t.equals(b.messages[0].value.content.text, 'B: root', '2nd thread is B'); 445 | ssb.close(t.end); 446 | }), 447 | ); 448 | }); 449 | 450 | test('threads.public ignores threads where root msg is missing', (t) => { 451 | const ssb = Testbot({ 452 | keys: lucyKeys, 453 | }); 454 | 455 | let rootAkey; 456 | pull( 457 | pullAsync((cb) => { 458 | ssb.db.publish({ type: 'post', text: 'A: root' }, wait(cb)); 459 | }), 460 | pull.asyncMap((rootMsg, cb) => { 461 | rootAkey = rootMsg.key; 462 | ssb.db.publish( 463 | { type: 'post', text: 'A: 2nd', root: rootMsg.key }, 464 | wait(cb, 800), 465 | ); 466 | }), 467 | pull.asyncMap((_, cb) => { 468 | ssb.db.publish( 469 | { type: 'post', text: 'B: 2nd', root: rootAkey.toLowerCase() }, 470 | wait(cb, 800), 471 | ); 472 | }), 473 | pull.asyncMap((_, cb) => { 474 | ssb.db.publish( 475 | { type: 'post', text: 'A: 3rd', root: rootAkey }, 476 | wait(cb, 800), 477 | ); 478 | }), 479 | 480 | pull.map(() => ssb.threads.public({ reverse: true })), 481 | pull.flatten(), 482 | 483 | pull.collect((err, threads) => { 484 | t.error(err); 485 | t.equals(threads.length, 1, 'has one thread'); 486 | const [a] = threads; 487 | t.equals(a.full, true, '1st thread comes back full'); 488 | t.equals(a.messages.length, 3, '1st thread has 3 messages'); 489 | t.equals(a.messages[0].value.content.text, 'A: root', '1st thread is A'); 490 | ssb.close(t.end); 491 | }), 492 | ); 493 | }); 494 | 495 | test('threads.public respects following opt', (t) => { 496 | const ssb = Testbot({ keys: lucyKeys }); 497 | 498 | pull( 499 | pullAsync((cb) => { 500 | ssb.db.publish( 501 | { type: 'contact', contact: maryKeys.id, following: true }, 502 | wait(cb, 800), 503 | ); 504 | }), 505 | pull.asyncMap((_, cb) => { 506 | ssb.db.create( 507 | { 508 | keys: maryKeys, 509 | content: { type: 'post', text: 'Root post from Mary' }, 510 | }, 511 | wait(cb, 800), 512 | ); 513 | }), 514 | pull.asyncMap((maryRoot, cb) => { 515 | ssb.db.create( 516 | { 517 | keys: aliceKeys, 518 | content: { 519 | type: 'post', 520 | text: 'Alice reply to Mary root', 521 | root: maryRoot.key, 522 | }, 523 | }, 524 | wait(cb, 800), 525 | ); 526 | }), 527 | pull.asyncMap((_, cb) => { 528 | ssb.db.create( 529 | { 530 | keys: aliceKeys, 531 | content: { type: 'post', text: 'Root post from Alice' }, 532 | }, 533 | cb, 534 | ); 535 | }), 536 | pull.map(() => ssb.threads.public({ following: true })), 537 | pull.flatten(), 538 | 539 | pull.collect((err, threads) => { 540 | t.error(err); 541 | t.equals(threads.length, 2); 542 | 543 | const onlyFollowingThreads = threads.every((t) => { 544 | const threadRootMsg = t.messages.find( 545 | (m) => m.value.content.root === undefined, 546 | ); 547 | return threadRootMsg.key !== aliceKeys.id; 548 | }); 549 | 550 | t.ok(onlyFollowingThreads, 'only threads created by following returned'); 551 | 552 | const maryThread = threads.find( 553 | (t) => 554 | !!t.messages.find( 555 | (m) => 556 | m.value.content.root === undefined && 557 | m.value.author === maryKeys.id, 558 | ), 559 | ); 560 | const maryThreadRoot = maryThread.messages.find( 561 | (m) => m.value.content.root === undefined, 562 | ); 563 | const maryThreadReplies = maryThread.messages.filter( 564 | (m) => !!m.value.content.root, 565 | ); 566 | 567 | t.equals(maryThreadReplies.length, 1); 568 | t.equals( 569 | maryThreadReplies[0].value.author, 570 | aliceKeys.id, 571 | 'Replies to following root from non-following are preserved', 572 | ); 573 | 574 | ssb.close(t.end); 575 | }), 576 | ); 577 | }); 578 | -------------------------------------------------------------------------------- /test/publicSummary.test.js: -------------------------------------------------------------------------------- 1 | const test = require('tape'); 2 | const pull = require('pull-stream'); 3 | const ssbKeys = require('ssb-keys'); 4 | const pullAsync = require('pull-async'); 5 | const Testbot = require('./testbot'); 6 | const wait = require('./wait'); 7 | 8 | const lucyKeys = ssbKeys.generate(null, 'lucy'); 9 | const maryKeys = ssbKeys.generate(null, 'mary'); 10 | const aliceKeys = ssbKeys.generate(null, 'alice'); 11 | 12 | test('threads.publicSummary gives a simple well-formed summary', (t) => { 13 | const ssb = Testbot({ keys: lucyKeys }); 14 | 15 | pull( 16 | pullAsync((cb) => { 17 | ssb.db.publish({ type: 'post', text: 'Thread root' }, cb); 18 | }), 19 | pull.asyncMap((rootMsg, cb) => { 20 | ssb.db.publish( 21 | { type: 'post', text: 'Second message', root: rootMsg.key }, 22 | wait(cb, 800), 23 | ); 24 | }), 25 | pull.asyncMap((prevMsg, cb) => { 26 | const rootKey = prevMsg.value.content.root; 27 | ssb.db.publish( 28 | { type: 'post', text: 'Third message', root: rootKey }, 29 | wait(cb, 800), 30 | ); 31 | }), 32 | pull.map(() => ssb.threads.publicSummary({})), 33 | pull.flatten(), 34 | 35 | pull.collect((err, summaries) => { 36 | t.error(err); 37 | t.equals(summaries.length, 1, 'only one summary'); 38 | const summary = summaries[0]; 39 | t.equals(summary.replyCount, 2, 'summary counts 2 replies'); 40 | t.true( 41 | summary.timestamp > summary.root.timestamp, 42 | 'summary timestamp greater than root timestamp', 43 | ); 44 | t.equals( 45 | summary.root.value.content.root, 46 | undefined, 47 | 'root message is root', 48 | ); 49 | t.equals(summary.root.value.content.text, 'Thread root'); 50 | 51 | ssb.close(t.end); 52 | }), 53 | ); 54 | }); 55 | 56 | test('threads.publicSummary can handle hundreds of threads', (t) => { 57 | const ssb = Testbot({ keys: lucyKeys }); 58 | 59 | const TOTAL = 1000; 60 | 61 | const roots = []; 62 | pull( 63 | pull.count(TOTAL), 64 | pull.asyncMap((x, cb) => { 65 | if (roots.length && Math.random() < 0.7) { 66 | const rootMsgKey = roots[Math.floor(Math.random() * roots.length)]; 67 | ssb.db.publish({ type: 'post', text: 'reply', root: rootMsgKey }, cb); 68 | } else { 69 | ssb.db.publish({ type: 'post', text: 'root' }, cb); 70 | } 71 | }), 72 | pull.through((msg) => { 73 | if (!msg.value.content.root) roots.push(msg.key); 74 | }), 75 | pull.drain( 76 | () => {}, 77 | () => { 78 | pull( 79 | ssb.threads.publicSummary({}), 80 | pull.collect((err, threads) => { 81 | t.error(err); 82 | t.pass(`there are ${threads.length} threads`); 83 | t.true(threads.length > TOTAL * 0.2, 'many threads'); 84 | ssb.close(t.end); 85 | }), 86 | ); 87 | }, 88 | ), 89 | ); 90 | }); 91 | 92 | test('threads.publicSummary respects following opt', (t) => { 93 | const ssb = Testbot({ 94 | keys: lucyKeys, 95 | }); 96 | 97 | pull( 98 | pullAsync((cb) => { 99 | ssb.db.publish( 100 | { type: 'contact', contact: maryKeys.id, following: true }, 101 | cb, 102 | ); 103 | }), 104 | pull.asyncMap((_, cb) => { 105 | ssb.db.create( 106 | { 107 | keys: maryKeys, 108 | content: { type: 'post', text: 'Root post from Mary' }, 109 | }, 110 | cb, 111 | ); 112 | }), 113 | pull.asyncMap((maryRoot, cb) => { 114 | ssb.db.create( 115 | { 116 | keys: aliceKeys, 117 | content: { 118 | type: 'post', 119 | text: 'Alice reply to Mary root', 120 | root: maryRoot.key, 121 | }, 122 | }, 123 | cb, 124 | ); 125 | }), 126 | pull.asyncMap((_, cb) => { 127 | ssb.db.create( 128 | { 129 | keys: aliceKeys, 130 | content: { type: 'post', text: 'Root post from Alice' }, 131 | }, 132 | cb, 133 | ); 134 | }), 135 | pull.map(() => ssb.threads.publicSummary({ following: true })), 136 | pull.flatten(), 137 | 138 | pull.collect((err, summaries) => { 139 | t.error(err); 140 | t.equals(summaries.length, 2); 141 | 142 | const onlyFollowingSummaries = summaries.every( 143 | (s) => s.root.value.author !== aliceKeys.id, 144 | ); 145 | 146 | t.ok( 147 | onlyFollowingSummaries, 148 | 'only summaries for threads created by following returned', 149 | ); 150 | 151 | const contactSummary = summaries.find( 152 | (s) => s.root.value.content.type === 'contact', 153 | ); 154 | const marySummary = summaries.find( 155 | (s) => s.root.value.author === maryKeys.id, 156 | ); 157 | 158 | t.equals(contactSummary.replyCount, 0); 159 | 160 | t.equals( 161 | marySummary.replyCount, 162 | 1, 163 | 'Replies to threads from non-following still accounted for', 164 | ); 165 | 166 | ssb.close(t.end); 167 | }), 168 | ); 169 | }); 170 | -------------------------------------------------------------------------------- /test/publicUpdates.test.js: -------------------------------------------------------------------------------- 1 | const test = require('tape'); 2 | const pull = require('pull-stream'); 3 | const ssbKeys = require('ssb-keys'); 4 | const pullAsync = require('pull-async'); 5 | const Testbot = require('./testbot'); 6 | const wait = require('./wait'); 7 | 8 | const lucyKeys = ssbKeys.generate(null, 'lucy'); 9 | const maryKeys = ssbKeys.generate(null, 'mary'); 10 | const aliceKeys = ssbKeys.generate(null, 'alice'); 11 | 12 | test('threads.publicUpdates notifies of new thread or new msg', (t) => { 13 | const ssb = Testbot({ 14 | keys: lucyKeys, 15 | }); 16 | 17 | let updates = []; 18 | 19 | let liveDrainer; 20 | pull( 21 | ssb.threads.publicUpdates({}), 22 | (liveDrainer = pull.drain((msgKey) => { 23 | updates.push(msgKey); 24 | })), 25 | ); 26 | 27 | let msg1, msg2, msg3; 28 | pull( 29 | pullAsync((cb) => { 30 | ssb.db.create( 31 | { 32 | keys: lucyKeys, 33 | content: { type: 'post', text: 'A: root' }, 34 | }, 35 | wait(cb, 800), 36 | ); 37 | }), 38 | pull.asyncMap((msg, cb) => { 39 | msg1 = msg; 40 | t.equals(updates.length, 0); 41 | ssb.db.create( 42 | { 43 | keys: maryKeys, 44 | content: { type: 'post', text: 'A: 2nd', root: msg1.key }, 45 | }, 46 | wait(cb, 800), 47 | ); 48 | }), 49 | pull.asyncMap((msg, cb) => { 50 | msg2 = msg; 51 | t.equals(updates.length, 1); 52 | t.equals(updates[0], msg2.key); 53 | ssb.db.create( 54 | { 55 | keys: maryKeys, 56 | content: { type: 'post', text: 'B: root' }, 57 | }, 58 | wait(cb, 800), 59 | ); 60 | }), 61 | pull.asyncMap((msg, cb) => { 62 | msg3 = msg; 63 | t.equals(updates.length, 2); 64 | t.equals(updates[1], msg3.key); 65 | ssb.db.create( 66 | { 67 | keys: lucyKeys, 68 | content: { type: 'post', text: 'B: 2nd', root: msg3.key }, 69 | }, 70 | wait(cb, 800), 71 | ); 72 | }), 73 | 74 | pull.drain(() => { 75 | t.equals(updates.length, 2, 'total updates'); 76 | liveDrainer.abort(); 77 | ssb.close(t.end); 78 | }), 79 | ); 80 | }); 81 | 82 | test('threads.publicUpdates respects includeSelf opt', (t) => { 83 | const ssb = Testbot({ 84 | keys: lucyKeys, 85 | }); 86 | 87 | let updates = 0; 88 | 89 | let liveDrainer; 90 | pull( 91 | ssb.threads.publicUpdates({ includeSelf: true }), 92 | (liveDrainer = pull.drain(() => { 93 | updates++; 94 | })), 95 | ); 96 | 97 | let msg1, msg3; 98 | pull( 99 | pullAsync((cb) => { 100 | ssb.db.create( 101 | { 102 | keys: lucyKeys, 103 | content: { type: 'post', text: 'A: root' }, 104 | }, 105 | wait(cb, 800), 106 | ); 107 | }), 108 | pull.asyncMap((msg, cb) => { 109 | msg1 = msg; 110 | t.equals(updates, 1); 111 | ssb.db.create( 112 | { 113 | keys: maryKeys, 114 | content: { type: 'post', text: 'A: 2nd', root: msg1.key }, 115 | }, 116 | wait(cb, 800), 117 | ); 118 | }), 119 | pull.asyncMap((_, cb) => { 120 | t.equals(updates, 2); 121 | ssb.db.create( 122 | { 123 | keys: maryKeys, 124 | content: { type: 'post', text: 'B: root' }, 125 | }, 126 | wait(cb, 800), 127 | ); 128 | }), 129 | pull.asyncMap((msg, cb) => { 130 | msg3 = msg; 131 | t.equals(updates, 3); 132 | ssb.db.create( 133 | { 134 | keys: lucyKeys, 135 | content: { type: 'post', text: 'B: 2nd', root: msg3.key }, 136 | }, 137 | wait(cb, 800), 138 | ); 139 | }), 140 | 141 | pull.drain(() => { 142 | t.equals(updates, 4, 'total updates'); 143 | liveDrainer.abort(); 144 | ssb.close(t.end); 145 | }), 146 | ); 147 | }); 148 | 149 | test('threads.publicUpdates ignores replies to unknown roots', (t) => { 150 | const ssb = Testbot({ 151 | keys: lucyKeys, 152 | }); 153 | 154 | let updates = []; 155 | 156 | let liveDrainer; 157 | pull( 158 | ssb.threads.publicUpdates({}), 159 | (liveDrainer = pull.drain((msgKey) => { 160 | updates.push(msgKey); 161 | })), 162 | ); 163 | 164 | let msg1, msg2; 165 | pull( 166 | pullAsync((cb) => { 167 | ssb.db.create( 168 | { 169 | keys: lucyKeys, 170 | content: { type: 'post', text: 'A: root' }, 171 | }, 172 | wait(cb, 800), 173 | ); 174 | }), 175 | pull.asyncMap((msg, cb) => { 176 | msg1 = msg; 177 | t.equals(updates.length, 0); 178 | 179 | ssb.db.create( 180 | { 181 | keys: maryKeys, 182 | content: { type: 'post', text: 'A: 2nd', root: msg1.key }, 183 | }, 184 | wait(cb, 800), 185 | ); 186 | }), 187 | pull.asyncMap((msg, cb) => { 188 | msg2 = msg; 189 | t.equals(updates.length, 1); 190 | t.equals(updates[0], msg2.key); 191 | 192 | ssb.db.create( 193 | { 194 | keys: maryKeys, 195 | content: { 196 | type: 'post', 197 | text: 'B: 2nd', 198 | root: `%${Buffer.alloc(32, 'deadbeef').toString('base64')}.sha256`, 199 | }, 200 | }, 201 | wait(cb, 800), 202 | ); 203 | }), 204 | 205 | pull.drain(() => { 206 | t.equals(updates.length, 1, 'total updates'); 207 | liveDrainer.abort(); 208 | ssb.close(t.end); 209 | }), 210 | ); 211 | }); 212 | 213 | test('threads.publicUpdates respects following opt', (t) => { 214 | const ssb = Testbot({ keys: lucyKeys }); 215 | 216 | let updates = []; 217 | 218 | let liveDrainer; 219 | pull( 220 | ssb.threads.publicUpdates({ following: true }), 221 | (liveDrainer = pull.drain((msgKey) => { 222 | updates.push(msgKey); 223 | })), 224 | ); 225 | 226 | let msg1, msg2, msg3, msg4, msg5; 227 | pull( 228 | pullAsync((cb) => { 229 | ssb.db.publish( 230 | { type: 'contact', contact: maryKeys.id, following: true }, 231 | cb, 232 | ); 233 | }), 234 | pull.asyncMap((_, cb) => { 235 | ssb.db.create( 236 | { content: { type: 'post', text: 'Root post from Lucy' } }, 237 | cb, 238 | wait(cb, 800), 239 | ); 240 | }), 241 | pull.asyncMap((msg, cb) => { 242 | msg1 = msg; 243 | t.equals(updates.length, 0); 244 | ssb.db.create( 245 | { 246 | keys: maryKeys, 247 | content: { 248 | type: 'post', 249 | text: 'Reply to Lucy root from Mary', 250 | root: msg1.key, 251 | }, 252 | }, 253 | wait(cb, 800), 254 | ); 255 | }), 256 | pull.asyncMap((msg, cb) => { 257 | msg2 = msg; 258 | t.equals(updates.length, 1); 259 | t.equals(updates[0], msg2.key); 260 | ssb.db.create( 261 | { 262 | keys: maryKeys, 263 | content: { type: 'post', text: 'Root post from Mary' }, 264 | }, 265 | wait(cb, 800), 266 | ); 267 | }), 268 | pull.asyncMap((msg, cb) => { 269 | msg3 = msg; 270 | t.equals(updates.length, 2); 271 | t.equals(updates[1], msg3.key); 272 | ssb.db.create( 273 | { 274 | keys: aliceKeys, 275 | content: { 276 | type: 'post', 277 | text: 'Reply to Mary root from Alice', 278 | root: msg3.key, 279 | }, 280 | }, 281 | wait(cb, 800), 282 | ); 283 | }), 284 | 285 | // Following db.create calls should NOT trigger an update 286 | pull.asyncMap((msg, cb) => { 287 | msg4 = msg; 288 | t.equals(updates.length, 3); 289 | t.equals(updates[2], msg4.key); 290 | 291 | ssb.db.create( 292 | { 293 | keys: aliceKeys, 294 | content: { 295 | type: 'post', 296 | text: 'Root post from Alice', 297 | }, 298 | }, 299 | wait(cb, 800), 300 | ); 301 | }), 302 | pull.asyncMap((msg, cb) => { 303 | msg5 = msg; 304 | t.equals(updates.length, 3); 305 | t.equals(updates[2], msg4.key); 306 | 307 | ssb.db.create( 308 | { 309 | keys: maryKeys, 310 | content: { 311 | type: 'post', 312 | text: 'Reply to Alice from Mary', 313 | root: msg5.key, 314 | }, 315 | }, 316 | wait(cb, 800), 317 | ); 318 | }), 319 | 320 | pull.drain(() => { 321 | t.equals(updates.length, 3, 'total updates'); 322 | liveDrainer.abort(); 323 | ssb.close(t.end); 324 | }), 325 | ); 326 | }); 327 | -------------------------------------------------------------------------------- /test/recentHashtags.test.js: -------------------------------------------------------------------------------- 1 | const test = require('tape'); 2 | const pull = require('pull-stream'); 3 | const pullAsync = require('pull-async'); 4 | const ssbKeys = require('ssb-keys'); 5 | const p = require('util').promisify; 6 | const Testbot = require('./testbot'); 7 | const wait = require('./wait'); 8 | 9 | const andrewKeys = ssbKeys.generate(null, 'andrew'); 10 | const brianKeys = ssbKeys.generate(null, 'brian'); 11 | 12 | test('threads.recentHashtags errors on bad limit option', (t) => { 13 | const ssb = Testbot({ keys: andrewKeys }); 14 | 15 | ssb.threads.recentHashtags({ limit: 0 }, (err) => { 16 | t.ok(err); 17 | 18 | ssb.threads.recentHashtags({ limit: -1 }, (err2) => { 19 | t.ok(err2); 20 | ssb.close(t.end); 21 | }); 22 | }); 23 | }); 24 | 25 | test('threads.recentHashtags returns empty result when no messages with hashtags exist', (t) => { 26 | const ssb = Testbot({ keys: andrewKeys }); 27 | 28 | pull( 29 | pullAsync((cb) => { 30 | ssb.db.create( 31 | { 32 | keys: andrewKeys, 33 | content: { 34 | type: 'post', 35 | text: 'My favorite animals (thread 1)', 36 | }, 37 | }, 38 | wait(cb, 100), 39 | ); 40 | }), 41 | pull.asyncMap((rootMsg, cb) => { 42 | ssb.db.create( 43 | { 44 | keys: andrewKeys, 45 | content: { 46 | type: 'post', 47 | text: 'I like wombats (reply to thread 1)', 48 | root: rootMsg, 49 | }, 50 | }, 51 | wait(cb, 100), 52 | ); 53 | }), 54 | pull.asyncMap((_, cb) => { 55 | ssb.db.create( 56 | { 57 | keys: brianKeys, 58 | content: { 59 | type: 'post', 60 | text: 'My favorite animal is the beaver (thread 2)', 61 | }, 62 | }, 63 | wait(cb, 100), 64 | ); 65 | }), 66 | pull.drain(null, (err) => { 67 | t.error(err); 68 | 69 | ssb.threads.recentHashtags({ limit: 10 }, (err2, hashtags) => { 70 | t.error(err2); 71 | t.deepEquals(hashtags, []); 72 | ssb.close(t.end); 73 | }); 74 | }), 75 | ); 76 | }); 77 | 78 | test('threads.recentHashtags basic case works', (t) => { 79 | const ssb = Testbot({ keys: andrewKeys }); 80 | 81 | pull( 82 | pullAsync((cb) => { 83 | ssb.db.create( 84 | { 85 | keys: andrewKeys, 86 | content: { 87 | type: 'post', 88 | text: 'My favorite animals (thread 1)', 89 | channel: 'animals', 90 | }, 91 | }, 92 | wait(cb, 100), 93 | ); 94 | }), 95 | pull.asyncMap((rootMsg, cb) => { 96 | ssb.db.create( 97 | { 98 | keys: andrewKeys, 99 | content: { 100 | type: 'post', 101 | text: 'I like #wombats (reply to thread 1)', 102 | mentions: [{ link: '#wombats' }], 103 | root: rootMsg, 104 | }, 105 | }, 106 | wait(cb, 100), 107 | ); 108 | }), 109 | pull.asyncMap((_, cb) => { 110 | ssb.db.create( 111 | { 112 | keys: brianKeys, 113 | content: { 114 | type: 'post', 115 | text: 'My favorite animal is the #beaver (thread 2)', 116 | mentions: [{ link: '#beaver' }], 117 | }, 118 | }, 119 | wait(cb, 100), 120 | ); 121 | }), 122 | pull.drain(null, (err) => { 123 | t.error(err); 124 | 125 | ssb.threads.recentHashtags({ limit: 10 }, (err2, hashtags) => { 126 | t.error(err2); 127 | t.deepEquals(hashtags, ['beaver', 'wombats', 'animals']); 128 | ssb.close(t.end); 129 | }); 130 | }), 131 | ); 132 | }); 133 | 134 | test('threads.recentHashtags respects limit opt', (t) => { 135 | const ssb = Testbot({ keys: andrewKeys }); 136 | 137 | pull( 138 | pullAsync((cb) => { 139 | ssb.db.create( 140 | { 141 | keys: andrewKeys, 142 | content: { 143 | type: 'post', 144 | text: 'My favorite animals (thread 1)', 145 | channel: 'animals', 146 | }, 147 | }, 148 | wait(cb, 100), 149 | ); 150 | }), 151 | pull.asyncMap((rootMsg, cb) => { 152 | ssb.db.create( 153 | { 154 | keys: andrewKeys, 155 | content: { 156 | type: 'post', 157 | text: 'I like #wombats (reply to thread 1)', 158 | mentions: [{ link: '#wombats' }], 159 | root: rootMsg, 160 | }, 161 | }, 162 | wait(cb, 100), 163 | ); 164 | }), 165 | pull.asyncMap((_, cb) => { 166 | ssb.db.create( 167 | { 168 | keys: brianKeys, 169 | content: { 170 | type: 'post', 171 | text: 'My favorite animal is the #beaver (thread 2)', 172 | mentions: [{ link: '#beaver' }], 173 | }, 174 | }, 175 | wait(cb, 100), 176 | ); 177 | }), 178 | pull.drain(null, (err) => { 179 | t.error(err); 180 | 181 | ssb.threads.recentHashtags({ limit: 2 }, (err2, hashtags) => { 182 | t.error(err2); 183 | t.deepEquals(hashtags, ['beaver', 'wombats']); 184 | ssb.close(t.end); 185 | }); 186 | }), 187 | ); 188 | }); 189 | 190 | test('threads.recentHashtags respects preserveCase opt', (t) => { 191 | const ssb = Testbot({ keys: andrewKeys }); 192 | 193 | pull( 194 | pullAsync((cb) => { 195 | ssb.db.create( 196 | { 197 | keys: andrewKeys, 198 | content: { 199 | type: 'post', 200 | text: 'My favorite animals (thread 1)', 201 | channel: 'animals', 202 | }, 203 | }, 204 | wait(cb, 100), 205 | ); 206 | }), 207 | pull.asyncMap((rootMsg, cb) => { 208 | ssb.db.create( 209 | { 210 | keys: andrewKeys, 211 | content: { 212 | type: 'post', 213 | text: 'I like #wombats (reply to thread 1)', 214 | mentions: [{ link: '#wombats' }], 215 | root: rootMsg, 216 | }, 217 | }, 218 | wait(cb, 100), 219 | ); 220 | }), 221 | pull.asyncMap((_, cb) => { 222 | ssb.db.create( 223 | { 224 | keys: brianKeys, 225 | content: { 226 | type: 'post', 227 | text: 'My favorite animal is the #BlueBeaver (thread 2)', 228 | mentions: [{ link: '#BlueBeaver' }], 229 | }, 230 | }, 231 | wait(cb, 100), 232 | ); 233 | }), 234 | pull.drain(null, (err) => { 235 | t.error(err); 236 | 237 | ssb.threads.recentHashtags( 238 | { limit: 1, preserveCase: false }, 239 | (err2, hashtags) => { 240 | t.error(err2); 241 | t.deepEquals(hashtags, ['bluebeaver']); 242 | 243 | ssb.threads.recentHashtags( 244 | { limit: 1, preserveCase: true }, 245 | (err3, hashtags2) => { 246 | t.error(err3); 247 | t.deepEquals(hashtags2, ['BlueBeaver']); 248 | ssb.close(t.end); 249 | }, 250 | ); 251 | }, 252 | ); 253 | }), 254 | ); 255 | }); 256 | 257 | test('threads.recentHashtags handles messages with many mentions links', (t) => { 258 | const ssb = Testbot({ keys: andrewKeys }); 259 | 260 | pull( 261 | pullAsync((cb) => { 262 | ssb.db.create( 263 | { 264 | keys: andrewKeys, 265 | content: { 266 | type: 'post', 267 | text: '#Animals I like include #wombats and #beavers', 268 | mentions: [ 269 | { link: '#animals' }, 270 | { link: '#wombats' }, 271 | { link: '#beavers' }, 272 | ], 273 | }, 274 | }, 275 | wait(cb, 100), 276 | ); 277 | }), 278 | pull.asyncMap((_, cb) => { 279 | ssb.db.create( 280 | { 281 | keys: andrewKeys, 282 | content: { 283 | type: 'post', 284 | text: '#p4p is 2x cooler than #p2p', 285 | mentions: [{ link: '#p4p' }, { link: '#p2p' }], 286 | }, 287 | }, 288 | wait(cb, 100), 289 | ); 290 | }), 291 | pull.drain(null, (err) => { 292 | t.error(err); 293 | 294 | ssb.threads.recentHashtags( 295 | { limit: 10, preserveCase: true }, 296 | (err2, hashtags) => { 297 | t.error(err2); 298 | t.deepEquals(hashtags, [ 299 | 'p4p', 300 | 'p2p', 301 | 'animals', 302 | 'wombats', 303 | 'beavers', 304 | ]); 305 | ssb.close(t.end); 306 | }, 307 | ); 308 | }), 309 | ); 310 | }); 311 | 312 | test('threads.recentHashtags only looks into hashtags in mentions', (t) => { 313 | const ssb = Testbot({ keys: andrewKeys }); 314 | 315 | pull( 316 | pullAsync((cb) => { 317 | ssb.db.create( 318 | { 319 | keys: andrewKeys, 320 | content: { 321 | type: 'post', 322 | text: 'I like #wombats and #beavers, here is a picture', 323 | mentions: [ 324 | { link: '#wombats' }, 325 | { link: '#beavers' }, 326 | { link: '&WWw4tQJ6ZrM7o3gA8lOEAcO4zmyqXqb/3bmIKTLQepo=.sha256' }, 327 | ], 328 | }, 329 | }, 330 | wait(cb, 100), 331 | ); 332 | }), 333 | pull.asyncMap((_, cb) => { 334 | ssb.db.create( 335 | { 336 | keys: andrewKeys, 337 | content: { 338 | type: 'post', 339 | text: '#p2p is cool', 340 | mentions: [{ link: '#p2p' }], 341 | }, 342 | }, 343 | wait(cb, 100), 344 | ); 345 | }), 346 | pull.drain(null, (err) => { 347 | t.error(err); 348 | 349 | ssb.threads.recentHashtags( 350 | { limit: 10, preserveCase: true }, 351 | (err2, hashtags) => { 352 | t.error(err2); 353 | t.deepEquals(hashtags, ['p2p', 'wombats', 'beavers']); 354 | ssb.close(t.end); 355 | }, 356 | ); 357 | }), 358 | ); 359 | }); 360 | 361 | test('threads.recentHashtags returns most recently seen variation when preserveCase opt is true', (t) => { 362 | const ssb = Testbot({ keys: andrewKeys }); 363 | 364 | pull( 365 | pullAsync((cb) => { 366 | ssb.db.create( 367 | { 368 | keys: andrewKeys, 369 | content: { 370 | type: 'post', 371 | text: 'My favorite animals (thread 1)', 372 | channel: 'animals', 373 | }, 374 | }, 375 | wait(cb, 100), 376 | ); 377 | }), 378 | pull.asyncMap((rootMsg, cb) => { 379 | ssb.db.create( 380 | { 381 | keys: brianKeys, 382 | content: { 383 | type: 'post', 384 | text: '#Animals I like include wombats (reply to thread 1)', 385 | mentions: [{ link: '#Animals' }], 386 | root: rootMsg, 387 | }, 388 | }, 389 | wait(cb, 100), 390 | ); 391 | }), 392 | pull.drain(null, (err) => { 393 | t.error(err); 394 | 395 | ssb.threads.recentHashtags( 396 | { limit: 10, preserveCase: true }, 397 | (err2, hashtags) => { 398 | t.error(err2); 399 | t.deepEquals(hashtags, ['Animals']); 400 | ssb.close(t.end); 401 | }, 402 | ); 403 | }), 404 | ); 405 | }); 406 | -------------------------------------------------------------------------------- /test/testbot.js: -------------------------------------------------------------------------------- 1 | const SecretStack = require('secret-stack'); 2 | const ssbKeys = require('ssb-keys'); 3 | const path = require('path'); 4 | const caps = require('ssb-caps'); 5 | const fs = require('fs'); 6 | const os = require('os'); 7 | 8 | module.exports = function Testbot(opts = {}) { 9 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'threads-test')); 10 | const keys = opts.keys || ssbKeys.loadOrCreateSync(path.join(dir, 'secret')); 11 | 12 | return SecretStack({ appKey: caps.shs }) 13 | .use(require('ssb-db2')) 14 | .use(require('ssb-friends')) 15 | .use(require('../lib/index')) 16 | .call(null, { path: dir, keys }); 17 | }; 18 | -------------------------------------------------------------------------------- /test/thread.test.js: -------------------------------------------------------------------------------- 1 | const test = require('tape'); 2 | const pull = require('pull-stream'); 3 | const ssbKeys = require('ssb-keys'); 4 | const pullAsync = require('pull-async'); 5 | const cat = require('pull-cat'); 6 | const Testbot = require('./testbot'); 7 | 8 | const lucyKeys = ssbKeys.generate(null, 'lucy'); 9 | 10 | test('threads.thread gives one full thread', (t) => { 11 | const ssb = Testbot({ keys: lucyKeys }); 12 | 13 | let rootAkey; 14 | pull( 15 | pullAsync((cb) => { 16 | ssb.db.publish({ type: 'post', text: 'A: root' }, cb); 17 | }), 18 | pull.asyncMap((rootMsg, cb) => { 19 | rootAkey = rootMsg.key; 20 | ssb.db.publish({ type: 'post', text: 'A: 2nd', root: rootMsg.key }, cb); 21 | }), 22 | pull.asyncMap((prevMsg, cb) => { 23 | const rootKey = prevMsg.value.content.root; 24 | ssb.db.publish({ type: 'post', text: 'A: 3rd', root: rootKey }, cb); 25 | }), 26 | 27 | pull.map(() => ssb.threads.thread({ root: rootAkey })), 28 | pull.flatten(), 29 | 30 | pull.collect((err, threads) => { 31 | t.error(err, 'no error'); 32 | t.equals(threads.length, 1, 'one thread'); 33 | const thread = threads[0]; 34 | t.equals(thread.full, true, 'thread comes back full'); 35 | t.equals(thread.messages.length, 3, 'thread has 3 messages'); 36 | t.equals(thread.messages[0].value.content.text, 'A: root', 'root msg ok'); 37 | t.equals(thread.messages[1].value.content.text, 'A: 2nd', '2nd msg ok'); 38 | t.equals(thread.messages[2].value.content.text, 'A: 3rd', '3rd msg ok'); 39 | ssb.close(t.end); 40 | }), 41 | ); 42 | }); 43 | 44 | test('threads.thread can be called twice consecutively (to use cache)', (t) => { 45 | const ssb = Testbot({ keys: lucyKeys }); 46 | 47 | let rootAkey; 48 | pull( 49 | pullAsync((cb) => { 50 | ssb.db.publish({ type: 'post', text: 'A: root' }, cb); 51 | }), 52 | pull.asyncMap((rootMsg, cb) => { 53 | rootAkey = rootMsg.key; 54 | ssb.db.publish({ type: 'post', text: 'A: 2nd', root: rootMsg.key }, cb); 55 | }), 56 | pull.asyncMap((prevMsg, cb) => { 57 | const rootKey = prevMsg.value.content.root; 58 | ssb.db.publish({ type: 'post', text: 'A: 3rd', root: rootKey }, cb); 59 | }), 60 | 61 | pull.map(() => 62 | cat([ 63 | ssb.threads.thread({ root: rootAkey }), 64 | ssb.threads.thread({ root: rootAkey }), 65 | ]), 66 | ), 67 | pull.flatten(), 68 | 69 | pull.collect((err, threads) => { 70 | t.error(err, 'no error'); 71 | t.equals(threads.length, 2, 'two threads'); 72 | 73 | const t1 = threads[0]; 74 | t.equals(t1.full, true, 'thread 1 comes back full'); 75 | t.equals(t1.messages.length, 3, 'thread 1 has 3 messages'); 76 | t.equals(t1.messages[0].value.content.text, 'A: root', 'root msg ok'); 77 | t.equals(t1.messages[1].value.content.text, 'A: 2nd', '2nd msg ok'); 78 | t.equals(t1.messages[2].value.content.text, 'A: 3rd', '3rd msg ok'); 79 | 80 | const t2 = threads[0]; 81 | t.equals(t2.full, true, 'thread 2 comes back full'); 82 | t.equals(t2.messages[0].key, t1.messages[0].key, 'same root as before'); 83 | t.equals(t2.messages.length, 3, 'thread 2 has 3 messages'); 84 | t.equals(t2.messages[0].value.content.text, 'A: root', 'root msg ok'); 85 | t.equals(t2.messages[1].value.content.text, 'A: 2nd', '2nd msg ok'); 86 | t.equals(t2.messages[2].value.content.text, 'A: 3rd', '3rd msg ok'); 87 | ssb.close(t.end); 88 | }), 89 | ); 90 | }); 91 | 92 | test('threads.thread (by default) cannot view private conversations', (t) => { 93 | const ssb = Testbot({ keys: lucyKeys }); 94 | 95 | let rootKey; 96 | 97 | pull( 98 | pullAsync((cb) => { 99 | ssb.db.create( 100 | { 101 | keys: lucyKeys, 102 | content: { type: 'post', text: 'Secret thread root' }, 103 | recps: [ssb.id], 104 | encryptionFormat: 'box', 105 | }, 106 | cb, 107 | ); 108 | }), 109 | pull.asyncMap((rootMsg, cb) => { 110 | rootKey = rootMsg.key; 111 | ssb.db.create( 112 | { 113 | keys: lucyKeys, 114 | content: { 115 | type: 'post', 116 | text: 'Second secret message', 117 | root: rootKey, 118 | }, 119 | recps: [ssb.id], 120 | encryptionFormat: 'box', 121 | }, 122 | cb, 123 | ); 124 | }), 125 | pull.asyncMap((_prevMsg, cb) => { 126 | ssb.db.create( 127 | { 128 | keys: lucyKeys, 129 | content: { 130 | type: 'post', 131 | text: 'Third secret message', 132 | root: rootKey, 133 | }, 134 | recps: [ssb.id], 135 | encryptionFormat: 'box', 136 | }, 137 | cb, 138 | ); 139 | }), 140 | pull.map(() => ssb.threads.thread({ root: rootKey })), 141 | pull.flatten(), 142 | 143 | pull.collect((err, threads) => { 144 | t.error(err, 'no error'); 145 | t.equals(threads.length, 0, 'no threads arrived'); 146 | ssb.close(t.end); 147 | }), 148 | ); 149 | }); 150 | 151 | test('threads.thread can view private conversations given opts.private', (t) => { 152 | const ssb = Testbot({ keys: lucyKeys }); 153 | 154 | let rootKey; 155 | 156 | pull( 157 | pullAsync((cb) => { 158 | ssb.db.create( 159 | { 160 | keys: lucyKeys, 161 | content: { type: 'post', text: 'Secret thread root' }, 162 | recps: [ssb.id], 163 | encryptionFormat: 'box', 164 | }, 165 | cb, 166 | ); 167 | }), 168 | pull.asyncMap((rootMsg, cb) => { 169 | rootKey = rootMsg.key; 170 | ssb.db.create( 171 | { 172 | keys: lucyKeys, 173 | content: { 174 | type: 'post', 175 | text: 'Second secret message', 176 | root: rootKey, 177 | }, 178 | recps: [ssb.id], 179 | encryptionFormat: 'box', 180 | }, 181 | cb, 182 | ); 183 | }), 184 | pull.asyncMap((_prevMsg, cb) => { 185 | ssb.db.create( 186 | { 187 | keys: lucyKeys, 188 | content: { 189 | type: 'post', 190 | text: 'Third secret message', 191 | root: rootKey, 192 | }, 193 | recps: [ssb.id], 194 | encryptionFormat: 'box', 195 | }, 196 | cb, 197 | ); 198 | }), 199 | pull.map(() => ssb.threads.thread({ root: rootKey, private: true })), 200 | pull.flatten(), 201 | 202 | pull.collect((err, threads) => { 203 | t.error(err, 'no error'); 204 | t.equals(threads.length, 1, 'one secret thread arrived'); 205 | const thread = threads[0]; 206 | t.equals(thread.full, true, 'thread comes back full'); 207 | t.equals(thread.messages.length, 3, 'thread has 3 messages'); 208 | t.equals(thread.messages[0].value.content.text, 'Secret thread root'); 209 | t.equals(thread.messages[1].value.content.text, 'Second secret message'); 210 | t.equals(thread.messages[2].value.content.text, 'Third secret message'); 211 | ssb.close(t.end); 212 | }), 213 | ); 214 | }); 215 | -------------------------------------------------------------------------------- /test/threadUpdates.test.js: -------------------------------------------------------------------------------- 1 | const test = require('tape'); 2 | const pull = require('pull-stream'); 3 | const ssbKeys = require('ssb-keys'); 4 | const pullAsync = require('pull-async'); 5 | const Testbot = require('./testbot'); 6 | const wait = require('./wait'); 7 | 8 | const lucyKeys = ssbKeys.generate(null, 'lucy'); 9 | const maryKeys = ssbKeys.generate(null, 'mary'); 10 | 11 | test('threads.threadUpdates notifies of new reply to that thread', (t) => { 12 | const ssb = Testbot({ keys: lucyKeys }); 13 | 14 | let updates = 0; 15 | let liveDrainer; 16 | 17 | pull( 18 | pullAsync((cb) => { 19 | ssb.db.create( 20 | { 21 | keys: lucyKeys, 22 | content: { type: 'post', text: 'A: root' }, 23 | }, 24 | wait(cb, 800), 25 | ); 26 | }), 27 | pull.asyncMap((rootMsg, cb) => { 28 | pull( 29 | ssb.threads.threadUpdates({ root: rootMsg.key }), 30 | (liveDrainer = pull.drain((msg) => { 31 | t.equals(msg.value.content.root, rootMsg.key, 'got update'); 32 | updates++; 33 | })), 34 | ); 35 | 36 | setTimeout(() => { 37 | t.equals(updates, 0); 38 | ssb.db.create( 39 | { 40 | keys: maryKeys, 41 | content: { type: 'post', text: 'A: 2nd', root: rootMsg.key }, 42 | }, 43 | wait(cb, 800), 44 | ); 45 | }, 300); 46 | }), 47 | pull.asyncMap((_, cb) => { 48 | t.equals(updates, 1); 49 | ssb.db.create( 50 | { 51 | keys: maryKeys, 52 | content: { type: 'post', text: 'B: root' }, 53 | }, 54 | wait(cb, 800), 55 | ); 56 | }), 57 | pull.asyncMap((msg3, cb) => { 58 | t.equals(updates, 1); 59 | ssb.db.create( 60 | { 61 | keys: lucyKeys, 62 | content: { type: 'post', text: 'B: 2nd', root: msg3.key }, 63 | }, 64 | wait(cb, 800), 65 | ); 66 | }), 67 | 68 | pull.drain(() => { 69 | t.equals(updates, 1); 70 | liveDrainer.abort(); 71 | ssb.close(t.end); 72 | }), 73 | ); 74 | }); 75 | 76 | test('threads.threadUpdates (by default) cannot see private replies', (t) => { 77 | const ssb = Testbot({ keys: lucyKeys }); 78 | 79 | let updates = 0; 80 | let liveDrainer; 81 | 82 | pull( 83 | pullAsync((cb) => { 84 | ssb.db.create( 85 | { 86 | keys: lucyKeys, 87 | content: { type: 'post', text: 'A: root' }, 88 | recps: [lucyKeys.id, maryKeys.id], 89 | encryptionFormat: 'box', 90 | }, 91 | wait(cb), 92 | ); 93 | }), 94 | pull.asyncMap((rootMsg, cb) => { 95 | pull( 96 | ssb.threads.threadUpdates({ root: rootMsg.key }), 97 | (liveDrainer = pull.drain((m) => { 98 | t.fail('should not get an update'); 99 | updates++; 100 | })), 101 | ); 102 | 103 | setTimeout(() => { 104 | t.equals(updates, 0); 105 | ssb.db.create( 106 | { 107 | keys: maryKeys, 108 | content: { type: 'post', text: 'A: 2nd', root: rootMsg.key }, 109 | recps: [lucyKeys.id, maryKeys.id], 110 | encryptionFormat: 'box', 111 | }, 112 | wait(cb), 113 | ); 114 | }, 300); 115 | }), 116 | pull.asyncMap((_, cb) => { 117 | t.equals(updates, 0); 118 | ssb.db.create( 119 | { 120 | keys: maryKeys, 121 | content: { type: 'post', text: 'B: root' }, 122 | recps: [lucyKeys.id, maryKeys.id], 123 | encryptionFormat: 'box', 124 | }, 125 | wait(cb), 126 | ); 127 | }), 128 | pull.asyncMap((msg3, cb) => { 129 | t.equals(updates, 0); 130 | ssb.db.create( 131 | { 132 | keys: lucyKeys, 133 | content: { type: 'post', text: 'B: 2nd', root: msg3.key }, 134 | recps: [lucyKeys.id, maryKeys.id], 135 | encryptionFormat: 'box', 136 | }, 137 | wait(cb), 138 | ); 139 | }), 140 | 141 | pull.drain(() => { 142 | t.equals(updates, 0); 143 | liveDrainer.abort(); 144 | ssb.close(t.end); 145 | }), 146 | ); 147 | }); 148 | 149 | test('threads.threadUpdates can view private replies given opts.private', (t) => { 150 | const ssb = Testbot({ keys: lucyKeys }); 151 | 152 | let updates = 0; 153 | let liveDrainer; 154 | 155 | pull( 156 | pullAsync((cb) => { 157 | ssb.db.create( 158 | { 159 | keys: lucyKeys, 160 | content: { type: 'post', text: 'A: root' }, 161 | recps: [lucyKeys.id, maryKeys.id], 162 | encryptionFormat: 'box', 163 | }, 164 | wait(cb, 800), 165 | ); 166 | }), 167 | pull.asyncMap((rootMsg, cb) => { 168 | pull( 169 | ssb.threads.threadUpdates({ root: rootMsg.key, private: true }), 170 | (liveDrainer = pull.drain((msg) => { 171 | t.equals(msg.value.content.root, rootMsg.key, 'got update'); 172 | updates++; 173 | })), 174 | ); 175 | 176 | setTimeout(() => { 177 | t.equals(updates, 0); 178 | ssb.db.create( 179 | { 180 | keys: maryKeys, 181 | content: { type: 'post', text: 'A: 2nd', root: rootMsg.key }, 182 | recps: [lucyKeys.id, maryKeys.id], 183 | encryptionFormat: 'box', 184 | }, 185 | wait(cb, 800), 186 | ); 187 | }, 300); 188 | }), 189 | pull.asyncMap((_, cb) => { 190 | t.equals(updates, 1); 191 | ssb.db.create( 192 | { 193 | keys: maryKeys, 194 | content: { type: 'post', text: 'B: root' }, 195 | recps: [lucyKeys.id, maryKeys.id], 196 | encryptionFormat: 'box', 197 | }, 198 | wait(cb, 800), 199 | ); 200 | }), 201 | pull.asyncMap((msg3, cb) => { 202 | t.equals(updates, 1); 203 | ssb.db.create( 204 | { 205 | keys: lucyKeys, 206 | content: { type: 'post', text: 'B: 2nd', root: msg3.key }, 207 | recps: [lucyKeys.id, maryKeys.id], 208 | encryptionFormat: 'box', 209 | }, 210 | wait(cb, 800), 211 | ); 212 | }), 213 | 214 | pull.drain(() => { 215 | t.equals(updates, 1); 216 | liveDrainer.abort(); 217 | ssb.close(t.end); 218 | }), 219 | ); 220 | }); 221 | -------------------------------------------------------------------------------- /test/wait.js: -------------------------------------------------------------------------------- 1 | module.exports = function wait(cb, period = 300) { 2 | return (err, data) => { 3 | setTimeout(() => cb(err, data), period); 4 | }; 5 | }; 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "removeComments": false, 4 | "preserveConstEnums": true, 5 | "sourceMap": true, 6 | "declaration": true, 7 | "noImplicitAny": true, 8 | "strictNullChecks": true, 9 | "experimentalDecorators": true, 10 | "suppressImplicitAnyIndexErrors": true, 11 | "skipLibCheck": true, 12 | "module": "commonjs", 13 | "target": "es2018", 14 | "outDir": "./lib", 15 | "lib": ["es5", "scripthost", "es2015"] 16 | }, 17 | "files": ["src/index.ts"], 18 | "exclude": ["node_modules"] 19 | } 20 | --------------------------------------------------------------------------------