Files: 535b9a31c844497006ff5f0bf0dc391d3df69ae0 / app / page / posts.js
7240 bytesRaw
1 | const nest = require('depnest') |
2 | const { h, Value, Array: MutantArray, Struct, computed, when, map } = require('mutant') |
3 | const pull = require('pull-stream') |
4 | const Scroller = require('mutant-scroll') |
5 | const next = require('pull-next-query') |
6 | const merge = require('lodash/merge') |
7 | const get = require('lodash/get') |
8 | const sort = require('ssb-sort') |
9 | |
10 | exports.gives = nest({ |
11 | 'app.html.menuItem': true, |
12 | 'app.page.posts': true |
13 | }) |
14 | |
15 | exports.needs = nest({ |
16 | 'about.obs.name': 'first', |
17 | 'about.html.avatar': 'first', |
18 | 'about.html.link': 'first', |
19 | 'app.sync.goTo': 'first', |
20 | // 'feed.pull.public': 'first', |
21 | 'sbot.async.get': 'first', |
22 | 'sbot.pull.stream': 'first', |
23 | 'message.html.compose': 'first', |
24 | 'message.html.markdown': 'first', |
25 | 'message.html.timestamp': 'first', |
26 | 'message.obs.backlinks': 'first' |
27 | }) |
28 | |
29 | exports.create = function (api) { |
30 | return nest({ |
31 | 'app.html.menuItem': menuItem, |
32 | 'app.page.posts': postsPage, |
33 | }) |
34 | |
35 | function menuItem () { |
36 | return h('a', { |
37 | style: { order: 1 }, |
38 | 'ev-click': () => api.app.sync.goTo({ page: 'posts' }) |
39 | }, '/posts') |
40 | } |
41 | |
42 | function postsPage (location) { |
43 | const BY_UPDATE = 'by update' |
44 | const BY_ROOT = 'by root' |
45 | |
46 | const state = Value(BY_UPDATE) |
47 | const viewSettings = h('section.viewSettings', [ |
48 | h('button', 'hey!') |
49 | ]) |
50 | |
51 | return computed(state, state => { |
52 | var page = byUpdatePage() |
53 | |
54 | page.title = '/posts' |
55 | page.id = '{"page": "posts"}' // this is needed because our page is a computed |
56 | page.scroll = keyscroll(page.querySelector('section.content')) |
57 | return page |
58 | }) |
59 | |
60 | function byUpdatePage () { |
61 | return Scroller({ |
62 | classList: ['Posts'], |
63 | prepend: [ |
64 | viewSettings, |
65 | Composer(location) |
66 | ], |
67 | streamToTop: createStream({ live: true, old: false }), |
68 | streamToBottom: createStream({ reverse: true }), |
69 | updateTop: (soFar, msg) => { |
70 | const root = getRoot(msg) |
71 | if (soFar.includes(root)) soFar.delete(root) |
72 | soFar.insert(root) |
73 | }, |
74 | updateBottom: (soFar, msg) => { |
75 | const root = getRoot(msg) |
76 | if (!soFar.includes(root)) soFar.push(root) |
77 | }, |
78 | render |
79 | }) |
80 | |
81 | function createStream (opts) { |
82 | return api.sbot.pull.stream(server => { |
83 | // by_update - stream by receive time |
84 | const defaults = { |
85 | limit: 50, |
86 | query: [{ |
87 | $filter: { |
88 | timestamp: { $gt: 0 }, |
89 | value: { |
90 | content: { |
91 | type: 'post', |
92 | recps: { $not: true } |
93 | } |
94 | } |
95 | } |
96 | }] |
97 | } |
98 | return next(server.query.read, merge({}, defaults, opts), ['timestamp']) |
99 | }) |
100 | } |
101 | } |
102 | } |
103 | |
104 | function Composer (location) { |
105 | return api.message.html.compose({ |
106 | location, |
107 | meta: { type: 'post' }, |
108 | placeholder: 'Write a public message' |
109 | }) |
110 | } |
111 | |
112 | // TODO - move out into message.html.render ? |
113 | function render (key) { |
114 | const root = Struct({ |
115 | avatar: '', |
116 | author: '', |
117 | timestamp: '', |
118 | md: '' |
119 | }) |
120 | api.sbot.async.get(key, (err, value) => { |
121 | if (err) console.error('ThreadCard could not fetch ', key) |
122 | root.avatar.set(api.about.html.avatar(value.author)) |
123 | root.author.set(api.about.html.link(value.author)) |
124 | root.timestamp.set(api.message.html.timestamp({ key, value })) |
125 | root.md.set(api.message.html.markdown(value.content)) |
126 | }) |
127 | |
128 | const repliesCount = Value() |
129 | const recent = MutantArray([]) |
130 | const likesCount = Value() |
131 | const backlinksCount = Value() |
132 | const participants = MutantArray([]) |
133 | |
134 | const opts = { |
135 | query: [{ |
136 | $filter: { dest: key } |
137 | }], |
138 | index: 'DTA' // asserted timestamp |
139 | } |
140 | pull( |
141 | api.sbot.pull.stream(server => server.backlinks.read(opts)), |
142 | pull.collect((err, msgs) => { |
143 | if (err) console.error(err) |
144 | |
145 | msgs = sort(msgs) |
146 | |
147 | const replies = msgs |
148 | .filter(isPost) |
149 | .filter(m => getRoot(m) === key) |
150 | |
151 | repliesCount.set(replies.length) |
152 | recent.set(lastFew(replies)) |
153 | |
154 | const likes = msgs.filter(isLikeOf(key)) |
155 | likesCount.set(likes.length) |
156 | |
157 | const backlinks = msgs |
158 | .filter(isPost) |
159 | .filter(m => getRoot(m) !== key) |
160 | backlinksCount.set(backlinks.length) |
161 | |
162 | const authors = replies |
163 | .map(m => m.value.author) |
164 | participants.set(Array.from(new Set(authors))) |
165 | }) |
166 | ) |
167 | |
168 | const className = computed(root.md, r => r ? '' : '-loading') |
169 | const onClick = ev => { |
170 | ev.preventDefault() |
171 | ev.stopPropagation() |
172 | api.app.sync.goTo(key) |
173 | } |
174 | |
175 | return h('ThreadCard', |
176 | { |
177 | className, |
178 | attributes: { |
179 | tabindex: '0', // needed to be able to navigate and show focus() |
180 | 'data-id': key // TODO do this with decorators? |
181 | } |
182 | }, [ |
183 | h('section.context', [ |
184 | h('div.avatar', root.avatar), |
185 | h('div.name', root.author), |
186 | h('div.timestamp', root.timestamp), |
187 | h('div.counts', [ |
188 | h('div.comments', [ repliesCount, h('i.fa.fa-comment-o') ]), |
189 | h('div.likes', [ likesCount, h('i.fa.fa-heart-o') ]), |
190 | h('div.backlinks', [ backlinksCount, h('i.fa.fa-link') ]) |
191 | ]), |
192 | h('div.participants', map(participants, api.about.html.avatar)) |
193 | ]), |
194 | h('section.content-preview', { 'ev-click': onClick }, [ |
195 | h('div.root', root.md), |
196 | h('div.recent', map(recent, msg => { |
197 | return h('div.msg', [ |
198 | h('div.author', api.about.obs.name(msg.value.author)), |
199 | ': ', |
200 | h('div.preview', [ |
201 | api.message.html.markdown(msg.value.content).innerText.slice(0, 120), |
202 | '...' |
203 | ]) |
204 | ]) |
205 | })) |
206 | ]) |
207 | ]) |
208 | } |
209 | } |
210 | |
211 | function getRoot (msg) { |
212 | return get(msg, 'value.content.root', msg.key) |
213 | } |
214 | |
215 | function isPost (msg) { |
216 | return get(msg, 'value.content.type') === 'post' |
217 | } |
218 | |
219 | function isLikeOf (key) { |
220 | return function (msg) { |
221 | return get(msg, 'value.content.type') === 'vote' && |
222 | get(msg, 'value.content.vote.link') === key |
223 | } |
224 | } |
225 | |
226 | function lastFew (arr) { |
227 | return arr.reverse().slice(0, 3).reverse() |
228 | } |
229 | |
230 | // copied from app.html.scroller |
231 | function keyscroll (content) { |
232 | var curMsgEl |
233 | |
234 | if (!content) return () => {} |
235 | |
236 | content.addEventListener('click', onActivateChild, false) |
237 | content.addEventListener('focus', onActivateChild, true) |
238 | |
239 | function onActivateChild (ev) { |
240 | for (var el = ev.target; el; el = el.parentNode) { |
241 | if (el.parentNode === content) { |
242 | curMsgEl = el |
243 | return |
244 | } |
245 | } |
246 | } |
247 | |
248 | return function scroll (d) { |
249 | selectChild((!curMsgEl || d === 'first') ? content.firstChild |
250 | : d < 0 ? curMsgEl.previousElementSibling || content.firstChild |
251 | : d > 0 ? curMsgEl.nextElementSibling || content.lastChild |
252 | : curMsgEl) |
253 | |
254 | return curMsgEl |
255 | } |
256 | |
257 | function selectChild (el) { |
258 | if (!el) { return } |
259 | |
260 | if (!el.scrollIntoViewIfNeeded && !el.scrollIntoView) return |
261 | ;(el.scrollIntoViewIfNeeded || el.scrollIntoView).call(el) |
262 | el.focus() |
263 | curMsgEl = el |
264 | } |
265 | } |
266 | |
267 |
Built with git-ssb-web