Files: 58ab0241031aa549a35cce1e678c27065ae66221 / lib / plugins / public-feed.js
8353 bytesRaw
1 | |
2 | const pull = require('pull-stream') |
3 | const HLRU = require('hashlru') |
4 | const extend = require('xtend') |
5 | const normalizeChannel = require('ssb-ref').normalizeChannel |
6 | const pullResume = require('../pull-resume') |
7 | const threadSummary = require('../thread-summary') |
8 | const LookupRoots = require('../lookup-roots') |
9 | const ResolveAbouts = require('../resolve-abouts') |
10 | const Paramap = require('pull-paramap') |
11 | const getRoot = require('../get-root') |
12 | const FilterBlocked = require('../filter-blocked') |
13 | const PullCont = require('pull-cont/source') |
14 | |
15 | exports.manifest = { |
16 | latest: 'source', |
17 | roots: 'source' |
18 | } |
19 | |
20 | exports.init = function (ssb) { |
21 | // cache mostly just to avoid reading the same roots over and over again |
22 | // not really big enough for multiple refresh cycles |
23 | const cache = HLRU(100) |
24 | |
25 | return { |
26 | latest: function () { |
27 | return pull( |
28 | ssb.createFeedStream({ live: true, old: false, awaitReady: false }), |
29 | |
30 | ApplyFilterResult({ ssb }), |
31 | pull.filter(msg => !!msg.filterResult), |
32 | |
33 | LookupRoots({ ssb, cache }), |
34 | |
35 | FilterPrivateRoots(), |
36 | FilterBlocked([ssb.id], { |
37 | isBlocking: ssb.patchwork.contacts.isBlocking, |
38 | useRootAuthorBlocks: true, |
39 | checkRoot: true |
40 | }), |
41 | |
42 | ApplyRootFilterResult({ ssb }), |
43 | pull.filter(msg => { |
44 | const root = msg.root || msg |
45 | return root.filterResult |
46 | }) |
47 | ) |
48 | }, |
49 | roots: function ({ reverse, limit, resume }) { |
50 | const seen = new Set() |
51 | const included = new Set() |
52 | |
53 | // use resume option if specified |
54 | const opts = { reverse, old: true, awaitReady: false } |
55 | if (resume) { |
56 | opts[reverse ? 'lt' : 'gt'] = resume |
57 | } |
58 | |
59 | return PullCont(cb => { |
60 | // wait until contacts have resolved before reading |
61 | ssb.patchwork.contacts.raw.get(() => { |
62 | cb(null, pullResume.source(ssb.createFeedStream(opts), { |
63 | limit, |
64 | getResume: (item) => { |
65 | return item && item.rts |
66 | }, |
67 | filterMap: pull( |
68 | ApplyFilterResult({ ssb }), |
69 | pull.filter(msg => !!msg.filterResult), |
70 | |
71 | LookupRoots({ ssb, cache }), |
72 | |
73 | FilterPrivateRoots(), |
74 | |
75 | FilterBlocked([ssb.id], { |
76 | isBlocking: ssb.patchwork.contacts.isBlocking, |
77 | useRootAuthorBlocks: true, |
78 | checkRoot: true |
79 | }), |
80 | |
81 | ApplyRootFilterResult({ ssb }), |
82 | |
83 | // FILTER ROOTS |
84 | pull.filter(item => { |
85 | const root = item.root || item |
86 | if (!included.has(root.key) && root && root.value && root.filterResult) { |
87 | if (root.filterResult.forced) { |
88 | // force include the root when a reply has matching tags or the author is you |
89 | included.add(root.key) |
90 | return true |
91 | } else if (!seen.has(root.key)) { |
92 | seen.add(root.key) |
93 | if (shouldShow(root.filterResult)) { |
94 | included.add(root.key) |
95 | return true |
96 | } |
97 | } |
98 | } |
99 | }), |
100 | |
101 | // MAP ROOT ITEMS |
102 | pull.map(item => { |
103 | const root = item.root || item |
104 | return root |
105 | }), |
106 | |
107 | // RESOLVE ROOTS WITH ABOUTS |
108 | ResolveAbouts({ ssb }), |
109 | |
110 | // ADD THREAD SUMMARY |
111 | Paramap((item, cb) => { |
112 | threadSummary(item.key, { |
113 | pullFilter: pull( |
114 | FilterBlocked([item.value && item.value.author, ssb.id], { isBlocking: ssb.patchwork.contacts.isBlocking }), |
115 | ApplyFilterResult({ ssb }) |
116 | ), |
117 | recentLimit: 3, |
118 | readThread: ssb.patchwork.thread.read, |
119 | bumpFilter: bumpFilter |
120 | }, (err, summary) => { |
121 | if (err) return cb(err) |
122 | cb(null, extend(item, summary, { |
123 | filterResult: undefined, |
124 | rootBump: bumpFilter |
125 | })) |
126 | }) |
127 | }, 20) |
128 | ) |
129 | })) |
130 | }) |
131 | }) |
132 | } |
133 | } |
134 | |
135 | function shouldShow (filterResult) { |
136 | return !!filterResult |
137 | } |
138 | } |
139 | |
140 | function FilterPrivateRoots () { |
141 | return pull.filter(msg => { |
142 | return !msg.root || (msg.root.value && !msg.root.value.private) |
143 | }) |
144 | } |
145 | |
146 | function ApplyRootFilterResult ({ ssb }) { |
147 | return Paramap((item, cb) => { |
148 | if (item.root) { |
149 | getFilterResult(item.root, { ssb }, (err, rootFilterResult) => { |
150 | if (err) return cb(err) |
151 | if (item.filterResult && checkReplyForcesDisplay(item)) { // include this item if it has matching tags or the author is you |
152 | item.root.filterResult = extend(item.filterResult, { forced: true }) |
153 | } else { |
154 | item.root.filterResult = rootFilterResult |
155 | } |
156 | cb(null, item) |
157 | }) |
158 | } else { |
159 | cb(null, item) |
160 | } |
161 | }) |
162 | } |
163 | |
164 | function ApplyFilterResult ({ ssb }) { |
165 | return Paramap((item, cb) => { |
166 | getFilterResult(item, { ssb }, (err, filterResult) => { |
167 | if (err) return cb(err) |
168 | item.filterResult = filterResult |
169 | cb(null, item) |
170 | }) |
171 | }, 10) |
172 | } |
173 | |
174 | function getFilterResult (msg, { ssb }, cb) { |
175 | ssb.patchwork.contacts.isFollowing({ source: ssb.id, dest: msg.value.author }, (err, following) => { |
176 | if (err) return cb(err) |
177 | ssb.patchwork.subscriptions2.get({ id: ssb.id }, (err, subscriptions) => { |
178 | if (err) return cb(err) |
179 | const type = msg.value.content.type |
180 | if (type === 'vote' || type === 'tag') return cb() // filter out likes and tags |
181 | const hasChannel = !!msg.value.content.channel |
182 | const matchesChannel = (type !== 'channel' && checkChannel(subscriptions, msg.value.content.channel)) |
183 | const matchingTags = getMatchingTags(subscriptions, msg.value.content.mentions) |
184 | const isYours = msg.value.author === ssb.id |
185 | const mentionsYou = getMentionsYou([ssb.id], msg.value.content.mentions) |
186 | if (isYours || matchesChannel || matchingTags.length || following || mentionsYou) { |
187 | cb(null, { |
188 | matchingTags, matchesChannel, isYours, following, mentionsYou, hasChannel |
189 | }) |
190 | } else { |
191 | cb() |
192 | } |
193 | }) |
194 | }) |
195 | } |
196 | |
197 | function getMatchingTags (lookup, mentions) { |
198 | if (Array.isArray(mentions)) { |
199 | return mentions.reduce((result, mention) => { |
200 | if (mention && typeof mention.link === 'string' && mention.link.startsWith('#')) { |
201 | if (checkChannel(lookup, mention.link.slice(1))) { |
202 | result.push(normalizeChannel(mention.link.slice(1))) |
203 | } |
204 | } |
205 | return result |
206 | }, []) |
207 | } |
208 | return [] |
209 | } |
210 | |
211 | function getMentionsYou (ids, mentions) { |
212 | if (Array.isArray(mentions)) { |
213 | return mentions.some((mention) => { |
214 | if (mention && typeof mention.link === 'string') { |
215 | return ids.includes(mention.link) |
216 | } |
217 | }) |
218 | } |
219 | } |
220 | |
221 | function checkReplyForcesDisplay (item) { |
222 | const filterResult = item.filterResult || {} |
223 | const matchesTags = filterResult.matchingTags && !!filterResult.matchingTags.length |
224 | return matchesTags || filterResult.isYours |
225 | } |
226 | |
227 | function checkChannel (lookup, channel) { |
228 | if (!lookup) return false |
229 | channel = normalizeChannel(channel) |
230 | if (channel) { |
231 | return lookup[channel] && lookup[channel].subscribed |
232 | } |
233 | } |
234 | |
235 | function bumpFilter (msg) { |
236 | const filterResult = msg.filterResult |
237 | if (filterResult) { |
238 | if (isAttendee(msg)) { |
239 | return 'attending' |
240 | } else if (filterResult.following || filterResult.isYours) { |
241 | if (msg.value.content.type === 'post') { |
242 | if (getRoot(msg)) { |
243 | return 'reply' |
244 | } else { |
245 | return 'post' |
246 | } |
247 | } else { |
248 | return 'updated' |
249 | } |
250 | } else if (filterResult.matchesChannel || filterResult.matchingTags.length) { |
251 | const channels = new Set() |
252 | if (filterResult.matchesChannel) channels.add(msg.value.content.channel) |
253 | if (Array.isArray(filterResult.matchingTags)) filterResult.matchingTags.forEach(x => channels.add(x)) |
254 | return { type: 'matches-channel', channels: Array.from(channels) } |
255 | } |
256 | } |
257 | } |
258 | |
259 | function isAttendee (msg) { |
260 | const content = msg.value && msg.value.content |
261 | return (content && content.type === 'about' && content.attendee && !content.attendee.remove) |
262 | } |
263 |
Built with git-ssb-web