Files: 3451510316992d414ec76ba5b29681fe359b7428 / lib / depject / page / html / render / message.js
10372 bytesRaw
1 | const { h, when, watch, Proxy, Struct, Array: MutantArray, Value, computed, onceTrue } = require('mutant') |
2 | const nest = require('depnest') |
3 | const ref = require('ssb-ref') |
4 | const AnchorHook = require('../../../../anchor-hook') |
5 | const sort = require('ssb-sort') |
6 | const pull = require('pull-stream') |
7 | const isBlog = require('scuttle-blog/isBlog') |
8 | const Blog = require('scuttle-blog') |
9 | const _ = require('lodash') |
10 | const getRoot = require('../../../../message/sync/root') |
11 | |
12 | exports.needs = nest({ |
13 | 'keys.sync.id': 'first', |
14 | 'sbot.pull.stream': 'first', |
15 | 'message.obs.name': 'first', |
16 | 'message.html.render': 'first', |
17 | 'message.html.compose': 'first', |
18 | 'message.html.missing': 'first', |
19 | 'profile.html.person': 'first', |
20 | 'sbot.async.get': 'first', |
21 | 'intl.sync.i18n': 'first', |
22 | 'sbot.obs.connection': 'first' |
23 | }) |
24 | |
25 | exports.gives = nest('page.html.render') |
26 | |
27 | exports.create = function (api) { |
28 | const i18n = api.intl.sync.i18n |
29 | return nest('page.html.render', function (id) { |
30 | if (!ref.isMsgLink(id)) return |
31 | |
32 | const link = ref.parseLink(id) |
33 | const unbox = link.query && link.query.unbox |
34 | id = link.link |
35 | |
36 | const loader = h('div', { className: 'Loading -large' }) |
37 | |
38 | const result = Proxy(loader) |
39 | const anchor = Value() |
40 | const participants = Proxy([]) |
41 | const messageRefs = MutantArray() |
42 | |
43 | const yourId = api.keys.sync.id() |
44 | |
45 | const meta = Struct({ |
46 | type: 'post', |
47 | root: Proxy(link.link), |
48 | fork: Proxy(undefined), |
49 | branch: Proxy(link.link), |
50 | reply: Proxy(undefined), |
51 | channel: Value(undefined), |
52 | recps: Value(undefined) |
53 | }) |
54 | |
55 | const isRecipient = computed(meta.recps, recps => { |
56 | if (recps == null) return true // not a private message |
57 | return normalizedRecps(recps).includes(yourId) |
58 | }, { idle: true }) |
59 | |
60 | const compose = api.message.html.compose({ |
61 | meta, |
62 | draftKey: id, |
63 | isPrivate: when(meta.recps, true), |
64 | shrink: false, |
65 | participants, |
66 | hooks: [ |
67 | AnchorHook('reply', anchor, (el) => el.focus()) |
68 | ], |
69 | placeholder: when(meta.recps, |
70 | i18n('Write a private reply'), |
71 | when(meta.fork, i18n('Write a public reply in sub-thread (fork)'), i18n('Write a public reply')) |
72 | ) |
73 | }) |
74 | |
75 | get(id, { unbox }, (err, rootMessage) => { |
76 | if (err) { |
77 | return result.set(h('PageHeading', [ |
78 | h('h1', i18n('Cannot load thread')) |
79 | ])) |
80 | } |
81 | |
82 | if (!rootMessage) { |
83 | return result.set(h('PageHeading', [ |
84 | h('h1', i18n('Cannot display message.')) |
85 | ])) |
86 | } |
87 | |
88 | const content = rootMessage.value.content |
89 | messageRefs.push(getMessageRef(rootMessage)) |
90 | |
91 | // Apply the recps of the original root message to all replies. What happens in private stays in private! |
92 | meta.recps.set(content.recps) |
93 | |
94 | if (Array.isArray(content.recps)) { |
95 | // use private recps if available |
96 | participants.set(uniq(normalizedRecps(content.recps))) |
97 | } else { |
98 | // otherwise message authors |
99 | participants.set(computed(messageRefs, messages => { |
100 | return uniq(messages.map(msg => msg && msg.value && msg.value.author)) |
101 | }, { idle: true })) |
102 | } |
103 | |
104 | const root = getRoot(rootMessage) || id |
105 | const isFork = id !== root |
106 | |
107 | meta.channel.set(content.channel) |
108 | meta.root.set(id) |
109 | |
110 | // if we are viewing a message with a root directly, then direct replies fork the original thread |
111 | meta.fork.set(isFork ? root : undefined) |
112 | |
113 | // track message author for resolving missing messages and reply mentions |
114 | meta.reply.set(computed(messageRefs, messages => { |
115 | const result = {} |
116 | const first = messages[0] |
117 | const last = messages[messages.length - 1] |
118 | |
119 | if (first && first.value) { |
120 | result[messages[0].key] = messages[0].value.author |
121 | } |
122 | |
123 | if (last && last !== first && last.value) { |
124 | result[last.key] = last.value.author |
125 | } |
126 | |
127 | return result |
128 | }, { idle: true })) |
129 | |
130 | // set message heads |
131 | meta.branch.set(computed(messageRefs, messages => { |
132 | let branches = sort.heads(messages) |
133 | if (branches.length <= 1) { |
134 | branches = branches[0] |
135 | } |
136 | return branches |
137 | }, { idle: true })) |
138 | |
139 | const rootMessageElement = api.message.html.render(rootMessage, { |
140 | forkedFrom: rootMessage.root, |
141 | pageId: rootMessage.key, |
142 | hooks: [UnreadClassHook(anchor, rootMessage.key)], |
143 | includeForks: false, |
144 | includeReferences: true |
145 | }) |
146 | |
147 | // handle display unknown message types as root |
148 | if (!rootMessageElement) { |
149 | result.set(h('Thread', [ |
150 | isFork ? h('a.full', { href: root, anchor: id }, [i18n('View parent thread')]) : null, |
151 | h('div.messages', [ |
152 | api.message.html.render(rootMessage, { |
153 | renderUnknown: true |
154 | }) |
155 | ]) |
156 | ])) |
157 | |
158 | return |
159 | } |
160 | |
161 | const messagesContainer = h('div.messages', [rootMessageElement]) |
162 | |
163 | const container = h('Thread', [ |
164 | isFork ? h('a.full', { href: root, anchor: id }, [i18n('View parent thread')]) : null, |
165 | messagesContainer, |
166 | when(isRecipient, compose) |
167 | ]) |
168 | |
169 | let sync = false |
170 | pull( |
171 | api.sbot.pull.stream(sbot => sbot.patchwork.thread.sorted({ |
172 | live: true, |
173 | old: true, |
174 | dest: rootMessage.key, |
175 | useBlocksFrom: rootMessage.value.author, |
176 | types: ['post', 'about'] |
177 | })), |
178 | pull.drain(msg => { |
179 | if (msg.sync) { |
180 | // actually add container to DOM when we get sync on thread |
181 | sync = true |
182 | result.set(container) |
183 | } else { |
184 | let element |
185 | if (_.get(msg, 'value.meta.blockedBy.role') === 'threadAuthor') { |
186 | element = h('Message', [ |
187 | h('a.backlink', { |
188 | href: msg.key |
189 | }, [ |
190 | h('strong', [ |
191 | api.profile.html.person(msg.value.author), |
192 | i18n(' replied but is blocked by '), |
193 | api.profile.html.person(msg.value.meta.blockedBy.id), |
194 | ':' |
195 | ]), ' ', |
196 | api.message.obs.name(msg.key) |
197 | ]) |
198 | ]) |
199 | } else { |
200 | element = api.message.html.render(msg, { |
201 | hooks: [UnreadClassHook(anchor, msg.key)], |
202 | includeForks: msg.key !== id, |
203 | includeReferences: true |
204 | }) |
205 | } |
206 | |
207 | // mark messages as new if added in realtime |
208 | if (sync && element && element.classList) { |
209 | element.classList.add('-new') |
210 | setTimeout(() => { |
211 | // remove the new class after 30 seconds |
212 | element.classList.remove('-new') |
213 | }, 30 * 1e3) |
214 | } |
215 | |
216 | messageRefs.push(getMessageRef(msg)) |
217 | messagesContainer.append(h('div', { |
218 | hooks: [AnchorHook(msg.key, anchor, showContext)] |
219 | }, [ |
220 | msg.key !== id ? api.message.html.missing(first(msg.value.content.branch), msg, rootMessage) : null, |
221 | element |
222 | ])) |
223 | |
224 | if (document.activeElement && document.activeElement.nodeName === 'TEXTAREA') { |
225 | // ensure compose box remains on screen even after a post is added |
226 | document.activeElement.scrollIntoViewIfNeeded() |
227 | } |
228 | } |
229 | }) |
230 | ) |
231 | }) |
232 | |
233 | const view = h('div', { className: 'SplitView' }, [ |
234 | h('div.main', { |
235 | intersectionBindingViewport: { rootMargin: '1000px' } |
236 | }, [ |
237 | result |
238 | ]) |
239 | ]) |
240 | |
241 | view.setAnchor = function (value) { |
242 | anchor.set(value) |
243 | } |
244 | |
245 | return view |
246 | }) |
247 | |
248 | function get (id, { unbox }, cb) { |
249 | api.sbot.async.get({ id, private: true, unbox }, (err, value) => { |
250 | if (err) return cb(err) |
251 | const msg = { key: id, value } |
252 | |
253 | const me = api.keys.sync.id() |
254 | onceTrue(api.sbot.obs.connection, sbot => { |
255 | sbot.patchwork.contacts.isBlocking({ source: me, dest: value.author }, (err, blocking) => { |
256 | if (err) return cb(err) |
257 | if (blocking) { |
258 | // Returning null to render 'Cannot display message.' if we've |
259 | // blocked the person |
260 | cb(null, null) |
261 | } else if (isBlog(msg)) { |
262 | Blog(api.sbot.obs.connection).async.get(msg, (err, result) => { |
263 | if (err) return cb(err) |
264 | msg.body = result.body |
265 | cb(null, msg) |
266 | }) |
267 | } else { |
268 | cb(null, msg) |
269 | } |
270 | }) |
271 | }) |
272 | }) |
273 | } |
274 | } |
275 | |
276 | function showContext (element) { |
277 | const scrollParent = getScrollParent(element) |
278 | if (scrollParent) { |
279 | // ensure context is visible |
280 | scrollParent.scrollTop = Math.max(0, scrollParent.scrollTop - 100) |
281 | } |
282 | } |
283 | |
284 | function getScrollParent (element) { |
285 | while (element.parentNode) { |
286 | if (element.parentNode.scrollTop > 10 && isScroller(element.parentNode)) { |
287 | return element.parentNode |
288 | } else { |
289 | element = element.parentNode |
290 | } |
291 | } |
292 | } |
293 | |
294 | function isScroller (element) { |
295 | const value = window.getComputedStyle(element)['overflow-y'] |
296 | return (value === 'auto' || value === 'scroll') |
297 | } |
298 | |
299 | function first (array) { |
300 | if (Array.isArray(array)) { |
301 | return array[0] |
302 | } else { |
303 | return array |
304 | } |
305 | } |
306 | |
307 | function getMessageRef (msg) { |
308 | // only store structure meta data, not full message content to ease memory usage |
309 | if (msg.value && msg.value.content) { |
310 | return { |
311 | key: msg.key, |
312 | value: { |
313 | author: msg.value.author, |
314 | content: { |
315 | root: msg.value.content.root, |
316 | branch: msg.value.content.branch |
317 | } |
318 | } |
319 | } |
320 | } |
321 | } |
322 | |
323 | function UnreadClassHook (anchor, msgId) { |
324 | return function (element) { |
325 | return watch(anchor, (current) => { |
326 | if (current && current.unread && current.unread.includes(msgId)) { |
327 | element.classList.add('-unread') |
328 | } else { |
329 | element.classList.remove('-unread') |
330 | } |
331 | }) |
332 | } |
333 | } |
334 | |
335 | function normalizedRecps (recps) { |
336 | return Array.isArray(recps) && recps.map(recp => { |
337 | if (recp == null) return null |
338 | if (typeof recp === 'string') { |
339 | return recp |
340 | } |
341 | // if recp is mentions object |
342 | if (typeof recp === 'object') { |
343 | return recp.link |
344 | } |
345 | return null |
346 | }) |
347 | } |
348 | |
349 | function uniq (array) { |
350 | return Array.from(new Set(array)) |
351 | } |
352 |
Built with git-ssb-web