Files: 6791a012f6f25d8aeb64cae36c5f8ff3b21e9132 / app / page / thread.js
5432 bytesRaw
1 | const { h, Struct, computed, map, resolve, onceTrue } = require('mutant') |
2 | const nest = require('depnest') |
3 | const get = require('lodash/get') |
4 | const { isFeed } = require('ssb-ref') |
5 | |
6 | exports.gives = nest('app.page.thread') |
7 | |
8 | exports.needs = nest({ |
9 | 'about.html.avatar': 'first', |
10 | 'app.html.scroller': 'first', |
11 | 'app.html.tabs': 'first', |
12 | 'app.sync.locationId': 'first', |
13 | 'contact.obs.following': 'first', |
14 | 'feed.obs.thread': 'first', |
15 | 'keys.sync.id': 'first', |
16 | 'message.html.compose': 'first', |
17 | 'message.html.render': 'first', |
18 | 'message.async.name': 'first', |
19 | 'sbot.async.run': 'first' |
20 | }) |
21 | |
22 | exports.create = function (api) { |
23 | return nest('app.page.thread', threadPage) |
24 | |
25 | function threadPage (location) { |
26 | let root = get(location, 'value.content.root') || get(location, 'value.content.about') || location.key |
27 | if (location.value && location.value.unbox) { |
28 | // direct link with unbox key |
29 | root = location.key |
30 | } |
31 | const msg = location.key |
32 | if (msg !== root) scrollDownToMessage(msg) |
33 | |
34 | const { messages, isPrivate, rootId, lastId, channel, recps } = api.feed.obs.thread(root) |
35 | const composer = api.message.html.compose({ |
36 | meta: Struct({ |
37 | type: 'post', |
38 | root: rootId, |
39 | branch: lastId, |
40 | channel, |
41 | recps |
42 | }), |
43 | location, |
44 | feedIdsInThread: computed(messages, msgs => msgs.map(m => m.value.author)), |
45 | placeholder: 'Write a reply', |
46 | shrink: false |
47 | }) |
48 | |
49 | onceTrue(channel, ch => { |
50 | const channelInput = composer.querySelector('input') |
51 | channelInput.value = `#${ch}` |
52 | channelInput.disabled = true |
53 | }) |
54 | |
55 | const content = map(messages, m => { |
56 | let msg = resolve(m) |
57 | if (msg.key === location.key && location.value && location.value.unbox) { |
58 | // we have an unbox key, so message is already unboxed |
59 | msg = location |
60 | } |
61 | const message = api.message.html.render(msg, { pageId: root }) |
62 | markReadWhenVisible(message) |
63 | return message |
64 | }, { comparer }) |
65 | |
66 | const { container } = api.app.html.scroller({ prepend: Header({ isPrivate, recps }), content, append: composer }) |
67 | container.classList.add('Thread') |
68 | container.title = root |
69 | api.message.async.name(root, (err, name) => { |
70 | if (err) throw err |
71 | container.title = name |
72 | |
73 | // TODO tidy this up |
74 | // over-ride message.async.name OR create message.async.subject |
75 | onceTrue(messages, msgs => { |
76 | const subject = get(msgs, ' [0].value.content.subject') |
77 | if (!subject) return |
78 | container.title = subject |
79 | }) |
80 | }) |
81 | |
82 | container.scrollDownToMessage = scrollDownToMessage |
83 | container.addQuote = composer.addQuote |
84 | return container |
85 | |
86 | function scrollDownToMessage (id) { |
87 | const locationId = api.app.sync.locationId(location) |
88 | const tabs = api.app.html.tabs() |
89 | locateKey() |
90 | |
91 | function locateKey () { |
92 | // wait till we're on the right page |
93 | if (tabs.currentPage().id !== locationId) return setTimeout(locateKey, 200) |
94 | |
95 | if (!tabs.currentPage().keyboardScroll) return setTimeout(locateKey, 200) |
96 | |
97 | tabs.currentPage().keyboardScroll('first') |
98 | const msg = tabs.currentPage().querySelector(`[data-id='${id}']`) |
99 | if (!msg) return setTimeout(locateKey, 200) |
100 | |
101 | ;(msg.scrollIntoViewIfNeeded || msg.scrollIntoView).call(msg) |
102 | msg.focus() |
103 | } |
104 | } |
105 | } |
106 | |
107 | // only for private threads |
108 | function Header ({ isPrivate, recps }) { |
109 | return computed(isPrivate, isPrivate => { |
110 | if (!isPrivate) return |
111 | |
112 | const myId = api.keys.sync.id() |
113 | const ImFollowing = api.contact.obs.following(myId) |
114 | |
115 | return computed([recps, ImFollowing], (recps, ImFollowing) => { |
116 | recps = recps.map(r => isFeed(r) ? r : r.link) |
117 | |
118 | const strangers = recps |
119 | .filter(r => !Array.from(ImFollowing).includes(r)) |
120 | .filter(r => r !== myId) |
121 | |
122 | return [ |
123 | h('section.recipients', recps.map(r => { |
124 | const className = strangers.includes(r) ? 'warning' : '' |
125 | return h('div', { className }, api.about.html.avatar(r)) |
126 | })), |
127 | strangers.length |
128 | ? h('section.info -warning', 'There is a person in this thread you do not follow (bordered in red). If you think you know this person it might be worth checking their profile to confirm they are who they say they are.') |
129 | : h('section.info', 'These are the other participants in this thread. Once a private thread is started you cannot add people to it.') |
130 | ] |
131 | }) |
132 | }) |
133 | } |
134 | |
135 | // UnreadFeature (search codebase for this if extracting) |
136 | // |
137 | // TODO |
138 | // - extract this into a global depject module? |
139 | // - garbage collect observation? |
140 | |
141 | var observer |
142 | function markReadWhenVisible (el) { |
143 | if (!observer) { |
144 | observer = new IntersectionObserver((entries, observer) => { // eslint-disable-line |
145 | entries |
146 | .filter(e => e.isIntersecting && e.target.dataset && e.target.dataset.key) |
147 | .forEach(e => { |
148 | markRead(e.target.dataset.key, e.target) |
149 | }) |
150 | }, { threshold: 0.35 }) |
151 | } |
152 | |
153 | observer.observe(el) |
154 | } |
155 | |
156 | function markRead (key, el) { |
157 | api.sbot.async.run(server => server.unread.markRead(key, (err, data) => { |
158 | if (err) console.error(err) |
159 | |
160 | if (el) setTimeout(() => el.classList.add('-read'), 2000) |
161 | })) |
162 | } |
163 | } |
164 | |
165 | function comparer (a, b) { |
166 | return get(resolve(a), 'key') === get(resolve(b), 'key') |
167 | } |
168 |
Built with git-ssb-web