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