Files: 55fc93a9190c25f467ead205ab8d676b5191dbd4 / lib / plugins / public-feed.js
8352 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 | return false |
100 | }), |
101 | |
102 | // MAP ROOT ITEMS |
103 | pull.map(item => { |
104 | const root = item.root || item |
105 | return root |
106 | }), |
107 | |
108 | // RESOLVE ROOTS WITH ABOUTS |
109 | ResolveAbouts({ ssb }), |
110 | |
111 | // ADD THREAD SUMMARY |
112 | Paramap((item, cb) => { |
113 | threadSummary(item.key, { |
114 | pullFilter: pull( |
115 | FilterBlocked([item.value && item.value.author, ssb.id], { isBlocking: ssb.patchwork.contacts.isBlocking }), |
116 | ApplyFilterResult({ ssb }) |
117 | ), |
118 | recentLimit: 3, |
119 | readThread: ssb.patchwork.thread.read, |
120 | bumpFilter: bumpFilter |
121 | }, (err, summary) => { |
122 | if (err) return cb(err) |
123 | cb(null, extend(item, summary, { |
124 | filterResult: undefined, |
125 | rootBump: bumpFilter |
126 | })) |
127 | }) |
128 | }, 20) |
129 | ) |
130 | })) |
131 | }) |
132 | }) |
133 | } |
134 | } |
135 | |
136 | function shouldShow (filterResult) { |
137 | return !!filterResult |
138 | } |
139 | } |
140 | |
141 | function FilterPrivateRoots () { |
142 | return pull.filter(msg => { |
143 | return !msg.root || (msg.root.value && !msg.root.value.private) |
144 | }) |
145 | } |
146 | |
147 | function ApplyRootFilterResult ({ ssb }) { |
148 | return Paramap((item, cb) => { |
149 | if (item.root) { |
150 | getFilterResult(item.root, { ssb }, (err, rootFilterResult) => { |
151 | if (err) return cb(err) |
152 | if (item.filterResult && checkReplyForcesDisplay(item)) { // include this item if it has matching tags or the author is you |
153 | item.root.filterResult = extend(item.filterResult, { forced: true }) |
154 | } else { |
155 | item.root.filterResult = rootFilterResult |
156 | } |
157 | cb(null, item) |
158 | }) |
159 | } else { |
160 | cb(null, item) |
161 | } |
162 | }) |
163 | } |
164 | |
165 | function ApplyFilterResult ({ ssb }) { |
166 | return Paramap((item, cb) => { |
167 | getFilterResult(item, { ssb }, (err, filterResult) => { |
168 | if (err) return cb(err) |
169 | item.filterResult = filterResult |
170 | cb(null, item) |
171 | }) |
172 | }, 10) |
173 | } |
174 | |
175 | function getFilterResult (msg, { ssb }, cb) { |
176 | ssb.patchwork.contacts.isFollowing({ source: ssb.id, dest: msg.value.author }, (err, following) => { |
177 | if (err) return cb(err) |
178 | ssb.patchwork.subscriptions2.get({ id: ssb.id }, (err, subscriptions) => { |
179 | if (err) return cb(err) |
180 | const type = msg.value.content.type |
181 | if (type === 'vote' || type === 'tag') return cb() // filter out likes and tags |
182 | const hasChannel = !!msg.value.content.channel |
183 | const matchesChannel = (type !== 'channel' && checkChannel(subscriptions, msg.value.content.channel)) |
184 | const matchingTags = getMatchingTags(subscriptions, msg.value.content.mentions) |
185 | const isYours = msg.value.author === ssb.id |
186 | const mentionsYou = getMentionsYou([ssb.id], msg.value.content.mentions) |
187 | if (isYours || matchesChannel || matchingTags.length || following || mentionsYou) { |
188 | cb(null, { |
189 | matchingTags, matchesChannel, isYours, following, mentionsYou, hasChannel |
190 | }) |
191 | } else { |
192 | cb() |
193 | } |
194 | }) |
195 | }) |
196 | } |
197 | |
198 | function getMatchingTags (lookup, mentions) { |
199 | if (Array.isArray(mentions)) { |
200 | return mentions.reduce((result, mention) => { |
201 | if (mention && typeof mention.link === 'string' && mention.link.startsWith('#')) { |
202 | if (checkChannel(lookup, mention.link.slice(1))) { |
203 | result.push(normalizeChannel(mention.link.slice(1))) |
204 | } |
205 | } |
206 | return result |
207 | }, []) |
208 | } |
209 | return [] |
210 | } |
211 | |
212 | function getMentionsYou (ids, mentions) { |
213 | if (Array.isArray(mentions)) { |
214 | return mentions.some((mention) => |
215 | mention && typeof mention.link === 'string' && ids.includes(mention.link) |
216 | ) |
217 | } |
218 | } |
219 | |
220 | function checkReplyForcesDisplay (item) { |
221 | const filterResult = item.filterResult || {} |
222 | const matchesTags = filterResult.matchingTags && !!filterResult.matchingTags.length |
223 | return matchesTags || filterResult.isYours |
224 | } |
225 | |
226 | function checkChannel (lookup, channel) { |
227 | if (!lookup) return false |
228 | channel = normalizeChannel(channel) |
229 | if (channel) { |
230 | return lookup[channel] && lookup[channel].subscribed |
231 | } |
232 | } |
233 | |
234 | function bumpFilter (msg) { |
235 | const filterResult = msg.filterResult |
236 | if (filterResult) { |
237 | if (isAttendee(msg)) { |
238 | return 'attending' |
239 | } else if (filterResult.following || filterResult.isYours) { |
240 | if (msg.value.content.type === 'post') { |
241 | if (getRoot(msg)) { |
242 | return 'reply' |
243 | } else { |
244 | return 'post' |
245 | } |
246 | } else { |
247 | return 'updated' |
248 | } |
249 | } else if (filterResult.matchesChannel || filterResult.matchingTags.length) { |
250 | const channels = new Set() |
251 | if (filterResult.matchesChannel) channels.add(msg.value.content.channel) |
252 | if (Array.isArray(filterResult.matchingTags)) filterResult.matchingTags.forEach(x => channels.add(x)) |
253 | return { type: 'matches-channel', channels: Array.from(channels) } |
254 | } |
255 | } |
256 | } |
257 | |
258 | function isAttendee (msg) { |
259 | const content = msg.value && msg.value.content |
260 | return (content && content.type === 'about' && content.attendee && !content.attendee.remove) |
261 | } |
262 |
Built with git-ssb-web