Commit 4a58e38afe8d7cd5659d286607457aeb91ee7638
Merge branch 'master' into blogSearch
mix irving committed on 11/29/2017, 1:47:44 AMParent: 35e506868ea54929c5978b29572a923888e2518d
Parent: 325cc232dd44d379082757ecd6a63f9305c81f9f
Files changed
about/html/avatar.js | ||
---|---|---|
@@ -1,22 +1,26 @@ | ||
1 | 1 | const nest = require('depnest') |
2 | 2 | const { h } = require('mutant') |
3 | 3 | |
4 | +exports.gives = nest('about.html.avatar') | |
5 | + | |
4 | 6 | exports.needs = nest({ |
5 | - 'about.html.image': 'first', | |
6 | - 'app.html.link': 'first' | |
7 | + 'about.obs.imageUrl': 'first', | |
8 | + 'about.obs.color': 'first', | |
9 | + 'history.sync.push': 'first' | |
7 | 10 | }) |
8 | 11 | |
9 | -exports.gives = nest('about.html.avatar') | |
10 | - | |
11 | 12 | exports.create = function (api) { |
12 | - return nest('about.html.avatar', feed => { | |
13 | - const Link = api.app.html.link | |
14 | - | |
15 | - return Link( | |
16 | - { page: 'userShow', feed }, | |
17 | - api.about.html.image(feed) | |
18 | - ) | |
19 | - | |
13 | + return nest('about.html.avatar', function (id, size = 'small') { | |
14 | + return h('img', { | |
15 | + classList: `Avatar -${size}`, | |
16 | + style: { 'background-color': api.about.obs.color(id) }, | |
17 | + src: api.about.obs.imageUrl(id), | |
18 | + title: id, | |
19 | + 'ev-click': e => { | |
20 | + e.stopPropagation() | |
21 | + api.history.sync.push({ page: 'userShow', feed: id }) | |
22 | + } | |
23 | + }) | |
20 | 24 | }) |
21 | 25 | } |
22 | 26 |
about/html/avatar.mcss | ||
---|---|---|
@@ -1,5 +1,21 @@ | ||
1 | 1 | Avatar { |
2 | - $avatarSmall | |
3 | - margin-right: .5rem | |
2 | + cursor: pointer | |
3 | + $circleSmall | |
4 | + | |
5 | + -tiny { | |
6 | + $circleTiny | |
7 | + } | |
8 | + | |
9 | + -small { | |
10 | + $circleSmall | |
11 | + } | |
12 | + | |
13 | + -medium { | |
14 | + $circleMedium | |
15 | + } | |
16 | + | |
17 | + -large { | |
18 | + $circleLarge | |
19 | + } | |
4 | 20 | } |
5 | 21 |
app/async/catch-link-click.js | ||
---|---|---|
@@ -1,9 +1,14 @@ | ||
1 | 1 | const nest = require('depnest') |
2 | 2 | const Url = require('url') |
3 | +const { isMsg } = require('ssb-ref') | |
3 | 4 | |
4 | 5 | exports.gives = nest('app.async.catchLinkClick') |
5 | 6 | |
7 | +exports.needs = nest({ | |
8 | + 'sbot.async.get': 'first' | |
9 | +}) | |
10 | + | |
6 | 11 | exports.create = function (api) { |
7 | 12 | return nest('app.async.catchLinkClick', catchLinkClick) |
8 | 13 | |
9 | 14 | function catchLinkClick (root, cb) { |
@@ -29,8 +34,16 @@ | ||
29 | 34 | const opts = { |
30 | 35 | isExternal: !!url.host |
31 | 36 | } |
32 | 37 | |
38 | + if (isMsg(href)) { | |
39 | + api.sbot.async.get(href, (err, data) => { | |
40 | + // NOTE the catchLinkClick cb has signature (link, opts) | |
41 | + cb(err || data, opts) | |
42 | + }) | |
43 | + return | |
44 | + } | |
45 | + | |
33 | 46 | cb(href, opts) |
34 | 47 | }) |
35 | 48 | } |
36 | 49 | } |
app/html/app.js | ||
---|---|---|
@@ -1,79 +1,56 @@ | ||
1 | 1 | const nest = require('depnest') |
2 | -const values = require('lodash/values') | |
3 | -const insertCss = require('insert-css') | |
4 | -const openExternal = require('open-external') | |
2 | +const { h, Value } = require('mutant') | |
5 | 3 | |
6 | -const HyperNav = require('hyper-nav') | |
7 | -const computed = require('mutant/computed') | |
8 | -const h = require('mutant/h') | |
4 | +exports.gives = nest('app.html.app') | |
9 | 5 | |
10 | -exports.gives = nest({ | |
11 | - 'app.html.app': true, | |
12 | - 'history.obs.history': true, | |
13 | - 'history.sync.push': true, | |
14 | - 'history.sync.back': true, | |
15 | -}) | |
16 | - | |
17 | 6 | exports.needs = nest({ |
18 | - 'about.async.suggest': 'first', | |
7 | + 'app.sync.initialize': 'map', | |
19 | 8 | 'app.html.header': 'first', |
20 | - 'app.async.catchLinkClick': 'first', | |
21 | - 'channel.async.suggest': 'first', | |
9 | + 'history.obs.location': 'first', | |
10 | + 'history.sync.push': 'first', | |
22 | 11 | 'keys.sync.id': 'first', |
23 | 12 | 'router.sync.router': 'first', |
24 | 13 | 'settings.sync.get': 'first', |
25 | 14 | 'settings.sync.set': 'first', |
26 | - 'styles.css': 'reduce', | |
27 | 15 | }) |
28 | 16 | |
29 | 17 | exports.create = (api) => { |
30 | - var nav = null | |
31 | - | |
32 | 18 | return nest({ |
33 | 19 | 'app.html.app': function app () { |
20 | + api.app.sync.initialize() | |
34 | 21 | |
35 | - // DIRTY HACK - initializes the suggestion indexes | |
36 | - api.about.async.suggest() | |
37 | - api.channel.async.suggest() | |
22 | + var view = Value() | |
23 | + var app = h('App', view) | |
24 | + api.history.obs.location()(renderLocation) | |
25 | + function renderLocation (loc) { | |
26 | + var page = api.router.sync.router(loc) | |
27 | + if (page) view.set([ | |
28 | + api.app.html.header({location: loc, push: api.history.sync.push}), | |
29 | + page | |
30 | + ]) | |
31 | + } | |
38 | 32 | |
39 | - const css = values(api.styles.css()).join('\n') | |
40 | - insertCss(css) | |
41 | - | |
42 | - api.app.async.catchLinkClick(document.body, (link, { isExternal }) => { | |
43 | - if (isExternal) return openExternal(link) | |
44 | - nav.push(link) | |
45 | - }) | |
46 | - | |
47 | - nav = HyperNav( | |
48 | - api.router.sync.router, | |
49 | - api.app.html.header | |
50 | - ) | |
51 | - | |
52 | 33 | const isOnboarded = api.settings.sync.get('onboarded') |
53 | 34 | if (isOnboarded) |
54 | - nav.push({page: 'home'}) | |
35 | + api.history.sync.push({page: 'home'}) | |
55 | 36 | else { |
56 | - nav.push({ | |
37 | + api.history.sync.push({ | |
57 | 38 | page:'userEdit', |
58 | 39 | feed: api.keys.sync.id(), |
59 | 40 | callback: (err, didEdit) => { |
60 | 41 | if (err) throw new Error ('Error editing profile', err) |
61 | 42 | |
62 | 43 | if (didEdit) |
63 | 44 | api.settings.sync.set({ onboarded: true }) |
64 | 45 | |
65 | - nav.push({ page: 'home' }) | |
46 | + api.history.sync.push({ page: 'home' }) | |
66 | 47 | } |
67 | 48 | }) |
68 | 49 | } |
69 | 50 | |
70 | - return nav | |
71 | - }, | |
72 | - 'history.sync.push': (location) => nav.push(location), | |
73 | - 'history.sync.back': () => nav.back(), | |
74 | - 'history.obs.history': () => nav.history, | |
51 | + | |
52 | + return app | |
53 | + } | |
75 | 54 | }) |
76 | 55 | } |
77 | 56 | |
78 | - | |
79 | - |
app/html/context.js | ||
---|---|---|
@@ -1,135 +1,245 @@ | ||
1 | 1 | const nest = require('depnest') |
2 | -const { h, computed, map, when, Dict, dictToCollection, Array: MutantArray, resolve } = require('mutant') | |
2 | +const { h, computed, map, when, Dict, Array: MutantArray, Value, Set, resolve } = require('mutant') | |
3 | 3 | const pull = require('pull-stream') |
4 | 4 | const next = require('pull-next-step') |
5 | 5 | const get = require('lodash/get') |
6 | 6 | const isEmpty = require('lodash/isEmpty') |
7 | 7 | |
8 | 8 | exports.gives = nest('app.html.context') |
9 | 9 | |
10 | 10 | exports.needs = nest({ |
11 | + 'app.html.scroller': 'first', | |
11 | 12 | 'about.html.avatar': 'first', |
12 | 13 | 'about.obs.name': 'first', |
13 | 14 | 'feed.pull.private': 'first', |
14 | 15 | 'feed.pull.rollup': 'first', |
16 | + 'feed.pull.public': 'first', | |
15 | 17 | 'keys.sync.id': 'first', |
16 | 18 | 'history.sync.push': 'first', |
17 | 19 | 'message.html.subject': 'first', |
18 | 20 | 'sbot.obs.localPeers': 'first', |
19 | 21 | 'translations.sync.strings': 'first', |
22 | + 'unread.sync.isUnread': 'first' | |
20 | 23 | }) |
21 | 24 | |
22 | - | |
23 | 25 | exports.create = (api) => { |
24 | - return nest('app.html.context', (location) => { | |
26 | + var recentMsgCache = MutantArray() | |
27 | + var usersLastMsgCache = Dict() // { id: [ msgs ] } | |
28 | + var usersUnreadMsgsCache = Dict() // { id: [ msgs ] } | |
25 | 29 | |
30 | + return nest('app.html.context', context) | |
31 | + | |
32 | + function context (location) { | |
26 | 33 | const strings = api.translations.sync.strings() |
27 | 34 | const myKey = api.keys.sync.id() |
28 | 35 | |
29 | 36 | var nearby = api.sbot.obs.localPeers() |
30 | - var recentPeersContacted = Dict() | |
31 | - // TODO - extract as contact.obs.recentPrivate or something | |
32 | 37 | |
38 | + // Unread message counts | |
39 | + const updateUserUnreadMsgsCache = (msg) => { | |
40 | + var cache = getUserUnreadMsgsCache(msg.value.author) | |
41 | + | |
42 | + if(api.unread.sync.isUnread(msg)) | |
43 | + cache.add(msg.key) | |
44 | + else | |
45 | + cache.delete(msg.key) | |
46 | + } | |
33 | 47 | pull( |
34 | - next(api.feed.pull.private, {reverse: true, limit: 100, live: false}, ['value', 'timestamp']), | |
35 | - pull.filter(msg => msg.value.content.type === 'post'), // TODO is this the best way to protect against votes? | |
36 | - pull.filter(msg => msg.value.content.recps), | |
37 | - pull.drain(msg => { | |
38 | - msg.value.content.recps | |
39 | - .map(recp => typeof recp === 'object' ? recp.link : recp) | |
40 | - .filter(recp => recp != myKey) | |
41 | - .forEach(recp => { | |
42 | - if (recentPeersContacted.has(recp)) return | |
48 | + next(api.feed.pull.private, {reverse: true, limit: 1000, live: false, property: ['value', 'timestamp']}), | |
49 | + privateMsgFilter(), | |
50 | + pull.drain(updateUserUnreadMsgsCache) | |
51 | + ) | |
43 | 52 | |
44 | - recentPeersContacted.put(recp, msg) | |
45 | - }) | |
46 | - }) | |
53 | + pull( | |
54 | + next(api.feed.pull.private, {old: false, live: true, property: ['value', 'timestamp']}), | |
55 | + privateMsgFilter(), | |
56 | + pull.drain(updateUserUnreadMsgsCache) | |
47 | 57 | ) |
48 | 58 | |
59 | + //TODO: calculate unread state for public threads/blogs | |
60 | + // pull( | |
61 | + // next(api.feed.pull.public, {reverse: true, limit: 100, live: false, property: ['value', 'timestamp']}), | |
62 | + // pull.drain(msg => { | |
63 | + // | |
64 | + // }) | |
65 | + // ) | |
66 | + | |
49 | 67 | return h('Context -feed', [ |
50 | 68 | LevelOneContext(), |
51 | 69 | LevelTwoContext() |
52 | 70 | ]) |
53 | 71 | |
54 | 72 | function LevelOneContext () { |
55 | - const PAGES_UNDER_DISCOVER = ['blogIndex', 'blogShow', 'home'] | |
73 | + function isDiscoverContext (loc) { | |
74 | + const PAGES_UNDER_DISCOVER = ['blogIndex', 'blogShow', 'home'] | |
56 | 75 | |
57 | - return h('div.level.-one', [ | |
76 | + return PAGES_UNDER_DISCOVER.includes(location.page) | |
77 | + || get(location, 'value.private') === undefined | |
78 | + } | |
79 | + | |
80 | + const prepend = [ | |
58 | 81 | // Nearby |
59 | 82 | computed(nearby, n => !isEmpty(n) ? h('header', strings.peopleNearby) : null), |
60 | 83 | map(nearby, feedId => Option({ |
61 | - notifications: Math.random() > 0.7 ? Math.floor(Math.random()*9+1) : 0, // TODO | |
62 | - imageEl: api.about.html.avatar(feedId), | |
84 | + notifications: notifications(feedId), | |
85 | + imageEl: api.about.html.avatar(feedId, 'small'), | |
63 | 86 | label: api.about.obs.name(feedId), |
64 | 87 | selected: location.feed === feedId, |
65 | - location: computed(recentPeersContacted, recent => { | |
66 | - const lastMsg = recent[feedId] | |
88 | + location: computed(recentMsgCache, recent => { | |
89 | + const lastMsg = recent.find(msg => msg.value.author === feedId) | |
67 | 90 | return lastMsg |
68 | 91 | ? Object.assign(lastMsg, { feed: feedId }) |
69 | 92 | : { page: 'threadNew', feed: feedId } |
70 | 93 | }), |
71 | - })), | |
94 | + }), { comparer: (a, b) => a === b }), | |
95 | + | |
96 | + // --------------------- | |
72 | 97 | computed(nearby, n => !isEmpty(n) ? h('hr') : null), |
73 | 98 | |
74 | 99 | // Discover |
75 | 100 | Option({ |
76 | - notifications: Math.floor(Math.random()*5+1), | |
101 | + // notifications: '!', //TODO - count this! | |
77 | 102 | imageEl: h('i.fa.fa-binoculars'), |
78 | 103 | label: strings.blogIndex.title, |
79 | - selected: PAGES_UNDER_DISCOVER.includes(location.page), | |
104 | + selected: isDiscoverContext(location), | |
80 | 105 | location: { page: 'blogIndex' }, |
81 | - }), | |
106 | + }) | |
107 | + ] | |
82 | 108 | |
83 | - // Recent Messages | |
84 | - map(dictToCollection(recentPeersContacted), ({ key, value }) => { | |
85 | - const feedId = key() | |
86 | - const lastMsg = value() | |
87 | - if (nearby.has(feedId)) return | |
109 | + return api.app.html.scroller({ | |
110 | + classList: [ 'level', '-one' ], | |
111 | + prepend, | |
112 | + stream: api.feed.pull.private, | |
113 | + filter: privateMsgFilter, | |
114 | + store: recentMsgCache, | |
115 | + updateTop: updateRecentMsgCache, | |
116 | + updateBottom: updateRecentMsgCache, | |
117 | + render: (msgObs) => { | |
118 | + const msg = resolve(msgObs) | |
119 | + const { author } = msg.value | |
120 | + if (nearby.has(author)) return | |
88 | 121 | |
89 | 122 | return Option({ |
90 | - notifications: Math.random() > 0.7 ? Math.floor(Math.random()*9+1) : 0, // TODO | |
91 | - imageEl: api.about.html.avatar(feedId), | |
92 | - label: api.about.obs.name(feedId), | |
93 | - selected: location.feed === feedId, | |
94 | - location: Object.assign({}, lastMsg, { feed: feedId }) // TODO make obs? | |
123 | + //the number of threads with each peer | |
124 | + notifications: notifications(author), | |
125 | + imageEl: api.about.html.avatar(author), | |
126 | + label: api.about.obs.name(author), | |
127 | + selected: location.feed === author, | |
128 | + location: Object.assign({}, msg, { feed: author }) // TODO make obs? | |
95 | 129 | }) |
130 | + } | |
131 | + }) | |
132 | + | |
133 | + function updateRecentMsgCache (soFar, newMsg) { | |
134 | + soFar.transaction(() => { | |
135 | + const { author, timestamp } = newMsg.value | |
136 | + const index = indexOf(soFar, (msg) => author === resolve(msg).value.author) | |
137 | + var object = Value() | |
138 | + | |
139 | + if (index >= 0) { | |
140 | + // reference already exists, lets use this instead! | |
141 | + const existingMsg = soFar.get(index) | |
142 | + | |
143 | + if (resolve(existingMsg).value.timestamp > timestamp) return | |
144 | + // but abort if the existing reference is newer | |
145 | + | |
146 | + object = existingMsg | |
147 | + soFar.deleteAt(index) | |
148 | + } | |
149 | + | |
150 | + object.set(newMsg) | |
151 | + | |
152 | + const justOlderPosition = indexOf(soFar, (msg) => timestamp > resolve(msg).value.timestamp) | |
153 | + if (justOlderPosition > -1) { | |
154 | + soFar.insert(object, justOlderPosition) | |
155 | + } else { | |
156 | + soFar.push(object) | |
157 | + } | |
96 | 158 | }) |
97 | - ]) | |
159 | + } | |
160 | + | |
98 | 161 | } |
99 | 162 | |
163 | + function getUserUnreadMsgsCache (author) { | |
164 | + var cache = usersUnreadMsgsCache.get(author) | |
165 | + if (!cache) { | |
166 | + cache = Set () | |
167 | + usersUnreadMsgsCache.put(author, cache) | |
168 | + } | |
169 | + return cache | |
170 | + } | |
171 | + | |
172 | + function notifications (author) { | |
173 | + return computed(getUserUnreadMsgsCache(author), cache => cache.length) | |
174 | + | |
175 | + // TODO find out why this doesn't work | |
176 | + // return getUserUnreadMsgsCache(feedId) | |
177 | + // .getLength | |
178 | + } | |
179 | + | |
100 | 180 | function LevelTwoContext () { |
101 | 181 | const { key, value, feed: targetUser, page } = location |
102 | 182 | const root = get(value, 'content.root', key) |
103 | 183 | if (!targetUser) return |
104 | 184 | |
105 | - var threads = MutantArray() | |
106 | 185 | |
107 | - pull( | |
108 | - next(api.feed.pull.private, {reverse: true, limit: 100, live: false}, ['value', 'timestamp']), | |
109 | - pull.filter(msg => msg.value.content.recps), | |
110 | - pull.filter(msg => msg.value.content.recps | |
111 | - .map(recp => typeof recp === 'object' ? recp.link : recp) | |
112 | - .some(recp => recp === targetUser) | |
186 | + const prepend = Option({ | |
187 | + selected: page === 'threadNew', | |
188 | + location: {page: 'threadNew', feed: targetUser}, | |
189 | + label: h('Button', strings.threadNew.action.new), | |
190 | + }) | |
191 | + | |
192 | + var userLastMsgCache = usersLastMsgCache.get(targetUser) | |
193 | + if (!userLastMsgCache) { | |
194 | + userLastMsgCache = MutantArray() | |
195 | + usersLastMsgCache.put(targetUser, userLastMsgCache) | |
196 | + } | |
197 | + | |
198 | + return api.app.html.scroller({ | |
199 | + classList: [ 'level', '-two' ], | |
200 | + prepend, | |
201 | + stream: api.feed.pull.private, | |
202 | + filter: () => pull( | |
203 | + pull.filter(msg => !msg.value.content.root), | |
204 | + pull.filter(msg => msg.value.content.type === 'post'), | |
205 | + pull.filter(msg => msg.value.content.recps), | |
206 | + pull.filter(msg => msg.value.content.recps | |
207 | + .map(recp => typeof recp === 'object' ? recp.link : recp) | |
208 | + .some(recp => recp === targetUser) | |
209 | + ) | |
113 | 210 | ), |
114 | - api.feed.pull.rollup(), | |
115 | - pull.drain(thread => threads.push(thread)) | |
116 | - ) | |
117 | - | |
118 | - return h('div.level.-two', [ | |
119 | - Option({ | |
120 | - selected: page === 'threadNew', | |
121 | - location: {page: 'threadNew', feed: targetUser}, | |
122 | - label: h('Button', strings.threadNew.action.new), | |
123 | - }), | |
124 | - map(threads, thread => { | |
211 | + store: userLastMsgCache, | |
212 | + updateTop: updateLastMsgCache, | |
213 | + updateBottom: updateLastMsgCache, | |
214 | + render: (rootMsgObs) => { | |
215 | + const rootMsg = resolve(rootMsgObs) | |
125 | 216 | return Option({ |
126 | - label: api.message.html.subject(thread), | |
127 | - selected: thread.key === root, | |
128 | - location: Object.assign(thread, { feed: targetUser }), | |
217 | + label: api.message.html.subject(rootMsg), | |
218 | + selected: rootMsg.key === root, | |
219 | + location: Object.assign(rootMsg, { feed: targetUser }), | |
129 | 220 | }) |
221 | + } | |
222 | + }) | |
223 | + | |
224 | + function updateLastMsgCache (soFar, newMsg) { | |
225 | + soFar.transaction(() => { | |
226 | + const { timestamp } = newMsg.value | |
227 | + const index = indexOf(soFar, (msg) => timestamp === resolve(msg).value.timestamp) | |
228 | + | |
229 | + if (index >= 0) return | |
230 | + // if reference already exists, abort | |
231 | + | |
232 | + var object = Value(newMsg) | |
233 | + | |
234 | + const justOlderPosition = indexOf(soFar, (msg) => timestamp > resolve(msg).value.timestamp) | |
235 | + if (justOlderPosition > -1) { | |
236 | + soFar.insert(object, justOlderPosition) | |
237 | + } else { | |
238 | + soFar.push(object) | |
239 | + } | |
130 | 240 | }) |
131 | - ]) | |
241 | + } | |
132 | 242 | } |
133 | 243 | |
134 | 244 | function Option ({ notifications = 0, imageEl, label, location, selected }) { |
135 | 245 | const className = selected ? '-selected' : '' |
@@ -152,7 +262,24 @@ | ||
152 | 262 | ]), |
153 | 263 | h('div.label', { 'ev-click': goToLocation }, label) |
154 | 264 | ]) |
155 | 265 | } |
156 | - }) | |
266 | + | |
267 | + function privateMsgFilter () { | |
268 | + return pull( | |
269 | + pull.filter(msg => msg.value.content.type === 'post'), | |
270 | + pull.filter(msg => msg.value.author != myKey), | |
271 | + pull.filter(msg => msg.value.content.recps) | |
272 | + ) | |
273 | + } | |
274 | + } | |
157 | 275 | } |
158 | 276 | |
277 | +function indexOf (array, fn) { | |
278 | + for (var i = 0; i < array.getLength(); i++) { | |
279 | + if (fn(array.get(i))) { | |
280 | + return i | |
281 | + } | |
282 | + } | |
283 | + return -1 | |
284 | +} | |
285 | + |
app/html/context.mcss | ||
---|---|---|
@@ -1,9 +1,9 @@ | ||
1 | 1 | Context { |
2 | 2 | flex-shrink: 0 |
3 | 3 | flex-grow: 0 |
4 | 4 | overflow: hidden |
5 | - background-color: #fff | |
5 | + $backgroundPrimaryText | |
6 | 6 | |
7 | 7 | display: flex |
8 | 8 | |
9 | 9 | div.level { |
@@ -12,25 +12,33 @@ | ||
12 | 12 | overflow-x: hidden |
13 | 13 | |
14 | 14 | border-right: 1px gainsboro solid |
15 | 15 | |
16 | - div.Option {} | |
16 | + div.wrapper { | |
17 | + section { | |
18 | + header { | |
19 | + $colorSubtle | |
20 | + padding: .5rem 1rem | |
21 | + } | |
17 | 22 | |
18 | - header { | |
19 | - $colorSubtle | |
20 | - padding: .5rem 1rem | |
21 | - } | |
23 | + div.Option {} | |
22 | 24 | |
23 | - hr { | |
24 | - border: 1px solid gainsboro | |
25 | - border-bottom: none | |
26 | - margin: 0 | |
25 | + hr { | |
26 | + border: 1px solid gainsboro | |
27 | + border-bottom: none | |
28 | + margin: 0 | |
29 | + } | |
30 | + } | |
27 | 31 | } |
28 | 32 | |
29 | 33 | -one {} |
30 | 34 | -two { |
31 | - div.Option { | |
32 | - padding: 0 1rem | |
35 | + div.wrapper { | |
36 | + section { | |
37 | + div.Option { | |
38 | + padding: 0 1rem | |
39 | + } | |
40 | + } | |
33 | 41 | } |
34 | 42 | } |
35 | 43 | } |
36 | 44 | } |
@@ -74,9 +82,9 @@ | ||
74 | 82 | a img { |
75 | 83 | |
76 | 84 | } |
77 | 85 | i { |
78 | - $avatarSmall | |
86 | + $circleSmall | |
79 | 87 | $colorPrimary |
80 | 88 | font-size: 1.3rem |
81 | 89 | display: flex |
82 | 90 | justify-content: center |
app/html/header.js | ||
---|---|---|
@@ -25,11 +25,10 @@ | ||
25 | 25 | |
26 | 26 | const loc = computed(location, location => { |
27 | 27 | if (typeof location != 'object') return {} |
28 | 28 | |
29 | - return location.location || {} | |
29 | + return location || {} | |
30 | 30 | }) |
31 | - // Dominics nav location api is slightly different than mine - it nest location in nav.location.location | |
32 | 31 | |
33 | 32 | const isFeed = computed(loc, loc => { |
34 | 33 | return FEED_PAGES.includes(loc.page) || (loc.key && loc.feed) |
35 | 34 | }) |
app/html/thread.js | ||
---|---|---|
@@ -1,8 +1,9 @@ | ||
1 | 1 | const nest = require('depnest') |
2 | 2 | const { h, Array: MutantArray, map, computed, when } = require('mutant') |
3 | 3 | const get = require('lodash/get') |
4 | 4 | |
5 | +// TODO - rename threadPrivate | |
5 | 6 | exports.gives = nest('app.html.thread') |
6 | 7 | |
7 | 8 | exports.needs = nest({ |
8 | 9 | 'about.html.avatar': 'first', |
@@ -26,18 +27,18 @@ | ||
26 | 27 | const author = computed([chunk], chunk => get(chunk, '[0].value.author')) |
27 | 28 | |
28 | 29 | return author() === myId |
29 | 30 | ? h('div.my-chunk', [ |
30 | - h('div.avatar'), | |
31 | + h('Avatar -small'), | |
31 | 32 | h('div.msgs', map(chunk, msg => { |
32 | 33 | return h('div.msg-row', [ |
33 | 34 | h('div.spacer'), |
34 | 35 | message(msg) |
35 | 36 | ]) |
36 | 37 | })) |
37 | 38 | ]) |
38 | 39 | : h('div.other-chunk', [ |
39 | - h('div.avatar', when(author, api.about.html.avatar(author()))), | |
40 | + when(author, api.about.html.avatar(author()), 'small'), | |
40 | 41 | h('div.msgs', map(chunk, msg => { |
41 | 42 | return h('div.msg-row', [ |
42 | 43 | message(msg), |
43 | 44 | h('div.spacer') |
@@ -48,9 +49,9 @@ | ||
48 | 49 | ) |
49 | 50 | |
50 | 51 | function message (msg) { |
51 | 52 | const raw = get(msg, 'value.content.text') |
52 | - var unread = api.unread.sync.isUnread(msg) ? ' -unread' : ' -read' | |
53 | + var unread = api.unread.sync.isUnread(msg) ? ' -unread' : '' | |
53 | 54 | api.unread.sync.markRead(msg) |
54 | 55 | return h('div.msg'+unread, api.message.html.markdown(raw)) |
55 | 56 | } |
56 | 57 | |
@@ -96,11 +97,4 @@ | ||
96 | 97 | // TODO (mix) use lodash/get |
97 | 98 | return msgA.value.author === msgB.value.author |
98 | 99 | } |
99 | 100 | |
100 | - | |
101 | - | |
102 | - | |
103 | - | |
104 | - | |
105 | - | |
106 | - |
app/html/thread.mcss | ||
---|---|---|
@@ -9,9 +9,9 @@ | ||
9 | 9 | $chunk |
10 | 10 | |
11 | 11 | justify-content: space-between |
12 | 12 | |
13 | - div.avatar { | |
13 | + img.Avatar { | |
14 | 14 | visibility: hidden |
15 | 15 | } |
16 | 16 | |
17 | 17 | div.msgs { |
@@ -32,8 +32,12 @@ | ||
32 | 32 | div.msg-row { |
33 | 33 | div.msg { |
34 | 34 | border: 1px #fff solid |
35 | 35 | $roundRight |
36 | + -unread { | |
37 | + font-weight: bold | |
38 | + } | |
39 | + | |
36 | 40 | } |
37 | 41 | } |
38 | 42 | } |
39 | 43 | } |
@@ -42,16 +46,9 @@ | ||
42 | 46 | $chunk { |
43 | 47 | display: flex |
44 | 48 | margin-bottom: .5rem |
45 | 49 | |
46 | - div.avatar { | |
47 | - background-color: #333 | |
48 | - $avatarSmall | |
49 | - | |
50 | - img { | |
51 | - $avatarSmall | |
52 | - } | |
53 | - | |
50 | + img.Avatar { | |
54 | 51 | margin-right: 1rem |
55 | 52 | } |
56 | 53 | |
57 | 54 | div.msgs { |
@@ -69,9 +66,9 @@ | ||
69 | 66 | } |
70 | 67 | |
71 | 68 | div.msg { |
72 | 69 | line-height: 1.2 |
73 | - background-color: #fff | |
70 | + $backgroundPrimaryText | |
74 | 71 | padding: 0 .7rem |
75 | 72 | border-radius: 4px |
76 | 73 | } |
77 | 74 | div.spacer { |
@@ -80,4 +77,6 @@ | ||
80 | 77 | } |
81 | 78 | } |
82 | 79 | } |
83 | 80 | |
81 | + | |
82 | + |
app/html/blog-card.js | ||
---|---|---|
@@ -1,130 +1,0 @@ | ||
1 | -var nest = require('depnest') | |
2 | -var h = require('mutant/h') | |
3 | -var isString= require('lodash/isString') | |
4 | -var maxBy= require('lodash/maxBy') | |
5 | -var humanTime = require('human-time') | |
6 | -var marksum = require('markdown-summary') | |
7 | -var markdown = require('ssb-markdown') | |
8 | -var ref = require('ssb-ref') | |
9 | -var htmlEscape = require('html-escape') | |
10 | - | |
11 | -function renderEmoji (emoji, url) { | |
12 | - if (!url) return ':' + emoji + ':' | |
13 | - return ` | |
14 | - <img | |
15 | - src="${htmlEscape(url)}" | |
16 | - alt=":${htmlEscape(emoji)}:" | |
17 | - title=":${htmlEscape(emoji)}:" | |
18 | - class="emoji" | |
19 | - > | |
20 | - ` | |
21 | -} | |
22 | - | |
23 | -exports.gives = nest('app.html.blogCard', true) | |
24 | - | |
25 | -exports.needs = nest({ | |
26 | - 'keys.sync.id': 'first', | |
27 | - 'history.sync.push': 'first', | |
28 | - 'about.obs.name': 'first', | |
29 | - 'about.html.avatar': 'first', | |
30 | - 'translations.sync.strings': 'first', | |
31 | - 'unread.sync.isUnread': 'first', | |
32 | - 'message.html.markdown': 'first', | |
33 | - 'blob.sync.url': 'first', | |
34 | - 'emoji.sync.url': 'first' | |
35 | -}) | |
36 | - | |
37 | -exports.create = function (api) { | |
38 | - | |
39 | - //render markdown, but don't support patchwork@2 style mentions or custom emoji right now. | |
40 | - function render (source) { | |
41 | - return markdown.block(source, { | |
42 | - emoji: (emoji) => { | |
43 | - return renderEmoji(emoji, api.emoji.sync.url(emoji)) | |
44 | - }, | |
45 | - toUrl: (id) => { | |
46 | - if (ref.isBlob(id)) return api.blob.sync.url(id) | |
47 | - return id | |
48 | - }, | |
49 | - imageLink: (id) => id | |
50 | - }) | |
51 | - } | |
52 | - | |
53 | - | |
54 | - //render the icon for a thread. | |
55 | - //it would be more depjecty to split this | |
56 | - //into two methods, one in a private plugin | |
57 | - //one in a channel plugin | |
58 | - function threadIcon (msg) { | |
59 | - if(msg.value.private) { | |
60 | - const myId = api.keys.sync.id() | |
61 | - | |
62 | - return msg.value.content.recps | |
63 | - .map(link => isString(link) ? link : link.link) | |
64 | - .filter(link => link !== myId) | |
65 | - .map(api.about.html.avatar) | |
66 | - } | |
67 | - else if(msg.value.content.channel) | |
68 | - return '#'+msg.value.content.channel | |
69 | - } | |
70 | - | |
71 | - | |
72 | - // REFACTOR: move this to a template? | |
73 | - function buildRecipientNames (thread) { | |
74 | - const myId = api.keys.sync.id() | |
75 | - | |
76 | - return thread.value.content.recps | |
77 | - .map(link => isString(link) ? link : link.link) | |
78 | - .filter(link => link !== myId) | |
79 | - .map(api.about.obs.name) | |
80 | - } | |
81 | - | |
82 | - return nest('app.html.blogCard', (thread, opts = {}) => { | |
83 | - var strings = api.translations.sync.strings() | |
84 | - const { subject } = api.message.html | |
85 | - | |
86 | - if(!thread.value) return | |
87 | - if('string' !== typeof thread.value.content.text) return | |
88 | - | |
89 | - const lastReply = thread.replies && maxBy(thread.replies, r => r.timestamp) | |
90 | - | |
91 | - const onClick = opts.onClick || function () { api.history.sync.push(thread) } | |
92 | - const id = `${thread.key.replace(/[^a-z0-9]/gi, '')}` //-${JSON.stringify(opts)}` | |
93 | - // id is only here to help morphdom morph accurately | |
94 | - | |
95 | - const { content, author, timestamp } = thread.value | |
96 | - | |
97 | - var img = h('Thumbnail') | |
98 | - var m = /\!\[[^]+\]\(([^\)]+)\)/.exec(marksum.image(content.text)) | |
99 | - if(m) { | |
100 | - //Hey this works! fit an image into a specific size (see thread-card.mcss) | |
101 | - //centered, and scaled to fit the square (works with both landscape and portrait!) | |
102 | - //This is functional css not opinionated css, so all embedded. | |
103 | - img.style = 'background-image: url("'+api.blob.sync.url(m[1])+'"); background-position:center; background-size: cover;' | |
104 | - } | |
105 | - | |
106 | - const title = render(marksum.title(content.text)) | |
107 | - const summary = render(marksum.summary(content.text)) | |
108 | - | |
109 | - const className = thread.unread ? '-unread': '' | |
110 | - | |
111 | - return h('BlogCard', { id, className }, [ | |
112 | - h('div.context', [ | |
113 | - api.about.html.avatar(author), | |
114 | - h('div.name', api.about.obs.name(author)), | |
115 | - h('div.timeago', humanTime(new Date(timestamp))), | |
116 | - ]), | |
117 | - h('div.content', {'ev-click': onClick}, [ | |
118 | - img, | |
119 | - h('div.text', [ | |
120 | - h('h2', {innerHTML: title}), | |
121 | - content.channel | |
122 | - ? h('Button -channel', '#'+content.channel) | |
123 | - : '', | |
124 | - h('div.summary', {innerHTML: summary}) | |
125 | - ]) | |
126 | - ]) | |
127 | - ]) | |
128 | - }) | |
129 | -} | |
130 | - |
app/html/app.mcss | ||
---|---|---|
@@ -1,0 +1,8 @@ | ||
1 | +App { | |
2 | + overflow: hidden | |
3 | + position: absolute | |
4 | + top: 0 | |
5 | + bottom: 0 | |
6 | + right: 0 | |
7 | + left: 0 | |
8 | +} |
app/html/blog-card.mcss | ||
---|---|---|
@@ -1,84 +1,0 @@ | ||
1 | -BlogCard { | |
2 | - padding: 1rem | |
3 | - background-color: #fff | |
4 | - | |
5 | - border: 1px solid #fff | |
6 | - transition: all .5s ease | |
7 | - | |
8 | - :hover { | |
9 | - border: 1px solid gainsboro | |
10 | - box-shadow: gainsboro 2px 2px 10px | |
11 | - } | |
12 | - | |
13 | - display: flex | |
14 | - flex-direction: column | |
15 | - | |
16 | - div.context { | |
17 | - font-size: .8rem | |
18 | - margin-bottom: 1rem | |
19 | - | |
20 | - display: flex | |
21 | - align-items: center | |
22 | - | |
23 | - div.Link { | |
24 | - height: 2rem | |
25 | - img.Avatar { | |
26 | - width: 2rem | |
27 | - height: 2rem | |
28 | - } | |
29 | - } | |
30 | - | |
31 | - div.name { | |
32 | - margin-right: 1rem | |
33 | - } | |
34 | - div.timeago { | |
35 | - $colorSubtle | |
36 | - } | |
37 | - } | |
38 | - | |
39 | - div.content { | |
40 | - display: flex | |
41 | - flex-direction: row | |
42 | - flex-grow: 1 | |
43 | - | |
44 | - cursor: pointer | |
45 | - | |
46 | - | |
47 | - div.Thumbnail { | |
48 | - margin-right: 1rem | |
49 | - } | |
50 | - | |
51 | - div.text { | |
52 | - display: flex | |
53 | - flex-wrap: wrap | |
54 | - | |
55 | - h2 { | |
56 | - $markdownLarge | |
57 | - margin: 0 .5rem 0 0 | |
58 | - } | |
59 | - div.Button.-channel {} | |
60 | - div.summary { | |
61 | - flex-basis: 100% | |
62 | - } | |
63 | - } | |
64 | - } | |
65 | - | |
66 | - -unread { | |
67 | - div.content { | |
68 | - background-color: #fff | |
69 | - | |
70 | - div.subject { | |
71 | - $markdownBold | |
72 | - } | |
73 | - } | |
74 | - } | |
75 | -} | |
76 | - | |
77 | -Thumbnail { | |
78 | - border-radius: .5rem | |
79 | - min-width: 8rem | |
80 | - min-height: 6rem | |
81 | - width: 8rem | |
82 | - height: 6rem | |
83 | -} | |
84 | - |
app/html/blogCard.js | ||
---|---|---|
@@ -1,0 +1,131 @@ | ||
1 | +var nest = require('depnest') | |
2 | +var h = require('mutant/h') | |
3 | +var isString= require('lodash/isString') | |
4 | +var maxBy= require('lodash/maxBy') | |
5 | +var marksum = require('markdown-summary') | |
6 | +var markdown = require('ssb-markdown') | |
7 | +var ref = require('ssb-ref') | |
8 | +var htmlEscape = require('html-escape') | |
9 | + | |
10 | +function renderEmoji (emoji, url) { | |
11 | + if (!url) return ':' + emoji + ':' | |
12 | + return ` | |
13 | + <img | |
14 | + src="${htmlEscape(url)}" | |
15 | + alt=":${htmlEscape(emoji)}:" | |
16 | + title=":${htmlEscape(emoji)}:" | |
17 | + class="emoji" | |
18 | + > | |
19 | + ` | |
20 | +} | |
21 | + | |
22 | +exports.gives = nest('app.html.blogCard', true) | |
23 | + | |
24 | +exports.needs = nest({ | |
25 | + 'keys.sync.id': 'first', | |
26 | + 'history.sync.push': 'first', | |
27 | + 'about.obs.name': 'first', | |
28 | + 'about.html.avatar': 'first', | |
29 | + 'translations.sync.strings': 'first', | |
30 | + 'unread.sync.isUnread': 'first', | |
31 | + // 'message.html.markdown': 'first', | |
32 | + 'message.html.timeago': 'first', | |
33 | + 'blob.sync.url': 'first', | |
34 | + 'emoji.sync.url': 'first' | |
35 | +}) | |
36 | + | |
37 | +exports.create = function (api) { | |
38 | + | |
39 | + //render markdown, but don't support patchwork@2 style mentions or custom emoji right now. | |
40 | + function render (source) { | |
41 | + return markdown.block(source, { | |
42 | + emoji: (emoji) => { | |
43 | + return renderEmoji(emoji, api.emoji.sync.url(emoji)) | |
44 | + }, | |
45 | + toUrl: (id) => { | |
46 | + if (ref.isBlob(id)) return api.blob.sync.url(id) | |
47 | + return id | |
48 | + }, | |
49 | + imageLink: (id) => id | |
50 | + }) | |
51 | + } | |
52 | + | |
53 | + | |
54 | + //render the icon for a blog. | |
55 | + //it would be more depjecty to split this | |
56 | + //into two methods, one in a private plugin | |
57 | + //one in a channel plugin | |
58 | + function blogIcon (msg) { | |
59 | + if(msg.value.private) { | |
60 | + const myId = api.keys.sync.id() | |
61 | + | |
62 | + return msg.value.content.recps | |
63 | + .map(link => isString(link) ? link : link.link) | |
64 | + .filter(link => link !== myId) | |
65 | + .map(link => api.about.html.avatar) | |
66 | + } | |
67 | + else if(msg.value.content.channel) | |
68 | + return '#'+msg.value.content.channel | |
69 | + } | |
70 | + | |
71 | + | |
72 | + // REFACTOR: move this to a template? | |
73 | + function buildRecipientNames (blog) { | |
74 | + const myId = api.keys.sync.id() | |
75 | + | |
76 | + return blog.value.content.recps | |
77 | + .map(link => isString(link) ? link : link.link) | |
78 | + .filter(link => link !== myId) | |
79 | + .map(api.about.obs.name) | |
80 | + } | |
81 | + | |
82 | + return nest('app.html.blogCard', (blog, opts = {}) => { | |
83 | + var strings = api.translations.sync.strings() | |
84 | + | |
85 | + if(!blog.value) return | |
86 | + if('string' !== typeof blog.value.content.text) return | |
87 | + | |
88 | + const lastReply = blog.replies && maxBy(blog.replies, r => r.timestamp) | |
89 | + | |
90 | + const goToBlog = () => api.history.sync.push(blog) | |
91 | + const onClick = opts.onClick || goToBlog | |
92 | + const id = `${blog.key.replace(/[^a-z0-9]/gi, '')}` //-${JSON.stringify(opts)}` | |
93 | + // id is only here to help morphdom morph accurately | |
94 | + | |
95 | + const { content, author } = blog.value | |
96 | + | |
97 | + var img = h('Thumbnail') | |
98 | + var m = /\!\[[^]+\]\(([^\)]+)\)/.exec(marksum.image(content.text)) | |
99 | + if(m) { | |
100 | + //Hey this works! fit an image into a specific size (see blog-card.mcss) | |
101 | + //centered, and scaled to fit the square (works with both landscape and portrait!) | |
102 | + //This is functional css not opinionated css, so all embedded. | |
103 | + img.style = 'background-image: url("'+api.blob.sync.url(m[1])+'"); background-position:center; background-size: cover;' | |
104 | + } | |
105 | + | |
106 | + const title = render(marksum.title(content.text)) | |
107 | + const summary = render(marksum.summary(content.text)) | |
108 | + | |
109 | + const className = blog.unread ? '-unread': '' | |
110 | + | |
111 | + return h('BlogCard', { id, className, 'ev-click': onClick }, [ | |
112 | + h('div.context', [ | |
113 | + api.about.html.avatar(author, 'tiny'), | |
114 | + h('div.name', api.about.obs.name(author)), | |
115 | + api.message.html.timeago(blog) | |
116 | + ]), | |
117 | + h('div.content', [ | |
118 | + img, | |
119 | + h('div.text', [ | |
120 | + h('h2', {innerHTML: title}), | |
121 | + content.channel | |
122 | + ? h('Button -channel', '#'+content.channel) | |
123 | + : '', | |
124 | + h('div.summary', {innerHTML: summary}) | |
125 | + ]) | |
126 | + ]) | |
127 | + ]) | |
128 | + }) | |
129 | +} | |
130 | + | |
131 | + |
app/html/blogCard.mcss | ||
---|---|---|
@@ -1,0 +1,77 @@ | ||
1 | +BlogCard { | |
2 | + padding: 1rem | |
3 | + $backgroundPrimaryText | |
4 | + | |
5 | + border: 1px solid #fff | |
6 | + transition: all .5s ease | |
7 | + | |
8 | + :hover { | |
9 | + border: 1px solid gainsboro | |
10 | + box-shadow: gainsboro 2px 2px 10px | |
11 | + } | |
12 | + | |
13 | + display: flex | |
14 | + flex-direction: column | |
15 | + | |
16 | + div.context { | |
17 | + font-size: .8rem | |
18 | + margin-bottom: 1rem | |
19 | + | |
20 | + display: flex | |
21 | + align-items: center | |
22 | + | |
23 | + img.Avatar { | |
24 | + margin-right: .5rem | |
25 | + } | |
26 | + | |
27 | + div.name { | |
28 | + margin-right: 1rem | |
29 | + } | |
30 | + | |
31 | + div.Timeago {} | |
32 | + } | |
33 | + | |
34 | + div.content { | |
35 | + display: flex | |
36 | + flex-direction: row | |
37 | + flex-grow: 1 | |
38 | + | |
39 | + cursor: pointer | |
40 | + | |
41 | + | |
42 | + div.Thumbnail { | |
43 | + margin-right: 1rem | |
44 | + } | |
45 | + | |
46 | + div.text { | |
47 | + display: flex | |
48 | + flex-wrap: wrap | |
49 | + | |
50 | + h2 { | |
51 | + $markdownLarge | |
52 | + margin: 0 .5rem 0 0 | |
53 | + } | |
54 | + div.Button.-channel {} | |
55 | + div.summary { | |
56 | + flex-basis: 100% | |
57 | + } | |
58 | + } | |
59 | + } | |
60 | + background-color: #f8f8f8 | |
61 | + | |
62 | + -unread { | |
63 | + div.content { | |
64 | + font-weight: bold | |
65 | + } | |
66 | + background-color: #fff | |
67 | + } | |
68 | +} | |
69 | + | |
70 | +Thumbnail { | |
71 | + border-radius: .5rem | |
72 | + min-width: 8rem | |
73 | + min-height: 6rem | |
74 | + width: 8rem | |
75 | + height: 6rem | |
76 | +} | |
77 | + |
app/html/comments.js | ||
---|---|---|
@@ -1,0 +1,140 @@ | ||
1 | +const nest = require('depnest') | |
2 | +const { h, Array: MutantArray, Value, map, computed, when, resolve } = require('mutant') | |
3 | +const get = require('lodash/get') | |
4 | + | |
5 | +exports.gives = nest('app.html.comments') | |
6 | + | |
7 | +exports.needs = nest({ | |
8 | + 'about.html.avatar': 'first', | |
9 | + 'about.obs.name': 'first', | |
10 | + 'backlinks.obs.for': 'first', | |
11 | + 'feed.obs.thread': 'first', | |
12 | + 'message.html.compose': 'first', | |
13 | + 'message.html.markdown': 'first', | |
14 | + 'message.html.timeago': 'first', | |
15 | + 'message.html.likes': 'first', | |
16 | + 'unread.sync.markRead': 'first', | |
17 | + 'unread.sync.isUnread': 'first', | |
18 | +}) | |
19 | + | |
20 | +exports.create = (api) => { | |
21 | + return nest('app.html.comments', comments) | |
22 | + | |
23 | + function comments (root) { | |
24 | + const { messages, channel, lastId: branch } = api.feed.obs.thread(root) | |
25 | + | |
26 | + // TODO - move this up into Patchcore | |
27 | + const messagesTree = computed(messages, msgs => { | |
28 | + return msgs | |
29 | + .filter(msg => forkOf(msg) === undefined) | |
30 | + .map(threadMsg => { | |
31 | + const nestedReplies = msgs.filter(msg => forkOf(msg) === threadMsg.key) | |
32 | + threadMsg.replies = nestedReplies | |
33 | + return threadMsg | |
34 | + }) | |
35 | + }) | |
36 | + | |
37 | + const meta = { | |
38 | + type: 'post', | |
39 | + root, | |
40 | + branch, | |
41 | + channel | |
42 | + } | |
43 | + const twoComposers = computed(messages, messages => { | |
44 | + return messages.length > 5 | |
45 | + }) | |
46 | + const { compose } = api.message.html | |
47 | + | |
48 | + | |
49 | + return h('Comments', [ | |
50 | + when(twoComposers, compose({ meta, shrink: true, canAttach: false })), | |
51 | + map(messagesTree, msg => Comment(msg, root, branch)), | |
52 | + compose({ meta, shrink: false, canAttach: false }), | |
53 | + ]) | |
54 | + } | |
55 | + | |
56 | + function Comment (msgObs, root, branch) { | |
57 | + const msg = resolve(msgObs) | |
58 | + | |
59 | + const raw = get(msg, 'value.content.text') | |
60 | + var className = api.unread.sync.isUnread(msg) ? ' -unread' : ' -read' | |
61 | + api.unread.sync.markRead(msg) | |
62 | + | |
63 | + if (!get(msg, 'value.content.root')) return | |
64 | + | |
65 | + const { author, content } = msg.value | |
66 | + | |
67 | + // // TODO - move this upstream into patchcore:feed.obs.thread ?? | |
68 | + // // OR change strategy to use forks | |
69 | + // const backlinks = api.backlinks.obs.for(msg.key) | |
70 | + // const nestedReplies = computed(backlinks, backlinks => { | |
71 | + // return backlinks.filter(backlinker => { | |
72 | + // const { type, root } = backlinker.value.content | |
73 | + // return type === 'post' && root === msg.key | |
74 | + // }) | |
75 | + // }) | |
76 | + | |
77 | + var nestedReplyCompose = Value(false) | |
78 | + const toggleCompose = () => nestedReplyCompose.set(!nestedReplyCompose()) | |
79 | + const nestedReplyComposer = api.message.html.compose({ | |
80 | + meta: { | |
81 | + type: 'post', | |
82 | + root, | |
83 | + fork: msg.key, | |
84 | + branch, | |
85 | + channel: content.channel | |
86 | + }, | |
87 | + shrink: false, | |
88 | + canAttach: false, | |
89 | + canPreview: false | |
90 | + }, toggleCompose) | |
91 | + | |
92 | + return h('Comment', { className }, [ | |
93 | + h('div.left', api.about.html.avatar(author, 'tiny')), | |
94 | + h('div.right', [ | |
95 | + h('section.context', [ | |
96 | + h('div.name', api.about.obs.name(author)), | |
97 | + api.message.html.timeago(msg) | |
98 | + ]), | |
99 | + h('section.content', api.message.html.markdown(raw)), | |
100 | + when(msgObs.replies, | |
101 | + h('section.replies', | |
102 | + map(msgObs.replies, NestedComment) | |
103 | + ) | |
104 | + ), | |
105 | + h('section.actions', [ | |
106 | + h('div.reply', { 'ev-click': toggleCompose }, [ | |
107 | + h('i.fa.fa-commenting-o'), | |
108 | + ]), | |
109 | + api.message.html.likes(msg) | |
110 | + ]), | |
111 | + when(nestedReplyCompose, nestedReplyComposer), | |
112 | + ]) | |
113 | + ]) | |
114 | + } | |
115 | + | |
116 | + function NestedComment (msgObs) { | |
117 | + const msg = resolve(msgObs) | |
118 | + const raw = get(msg, 'value.content.text') | |
119 | + if (!raw) return | |
120 | + | |
121 | + const { author } = msg.value | |
122 | + | |
123 | + return h('Comment -nested', [ | |
124 | + h('div.left'), | |
125 | + h('div.right', [ | |
126 | + h('section.context', [ | |
127 | + h('div.name', api.about.obs.name(author)), | |
128 | + api.message.html.timeago(msg) | |
129 | + ]), | |
130 | + h('section.content', api.message.html.markdown(raw)), | |
131 | + ]) | |
132 | + ]) | |
133 | + | |
134 | + api.message.html.markdown(raw) | |
135 | + } | |
136 | +} | |
137 | + | |
138 | +function forkOf (msg) { | |
139 | + return get(msg, 'value.content.fork') | |
140 | +} |
app/html/comments.mcss | ||
---|---|---|
@@ -1,0 +1,92 @@ | ||
1 | +Comments { | |
2 | + margin: 0 1.5rem | |
3 | + | |
4 | + div.Comment {} | |
5 | + | |
6 | + div.Compose { | |
7 | + margin-bottom: 1.5rem | |
8 | + } | |
9 | +} | |
10 | + | |
11 | +Comment { | |
12 | + display: flex | |
13 | + | |
14 | + div.left { | |
15 | + margin-right: 1rem | |
16 | + | |
17 | + div.Avatar {} | |
18 | + } | |
19 | + | |
20 | + div.right { | |
21 | + flex-grow: 1 | |
22 | + | |
23 | + border-bottom: 1px solid gainsboro | |
24 | + padding-bottom: 1rem | |
25 | + margin-bottom: 1rem | |
26 | + | |
27 | + section.context { | |
28 | + display: flex | |
29 | + align-items: baseline | |
30 | + | |
31 | + div.name { | |
32 | + font-size: 1.2rem | |
33 | + margin-right: 1rem | |
34 | + } | |
35 | + div.Timeago {} | |
36 | + } | |
37 | + | |
38 | + section.content { | |
39 | + font-size: .9rem | |
40 | + line-height: 1.4 | |
41 | + div.Markdown {} | |
42 | + } | |
43 | + | |
44 | + section.actions { | |
45 | + font-size: 1.2rem | |
46 | + margin-right: .5rem | |
47 | + | |
48 | + display: flex | |
49 | + justify-content: flex-end | |
50 | + align-items: baseline | |
51 | + | |
52 | + div.reply { | |
53 | + cursor: pointer | |
54 | + | |
55 | + margin-right: 1.5rem | |
56 | + i.fa {} | |
57 | + } | |
58 | + | |
59 | + div.Likes { | |
60 | + min-width: 2.5rem | |
61 | + | |
62 | + display: flex | |
63 | + align-items: center | |
64 | + | |
65 | + i.fa { margin-right: .3rem } | |
66 | + div.count {} | |
67 | + } | |
68 | + } | |
69 | + | |
70 | + div.Compose { | |
71 | + margin: 1rem 0 | |
72 | + } | |
73 | + } | |
74 | +} | |
75 | + | |
76 | +Comment -nested { | |
77 | + padding: 1rem 1rem 0 1rem | |
78 | + $backgroundPrimaryText | |
79 | + $roundTop | |
80 | + $roundBottom | |
81 | + | |
82 | + margin-bottom: 1rem | |
83 | + | |
84 | + div.left { margin: 0 } | |
85 | + div.right { | |
86 | + padding: 0 | |
87 | + border: 0 | |
88 | + margin: 0 | |
89 | + | |
90 | + } | |
91 | +} | |
92 | + |
app/html/scroller.js | ||
---|---|---|
@@ -1,0 +1,80 @@ | ||
1 | +const nest = require('depnest') | |
2 | +const { h } = require('mutant') | |
3 | +const pull = require('pull-stream') | |
4 | +const Scroller = require('mutant-scroll') | |
5 | +const next = require('pull-next-step') | |
6 | + | |
7 | +exports.gives = nest('app.html.scroller') | |
8 | + | |
9 | +exports.needs = nest({ | |
10 | + 'message.html.render': 'first' | |
11 | +}) | |
12 | + | |
13 | +exports.create = function (api) { | |
14 | + return nest('app.html.scroller', createScroller) | |
15 | + | |
16 | + function createScroller (opts = {}) { | |
17 | + const { | |
18 | + stream, | |
19 | + filter = () => pull.filter((msg) => true), | |
20 | + } = opts | |
21 | + | |
22 | + const streamToTop = pull( | |
23 | + next(stream, {old: false, limit: 100, property: ['value', 'timestamp']}), | |
24 | + filter() // is a pull-stream through | |
25 | + ) | |
26 | + | |
27 | + const streamToBottom = pull( | |
28 | + next(stream, {reverse: true, limit: 100, live: false, property: ['value', 'timestamp']}), | |
29 | + filter() | |
30 | + ) | |
31 | + | |
32 | + return Scroller(Object.assign({}, opts, { streamToTop, streamToBottom })) | |
33 | + // valid Scroller opts : see github.com/mixmix/mutant-scroll | |
34 | + // classList = [], | |
35 | + // prepend = [], | |
36 | + // append = [], | |
37 | + // streamToTop, | |
38 | + // streamToBottom, | |
39 | + // render, | |
40 | + // updateTop = updateTopDefault, | |
41 | + // updateBottom = updateBottomDefault, | |
42 | + // store = MutantArray(), | |
43 | + // cb = (err) => { if (err) throw err } | |
44 | + } | |
45 | +} | |
46 | + | |
47 | +function keyscroll (content) { | |
48 | + var curMsgEl | |
49 | + | |
50 | + if (!content) return () => {} | |
51 | + | |
52 | + content.addEventListener('click', onActivateChild, false) | |
53 | + content.addEventListener('focus', onActivateChild, true) | |
54 | + | |
55 | + function onActivateChild (ev) { | |
56 | + for (var el = ev.target; el; el = el.parentNode) { | |
57 | + if (el.parentNode === content) { | |
58 | + curMsgEl = el | |
59 | + return | |
60 | + } | |
61 | + } | |
62 | + } | |
63 | + | |
64 | + function selectChild (el) { | |
65 | + if (!el) { return } | |
66 | + | |
67 | + ;(el.scrollIntoViewIfNeeded || el.scrollIntoView).call(el) | |
68 | + el.focus() | |
69 | + curMsgEl = el | |
70 | + } | |
71 | + | |
72 | + return function scroll (d) { | |
73 | + selectChild((!curMsgEl || d === 'first') ? content.firstChild | |
74 | + : d < 0 ? curMsgEl.previousElementSibling || content.firstChild | |
75 | + : d > 0 ? curMsgEl.nextElementSibling || content.lastChild | |
76 | + : curMsgEl) | |
77 | + | |
78 | + return curMsgEl | |
79 | + } | |
80 | +} |
app/html/scroller.mcss | ||
---|---|---|
@@ -1,0 +1,36 @@ | ||
1 | +Scroller { | |
2 | + display: flex | |
3 | + flex-direction: column | |
4 | + | |
5 | + overflow: auto | |
6 | + width: 100% | |
7 | + height: 100% | |
8 | + min-height: 0px | |
9 | + | |
10 | + /* div.wrapper { */ | |
11 | + /* align-self: center */ | |
12 | + | |
13 | + /* flex: 1 1 */ | |
14 | + /* $threadWidth */ | |
15 | + /* padding-top: .5rem */ | |
16 | + | |
17 | + /* section.content { */ | |
18 | + /* div { */ | |
19 | + /* border-bottom: solid 1px gainsboro */ | |
20 | + /* } */ | |
21 | + /* } */ | |
22 | + /* } */ | |
23 | +} | |
24 | + | |
25 | +Scroller -errors { | |
26 | + div.wrapper { | |
27 | + width: initial | |
28 | + max-width: 100% | |
29 | + | |
30 | + section.content div { | |
31 | + border: none | |
32 | + } | |
33 | + } | |
34 | +} | |
35 | + | |
36 | + |
app/index.js | ||
---|---|---|
@@ -3,32 +3,41 @@ | ||
3 | 3 | catchLinkClick: require('./async/catch-link-click'), |
4 | 4 | }, |
5 | 5 | html: { |
6 | 6 | app: require('./html/app'), |
7 | + comments: require('./html/comments'), | |
7 | 8 | context: require('./html/context'), |
8 | 9 | header: require('./html/header'), |
9 | 10 | thread: require('./html/thread'), |
10 | 11 | link: require('./html/link'), |
11 | - blogCard: require('./html/blog-card'), | |
12 | + blogCard: require('./html/blogCard'), | |
12 | 13 | blogHeader: require('./html/blogHeader'), |
14 | + scroller: require('./html/scroller'), | |
13 | 15 | }, |
14 | 16 | page: { |
15 | 17 | blogIndex: require('./page/blogIndex'), |
16 | 18 | blogNew: require('./page/blogNew'), |
19 | + blogShow: require('./page/blogShow'), | |
17 | 20 | error: require('./page/error'), |
18 | 21 | settings: require('./page/settings'), |
19 | 22 | // channel: require('./page/channel'), |
20 | 23 | // image: require('./page/image'), |
21 | 24 | // groupFind: require('./page/groupFind'), |
22 | 25 | // groupIndex: require('./page/groupIndex'), |
23 | 26 | // groupNew: require('./page/groupNew'), |
24 | 27 | // groupShow: require('./page/groupShow'), |
25 | - // home: require('./page/home'), | |
26 | 28 | // threadShow: require('./page/threadShow'), |
27 | 29 | userEdit: require('./page/userEdit'), |
28 | 30 | // userFind: require('./page/userFind'), |
29 | 31 | userShow: require('./page/userShow'), |
30 | 32 | threadNew: require('./page/threadNew'), |
31 | 33 | threadShow: require('./page/threadShow'), |
34 | + }, | |
35 | + sync: { | |
36 | + initialize: { | |
37 | + clickHandler: require('./sync/initialize/clickHandler'), | |
38 | + styles: require('./sync/initialize/styles'), | |
39 | + suggests: require('./sync/initialize/suggests'), | |
40 | + }, | |
32 | 41 | } |
33 | 42 | } |
34 | 43 |
app/page/blogIndex.js | ||
---|---|---|
@@ -1,116 +1,60 @@ | ||
1 | 1 | const nest = require('depnest') |
2 | 2 | const { h } = require('mutant') |
3 | +const pull = require('pull-stream') | |
3 | 4 | |
4 | -const isString = require('lodash/isString') | |
5 | -const More = require('hypermore') | |
6 | -const morphdom = require('morphdom') | |
7 | -const Debounce = require('obv-debounce') | |
8 | - | |
9 | 5 | exports.gives = nest('app.page.blogIndex') |
10 | 6 | |
11 | 7 | exports.needs = nest({ |
12 | 8 | 'app.html.context': 'first', |
13 | 9 | 'app.html.blogCard': 'first', |
10 | + 'app.html.scroller': 'first', | |
11 | + 'feed.pull.public': 'first', | |
14 | 12 | 'history.sync.push': 'first', |
15 | 13 | 'keys.sync.id': 'first', |
16 | 14 | 'translations.sync.strings': 'first', |
17 | - 'state.obs.threads': 'first', | |
18 | 15 | 'unread.sync.isUnread': 'first' |
19 | 16 | }) |
20 | 17 | |
21 | 18 | exports.create = (api) => { |
22 | - var contentHtmlObs | |
23 | - | |
24 | 19 | return nest('app.page.blogIndex', function (location) { |
25 | - // location here can expected to be: { page: 'blogIndex'} | |
26 | - // | |
20 | + // location here can expected to be: { page: 'blogIndex'} or { page: 'home' } | |
21 | + | |
27 | 22 | var strings = api.translations.sync.strings() |
28 | 23 | |
24 | + var blogs = api.app.html.scroller({ | |
25 | + classList: ['content'], | |
26 | + prepend: h('Button -primary', { 'ev-click': () => api.history.sync.push({ page: 'blogNew' }) }, strings.blogNew.actions.writeBlog), | |
27 | + stream: api.feed.pull.public, | |
28 | + filter: () => pull( | |
29 | + pull.filter(msg => { | |
30 | + const type = msg.value.content.type | |
31 | + return type === 'post' || type === 'blog' | |
32 | + }), | |
33 | + pull.filter(msg => !msg.value.content.root) // show only root messages | |
34 | + ), | |
35 | + // FUTURE : if we need better perf, we can add a persistent cache. At the moment this page is fast enough though. | |
36 | + // See implementation of app.html.context for example | |
37 | + // store: recentMsgCache, | |
38 | + // updateTop: updateRecentMsgCache, | |
39 | + // updateBottom: updateRecentMsgCache, | |
40 | + render | |
41 | + }) | |
42 | + | |
29 | 43 | return h('Page -blogIndex', {title: strings.home}, [ |
30 | 44 | api.app.html.context(location), |
31 | - h('div.content', [ | |
32 | - h('Button -primary', { 'ev-click': () => api.history.sync.push({ page: 'blogNew' }) }, strings.blogNew.actions.writeBlog), | |
33 | - blogs(), | |
34 | - h('Button -showMore', { 'ev-click': contentHtmlObs.more }, strings.showMore) | |
35 | - ]), | |
45 | + blogs | |
36 | 46 | ]) |
37 | 47 | }) |
38 | 48 | |
39 | - function blogs () { | |
40 | - // TODO - replace with actual blogs | |
41 | - var morePlease = false | |
42 | - var threadsObs = api.state.obs.threads() | |
43 | 49 | |
44 | - // DUCT TAPE: debounce the observable so it doesn't | |
45 | - // update the dom more than 1/second | |
46 | - threadsObs(function () { | |
47 | - if(morePlease) threadsObs.more() | |
48 | - }) | |
49 | - threadsObsDebounced = Debounce(threadsObs, 1000) | |
50 | - threadsObsDebounced(function () { | |
51 | - morePlease = false | |
52 | - }) | |
53 | - threadsObsDebounced.more = function () { | |
54 | - morePlease = true | |
55 | - requestIdleCallback(threadsObs.more) | |
56 | - } | |
57 | 50 | |
58 | - var updates = h('div.blogs', []) | |
59 | - contentHtmlObs = More( | |
60 | - threadsObsDebounced, | |
61 | - function render (threads) { | |
51 | + function render (blog) { | |
52 | + const { recps, channel } = blog.value.content | |
53 | + var onClick | |
54 | + if (channel && !recps) | |
55 | + onClick = (ev) => api.history.sync.push(Object.assign({}, blog, { page: 'blogShow' })) | |
62 | 56 | |
63 | - function latestUpdate(thread) { | |
64 | - var m = thread.timestamp || 0 | |
65 | - if(!thread.replies) return m | |
66 | - | |
67 | - for(var i = 0; i < thread.replies.length; i++) | |
68 | - m = Math.max(thread.replies[i].timestamp||0, m) | |
69 | - return m | |
70 | - } | |
71 | - | |
72 | - var groupedThreads = | |
73 | - Object.keys(threads.roots || {}).map(function (id) { | |
74 | - return threads.roots[id] | |
75 | - }) | |
76 | - .filter(function (thread) { | |
77 | - return thread.value | |
78 | - }) | |
79 | - .filter(function (thread) { | |
80 | - //show public messages only | |
81 | - return !thread.value.content.recps | |
82 | - }) | |
83 | - .map(function (thread) { | |
84 | - var unread = 0 | |
85 | - if(api.unread.sync.isUnread(thread)) | |
86 | - unread ++ | |
87 | - ;(thread.replies || []).forEach(function (msg) { | |
88 | - if(api.unread.sync.isUnread(msg)) unread ++ | |
89 | - }) | |
90 | - thread.unread = unread | |
91 | - return thread | |
92 | - }) | |
93 | - .sort((a, b) => latestUpdate(b) - latestUpdate(a)) | |
94 | - | |
95 | - morphdom( | |
96 | - updates, | |
97 | - h('div.blogs', | |
98 | - groupedThreads.map(thread => { | |
99 | - const { recps, channel } = thread.value.content | |
100 | - var onClick | |
101 | - if (channel && !recps) | |
102 | - onClick = (ev) => api.history.sync.push({ key: thread.key, page: 'blogShow' }) | |
103 | - | |
104 | - return api.app.html.blogCard(thread, { onClick }) | |
105 | - }) | |
106 | - ) | |
107 | - ) | |
108 | - | |
109 | - return updates | |
110 | - } | |
111 | - ) | |
112 | - | |
113 | - return contentHtmlObs | |
57 | + return api.app.html.blogCard(blog, { onClick }) | |
114 | 58 | } |
115 | 59 | } |
116 | 60 |
app/page/blogIndex.mcss | ||
---|---|---|
@@ -1,17 +1,34 @@ | ||
1 | 1 | Page -blogIndex { |
2 | + $backgroundPrimary | |
2 | 3 | |
3 | 4 | div.content { |
4 | - $backgroundPrimary | |
5 | + padding: 0 | |
6 | + } | |
5 | 7 | |
6 | - div.Button { | |
7 | - margin: 1rem 0 | |
8 | - } | |
8 | + div.Scroller.content { | |
9 | + background-color: #fff | |
9 | 10 | |
10 | - div.blogs { | |
11 | - div.BlogCard { | |
12 | - margin-bottom: .5rem | |
11 | + div.wrapper { | |
12 | + padding: 1rem | |
13 | + | |
14 | + section.top { | |
15 | + div.Button { | |
16 | + margin-bottom: 1rem | |
17 | + } | |
13 | 18 | } |
19 | + | |
20 | + section.content { | |
21 | + div.BlogCard { | |
22 | + margin-bottom: .5rem | |
23 | + } | |
24 | + } | |
25 | + | |
26 | + section.bottom { | |
27 | + div.Button { | |
28 | + margin: 1rem 0 | |
29 | + } | |
30 | + } | |
14 | 31 | } |
15 | 32 | } |
16 | 33 | } |
17 | 34 |
app/page/error.js | ||
---|---|---|
@@ -13,13 +13,10 @@ | ||
13 | 13 | return nest('app.page.error', error) |
14 | 14 | |
15 | 15 | function error (location) { |
16 | 16 | return h('Page -error', {title: strings.error}, [ |
17 | - strings.errorNotFound, | |
17 | + h('div.message', strings.errorNotFound), | |
18 | 18 | h('pre', [JSON.stringify(location, null, 2)]) |
19 | 19 | ]) |
20 | 20 | } |
21 | 21 | } |
22 | 22 | |
23 | - | |
24 | - | |
25 | - |
app/page/groupFind.mcss | ||
---|---|---|
@@ -1,9 +1,9 @@ | ||
1 | 1 | Page -groupFind { |
2 | 2 | div.content { |
3 | 3 | $maxWidthSmaller |
4 | 4 | div.search { |
5 | - background-color: #fff | |
5 | + $backgroundPrimaryText | |
6 | 6 | |
7 | 7 | margin-bottom: 1rem |
8 | 8 | |
9 | 9 | display: flex |
@@ -29,17 +29,17 @@ | ||
29 | 29 | $maxWidthSmaller |
30 | 30 | |
31 | 31 | div.Link { |
32 | 32 | div.result { |
33 | - background-color: #fff | |
33 | + $backgroundPrimaryText | |
34 | 34 | |
35 | 35 | padding: .5rem |
36 | 36 | |
37 | 37 | display: flex |
38 | 38 | align-items: center |
39 | 39 | |
40 | 40 | img { |
41 | - $avatarSmall | |
41 | + $circleSmall | |
42 | 42 | margin-right: 1rem |
43 | 43 | } |
44 | 44 | |
45 | 45 | div.alias { |
app/page/settings.js | ||
---|---|---|
@@ -6,9 +6,9 @@ | ||
6 | 6 | exports.needs = nest({ |
7 | 7 | 'about.html.image': 'first', |
8 | 8 | 'about.obs.name': 'first', |
9 | 9 | 'history.sync.push': 'first', |
10 | - 'history.obs.history': 'first', | |
10 | + 'history.obs.store': 'first', | |
11 | 11 | 'keys.sync.id': 'first', |
12 | 12 | 'settings.sync.get': 'first', |
13 | 13 | 'settings.sync.set': 'first', |
14 | 14 | 'settings.obs.get': 'first', |
@@ -31,9 +31,9 @@ | ||
31 | 31 | // RESET the app when the settings are changed |
32 | 32 | api.settings.obs.get('language')(() => { |
33 | 33 | console.log('language changed, resetting view') |
34 | 34 | |
35 | - api.history.obs.history().set([]) // wipe nav cache | |
35 | + api.history.obs.store().set([]) // wipe nav cache | |
36 | 36 | api.history.sync.push({page: 'home'}) // queue up basic pages |
37 | 37 | api.history.sync.push({page: 'settings'}) |
38 | 38 | }) |
39 | 39 |
app/page/threadNew.mcss | ||
---|---|---|
@@ -33,9 +33,9 @@ | ||
33 | 33 | padding: .3rem |
34 | 34 | min-width: 5rem |
35 | 35 | $borderSubtle |
36 | 36 | border-radius: 6rem |
37 | - background-color: #fff | |
37 | + $backgroundPrimaryText | |
38 | 38 | |
39 | 39 | margin-right: 1rem |
40 | 40 | |
41 | 41 | display: flex |
app/page/threadShow.js | ||
---|---|---|
@@ -7,9 +7,10 @@ | ||
7 | 7 | |
8 | 8 | exports.needs = nest({ |
9 | 9 | 'app.html.context': 'first', |
10 | 10 | 'app.html.thread': 'first', |
11 | - 'message.html.compose': 'first' | |
11 | + 'message.html.compose': 'first', | |
12 | + 'unread.sync.markRead': 'first' | |
12 | 13 | }) |
13 | 14 | |
14 | 15 | exports.create = (api) => { |
15 | 16 | return nest('app.page.threadShow', threadShow) |
@@ -19,13 +20,15 @@ | ||
19 | 20 | const { key, value } = location |
20 | 21 | const root = get(value, 'content.root', key) |
21 | 22 | const channel = get(value, 'content.channel') |
22 | 23 | |
24 | + //unread state is set in here... | |
23 | 25 | const thread = api.app.html.thread(root) |
24 | 26 | |
25 | 27 | const meta = { |
26 | 28 | type: 'post', |
27 | 29 | root, |
30 | + //XXX incorrect branch | |
28 | 31 | branch: get(last(location.replies), 'key'), |
29 | 32 | // >> lastId? CHECK THIS LOGIC |
30 | 33 | channel, |
31 | 34 | recps: get(location, 'value.content.recps') |
@@ -43,4 +46,8 @@ | ||
43 | 46 | } |
44 | 47 | } |
45 | 48 | |
46 | 49 | |
50 | + | |
51 | + | |
52 | + | |
53 | + |
app/page/userFind.mcss | ||
---|---|---|
@@ -1,9 +1,9 @@ | ||
1 | 1 | Page -userFind { |
2 | 2 | div.content { |
3 | 3 | $maxWidthSmaller |
4 | 4 | div.search { |
5 | - background-color: #fff | |
5 | + $backgroundPrimaryText | |
6 | 6 | |
7 | 7 | margin-bottom: 1rem |
8 | 8 | |
9 | 9 | display: flex |
@@ -29,17 +29,17 @@ | ||
29 | 29 | $maxWidthSmaller |
30 | 30 | |
31 | 31 | div.Link { |
32 | 32 | div.result { |
33 | - background-color: #fff | |
33 | + $backgroundPrimaryText | |
34 | 34 | |
35 | 35 | padding: .5rem |
36 | 36 | |
37 | 37 | display: flex |
38 | 38 | align-items: center |
39 | 39 | |
40 | 40 | img { |
41 | - $avatarSmall | |
41 | + $circleSmall | |
42 | 42 | margin-right: 1rem |
43 | 43 | } |
44 | 44 | |
45 | 45 | div.alias { |
app/page/userShow.js | ||
---|---|---|
@@ -5,21 +5,22 @@ | ||
5 | 5 | |
6 | 6 | exports.gives = nest('app.page.userShow') |
7 | 7 | |
8 | 8 | exports.needs = nest({ |
9 | - 'about.html.image': 'first', | |
9 | + 'about.html.avatar': 'first', | |
10 | 10 | 'about.obs.name': 'first', |
11 | 11 | 'app.html.link': 'first', |
12 | 12 | 'app.html.blogCard': 'first', |
13 | - 'contact.async.follow': 'first', | |
14 | - 'contact.async.unfollow': 'first', | |
15 | - 'contact.obs.followers': 'first', | |
13 | + 'contact.html.follow': 'first', | |
14 | + 'feed.pull.rollup': 'first', | |
16 | 15 | 'sbot.pull.userFeed': 'first', |
17 | 16 | 'keys.sync.id': 'first', |
18 | 17 | 'translations.sync.strings': 'first', |
18 | + 'unread.sync.isUnread': 'first' | |
19 | 19 | }) |
20 | 20 | |
21 | 21 | exports.create = (api) => { |
22 | + var isUnread = api.unread.sync.isUnread | |
22 | 23 | return nest('app.page.userShow', userShow) |
23 | 24 | |
24 | 25 | function userShow (location) { |
25 | 26 | |
@@ -28,11 +29,11 @@ | ||
28 | 29 | const name = api.about.obs.name(feed) |
29 | 30 | |
30 | 31 | const strings = api.translations.sync.strings() |
31 | 32 | |
32 | - const { followers } = api.contact.obs | |
33 | + // const { followers } = api.contact.obs | |
33 | 34 | |
34 | - const youFollowThem = computed(followers(feed), followers => followers.includes(myId)) | |
35 | + // const youFollowThem = computed(followers(feed), followers => followers.includes(myId)) | |
35 | 36 | // const theyFollowYou = computed(followers(myId), followers => followers.includes(feed)) |
36 | 37 | // const youAreFriends = computed([youFollowThem, theyFollowYou], (a, b) => a && b) |
37 | 38 | |
38 | 39 | // const ourRelationship = computed( |
@@ -42,46 +43,46 @@ | ||
42 | 43 | // if (theyFollowYou) return strings.userShow.state.theyFollow |
43 | 44 | // if (youFollowThem) return strings.userShow.state.youFollow |
44 | 45 | // } |
45 | 46 | // ) |
46 | - const { unfollow, follow } = api.contact.async | |
47 | - const followButton = when(followers(myId).sync, | |
48 | - when(youFollowThem, | |
49 | - h('Button -primary', { 'ev-click': () => unfollow(feed) }, strings.userShow.action.unfollow), | |
50 | - h('Button -primary', { 'ev-click': () => follow(feed) }, strings.userShow.action.follow) | |
51 | - ), | |
52 | - h('Button', { disabled: 'disabled' }, strings.loading ) | |
53 | - ) | |
54 | 47 | |
55 | 48 | const Link = api.app.html.link |
56 | 49 | const userEditButton = Link({ page: 'userEdit', feed }, h('i.fa.fa-pencil')) |
57 | 50 | const directMessageButton = Link({ page: 'threadNew', feed }, h('Button', strings.userShow.action.directMessage)) |
58 | 51 | |
59 | 52 | const BLOG_TYPES = ['blog', 'post'] |
60 | 53 | const blogs = MutantArray() |
61 | 54 | pull( |
62 | - // next(api.feed.pull.private, {reverse: true, limit: 100, live: false}, ['value', 'timestamp']), | |
63 | - // api.feed.pull.private({reverse: true, limit: 100, live: false}), | |
64 | 55 | api.sbot.pull.userFeed({id: feed, reverse: true, live: false}), |
65 | 56 | pull.filter(msg => BLOG_TYPES.includes(get(msg, 'value.content.type'))), |
66 | - pull.filter(msg => get(msg, 'value.content.root') === undefined), | |
57 | + // pull.filter(msg => get(msg, 'value.content.root') === undefined), | |
58 | + api.feed.pull.rollup(), | |
59 | + //unread state should not be in this file... | |
60 | + pull.through(function (blog) { | |
61 | + if(isUnread(blog)) | |
62 | + blog.unread = true | |
63 | + blog.replies.forEach(function (data) { | |
64 | + if(isUnread(data)) | |
65 | + blog.unread = data.unread = true | |
66 | + }) | |
67 | + }), | |
67 | 68 | pull.drain(blogs.push) |
68 | - // Scroller(content, scrollerContent, render, false, false) | |
69 | + // TODO - new Scroller ? | |
69 | 70 | ) |
70 | 71 | |
71 | 72 | return h('Page -userShow', {title: name}, [ |
72 | 73 | h('div.content', [ |
73 | 74 | h('section.about', [ |
74 | - api.about.html.image(feed), | |
75 | + api.about.html.avatar(feed, 'large'), | |
75 | 76 | h('h1', [ |
76 | 77 | name, |
77 | 78 | feed === myId // Only expose own profile editing right now |
78 | 79 | ? userEditButton |
79 | 80 | : '' |
80 | 81 | ]), |
81 | 82 | feed !== myId |
82 | 83 | ? h('div.actions', [ |
83 | - h('div.friendship', followButton), | |
84 | + api.contact.html.follow(feed), | |
84 | 85 | h('div.directMessage', directMessageButton) |
85 | 86 | ]) |
86 | 87 | : '', |
87 | 88 | ]), |
@@ -90,5 +91,4 @@ | ||
90 | 91 | ]) |
91 | 92 | } |
92 | 93 | } |
93 | 94 | |
94 | - |
app/page/userShow.mcss | ||
---|---|---|
@@ -8,22 +8,24 @@ | ||
8 | 8 | flex-direction: column |
9 | 9 | align-items: center |
10 | 10 | |
11 | 11 | img.Avatar { |
12 | - width: 5rem | |
13 | - height: 5rem | |
14 | - border-radius: 3rem | |
15 | 12 | } |
16 | 13 | |
17 | 14 | h1 { |
18 | 15 | font-weight: 300 |
19 | 16 | font-size: 1rem |
17 | + | |
18 | + display: flex | |
19 | + div.Link { | |
20 | + margin-left: .5rem | |
21 | + } | |
20 | 22 | } |
21 | 23 | |
22 | 24 | div.actions { |
23 | 25 | display: flex |
24 | 26 | |
25 | - div.friendship { | |
27 | + div.Follow { | |
26 | 28 | margin-right: 1rem |
27 | 29 | } |
28 | 30 | |
29 | 31 | div.directMessage { |
app/page/blogShow.js | ||
---|---|---|
@@ -1,0 +1,88 @@ | ||
1 | +const nest = require('depnest') | |
2 | +const { h, computed, when } = require('mutant') | |
3 | +const { title: getTitle } = require('markdown-summary') | |
4 | +const last = require('lodash/last') | |
5 | +const get = require('lodash/get') | |
6 | + | |
7 | +exports.gives = nest('app.page.blogShow') | |
8 | + | |
9 | +exports.needs = nest({ | |
10 | + 'about.html.avatar': 'first', | |
11 | + 'about.obs.name': 'first', | |
12 | + 'app.html.comments': 'first', | |
13 | + 'app.html.context': 'first', | |
14 | + 'contact.html.follow': 'first', | |
15 | + 'message.html.channel': 'first', | |
16 | + 'message.html.markdown': 'first', | |
17 | + 'message.html.timeago': 'first', | |
18 | + 'feed.obs.thread': 'first' | |
19 | +}) | |
20 | + | |
21 | +exports.create = (api) => { | |
22 | + return nest('app.page.blogShow', blogShow) | |
23 | + | |
24 | + function blogShow (blogMsg) { | |
25 | + // blogMsg = a thread (message, may be decorated with replies) | |
26 | + | |
27 | + const { author, content } = blogMsg.value | |
28 | + | |
29 | + const blog = content.text | |
30 | + const title = api.message.html.markdown(content.title || getTitle(blog)) | |
31 | + | |
32 | + const comments = api.app.html.comments(blogMsg.key) | |
33 | + | |
34 | + const { lastId: branch } = api.feed.obs.thread(blogMsg.key) | |
35 | + | |
36 | + const { timeago, channel, markdown, compose } = api.message.html | |
37 | + | |
38 | + // return api.app.html.scroller({ | |
39 | + // classList: [ 'level', '-one' ], | |
40 | + // prepend, | |
41 | + // stream: api.feed.pull.private, | |
42 | + // filter: () => pull( | |
43 | + // pull.filter(msg => msg.value.content.type === 'post'), // TODO is this the best way to protect against votes? | |
44 | + // pull.filter(msg => msg.value.author != myKey), | |
45 | + // pull.filter(msg => msg.value.content.recps) | |
46 | + // ), | |
47 | + // store: recentMsgCache, | |
48 | + // updateTop: updateRecentMsgCache, | |
49 | + // updateBottom: updateRecentMsgCache, | |
50 | + // render: (msgObs) => { | |
51 | + // const msg = resolve(msgObs) | |
52 | + // const { author } = msg.value | |
53 | + // if (nearby.has(author)) return | |
54 | + | |
55 | + // return Option({ | |
56 | + // notifications: Math.random() > 0.7 ? Math.floor(Math.random()*9+1) : 0, // TODO | |
57 | + // imageEl: api.about.html.avatar(author), | |
58 | + // label: api.about.obs.name(author), | |
59 | + // selected: location.feed === author, | |
60 | + // location: Object.assign({}, msg, { feed: author }) // TODO make obs? | |
61 | + // }) | |
62 | + // } | |
63 | + // }) | |
64 | + return h('Page -blogShow', [ | |
65 | + api.app.html.context({ page: 'discover' }), // HACK to highlight discover | |
66 | + h('div.content', [ | |
67 | + h('header', [ | |
68 | + h('div.blog', [ | |
69 | + h('h1', title), | |
70 | + timeago(blogMsg), | |
71 | + channel(blogMsg) | |
72 | + ]), | |
73 | + h('div.author', [ | |
74 | + h('div.leftCol', api.about.html.avatar(author, 'medium')), | |
75 | + h('div.rightCol', [ | |
76 | + h('div.name', api.about.obs.name(author)), | |
77 | + api.contact.html.follow(author) | |
78 | + ]), | |
79 | + ]) | |
80 | + ]), | |
81 | + h('div.break', h('hr')), | |
82 | + h('section.blog', markdown(blog)), | |
83 | + comments, | |
84 | + ]), | |
85 | + ]) | |
86 | + } | |
87 | +} | |
88 | + |
app/page/blogShow.mcss | ||
---|---|---|
@@ -1,0 +1,79 @@ | ||
1 | +Page -blogShow { | |
2 | + // div.context {} | |
3 | + | |
4 | + div.content { | |
5 | + header, div, section { | |
6 | + $maxWidth | |
7 | + margin-left: auto | |
8 | + margin-right: auto | |
9 | + } | |
10 | + | |
11 | + header { | |
12 | + $backgroundPrimaryText | |
13 | + padding: 1rem | |
14 | + | |
15 | + display: flex | |
16 | + | |
17 | + div.blog { | |
18 | + display: flex | |
19 | + flex-wrap: wrap | |
20 | + flex-grow: 1 | |
21 | + | |
22 | + h1 { | |
23 | + flex-basis: 100% | |
24 | + | |
25 | + $markdownLarge | |
26 | + font-size: 2rem | |
27 | + font-weight: 300 | |
28 | + margin: 0 0 1rem 0 | |
29 | + } | |
30 | + | |
31 | + div.Timeago { | |
32 | + flex-basis: 100% | |
33 | + margin-bottom: .6rem | |
34 | + } | |
35 | + | |
36 | + div.Button.-channel {} | |
37 | + } | |
38 | + | |
39 | + div.author { | |
40 | + display: flex | |
41 | + | |
42 | + div.leftCol { | |
43 | + margin-right: 1rem | |
44 | + img.Avatar {} | |
45 | + } | |
46 | + | |
47 | + div.rightCol { | |
48 | + div.name { | |
49 | + font-size: .9rem | |
50 | + margin-bottom: .5rem | |
51 | + } | |
52 | + div.Button.-follow {} // extract | |
53 | + } | |
54 | + } | |
55 | + } | |
56 | + | |
57 | + div.break { | |
58 | + padding: 0 1rem | |
59 | + $backgroundPrimaryText | |
60 | + | |
61 | + hr { | |
62 | + margin: 0 | |
63 | + border: none | |
64 | + border-bottom: 1px solid gainsboro | |
65 | + } | |
66 | + } | |
67 | + | |
68 | + section.blog { | |
69 | + $backgroundPrimaryText | |
70 | + padding: 1rem | |
71 | + | |
72 | + margin-bottom: 1.5rem | |
73 | + } | |
74 | + | |
75 | + div.Comments { | |
76 | + } | |
77 | + } | |
78 | +} | |
79 | + |
app/page/home.js | ||
---|---|---|
@@ -1,151 +1,0 @@ | ||
1 | -const nest = require('depnest') | |
2 | -const { h } = require('mutant') | |
3 | -const isString = require('lodash/isString') | |
4 | -const More = require('hypermore') | |
5 | -const morphdom = require('morphdom') | |
6 | -const Debounce = require('obv-debounce') | |
7 | - | |
8 | -exports.gives = nest('app.page.home') | |
9 | - | |
10 | -exports.needs = nest({ | |
11 | - 'history.sync.push': 'first', | |
12 | - 'keys.sync.id': 'first', | |
13 | - 'translations.sync.strings': 'first', | |
14 | - 'state.obs.threads': 'first', | |
15 | - 'app.html.blogCard': 'first', | |
16 | - 'unread.sync.isUnread': 'first' | |
17 | -}) | |
18 | - | |
19 | -// function toRecpGroup(msg) { | |
20 | -// //cannocialize | |
21 | -// return Array.isArray(msg.value.content.repcs) && | |
22 | -// msg.value.content.recps.map(function (e) { | |
23 | -// return (isString(e) ? e : e.link) | |
24 | -// }).sort().map(function (id) { | |
25 | -// return id.substring(0, 10) | |
26 | -// }).join(',') | |
27 | -// } | |
28 | - | |
29 | -exports.create = (api) => { | |
30 | - return nest('app.page.home', function (location) { | |
31 | - // location here can expected to be: { page: 'home'} | |
32 | - var strings = api.translations.sync.strings() | |
33 | - | |
34 | - | |
35 | - // function filterForThread (thread) { | |
36 | - // if(thread.value.private) | |
37 | - // return {private: toRecpGroup(thread)} | |
38 | - // else if(thread.value.content.channel) | |
39 | - // return {channel: thread.value.content.channel} | |
40 | - // } | |
41 | - | |
42 | - // function filter (rule, thread) { | |
43 | - // if(!thread.value) return false | |
44 | - // if(!rule) return true | |
45 | - // if(rule.channel) { | |
46 | - // return rule.channel == thread.value.content.channel | |
47 | - // } | |
48 | - // else if(rule.group) | |
49 | - // return rule.group == thread.value.content.group | |
50 | - // else if(rule.private) | |
51 | - // return rule.private == toRecpGroup(thread) | |
52 | - // else return true | |
53 | - // } | |
54 | - | |
55 | - var morePlease = false | |
56 | - var threadsObs = api.state.obs.threads() | |
57 | - | |
58 | - // DUCT TAPE: debounce the observable so it doesn't | |
59 | - // update the dom more than 1/second | |
60 | - threadsObs(function () { | |
61 | - if(morePlease) threadsObs.more() | |
62 | - }) | |
63 | - threadsObsDebounced = Debounce(threadsObs, 1000) | |
64 | - threadsObsDebounced(function () { | |
65 | - morePlease = false | |
66 | - }) | |
67 | - threadsObsDebounced.more = function () { | |
68 | - morePlease = true | |
69 | - requestIdleCallback(threadsObs.more) | |
70 | - } | |
71 | - | |
72 | - var updates = h('div.threads', []) | |
73 | - var threadsHtmlObs = More( | |
74 | - threadsObsDebounced, | |
75 | - function render (threads) { | |
76 | - | |
77 | - function latestUpdate(thread) { | |
78 | - var m = thread.timestamp || 0 | |
79 | - if(!thread.replies) return m | |
80 | - | |
81 | - for(var i = 0; i < thread.replies.length; i++) | |
82 | - m = Math.max(thread.replies[i].timestamp||0, m) | |
83 | - return m | |
84 | - } | |
85 | - | |
86 | - var o = {} | |
87 | - function roots (r) { | |
88 | - return Object.keys(r || {}).map(function (name) { | |
89 | - var id = r[name] | |
90 | - if(!o[id]) { | |
91 | - o[id] = true | |
92 | - return threads.roots[id] | |
93 | - } | |
94 | - }).filter(function (e) { | |
95 | - return e && e.value | |
96 | - }) | |
97 | - } | |
98 | - | |
99 | - var groupedThreads = roots(threads.private) | |
100 | - .concat(roots(threads.channels)) | |
101 | - .concat(roots(threads.groups)) | |
102 | - .filter(function (thread) { | |
103 | - return thread.value.content.recps || thread.value.content.channel | |
104 | - }) | |
105 | - .map(function (thread) { | |
106 | - var unread = 0 | |
107 | - if(api.unread.sync.isUnread(thread)) | |
108 | - unread ++ | |
109 | - ;(thread.replies || []).forEach(function (msg) { | |
110 | - if(api.unread.sync.isUnread(msg)) unread ++ | |
111 | - }) | |
112 | - thread.unread = unread | |
113 | - return thread | |
114 | - }) | |
115 | - .sort((a, b) => latestUpdate(b) - latestUpdate(a)) | |
116 | - | |
117 | - morphdom( | |
118 | - updates, | |
119 | - h('div.threads', | |
120 | - groupedThreads.map(thread => { | |
121 | - const { recps, channel } = thread.value.content | |
122 | - var onClick | |
123 | - if (channel && !recps) | |
124 | - onClick = (ev) => api.history.sync.push({ channel }) | |
125 | - | |
126 | - return api.app.html.blogCard(thread, { onClick }) | |
127 | - }) | |
128 | - ) | |
129 | - ) | |
130 | - | |
131 | - return updates | |
132 | - } | |
133 | - ) | |
134 | - return h('Page -home', {title: strings.home}, [ | |
135 | - h('div.content', [ threadsHtmlObs ]), | |
136 | - h('Button -showMore', { 'ev-click': threadsHtmlObs.more }, strings.showMore) | |
137 | - ]) | |
138 | - }) | |
139 | -} | |
140 | - | |
141 | - | |
142 | - | |
143 | - | |
144 | - | |
145 | - | |
146 | - | |
147 | - | |
148 | - | |
149 | - | |
150 | - | |
151 | - |
app/page/home.mcss | ||
---|---|---|
@@ -1,76 +1,0 @@ | ||
1 | -Page -home { | |
2 | - | |
3 | - div.content { | |
4 | - $backgroundPrimary | |
5 | - | |
6 | - | |
7 | - $homePageSection | |
8 | - | |
9 | - div.threads { | |
10 | - min-width: 60% | |
11 | - | |
12 | - div.BlogCard { | |
13 | - div.content { | |
14 | - div.subject { | |
15 | - display: flex | |
16 | - | |
17 | - div.recps { | |
18 | - display: flex | |
19 | - font-weight: 600 | |
20 | - margin-right: .4rem | |
21 | - | |
22 | - div.recp { | |
23 | - margin-right: .4rem | |
24 | - } | |
25 | - } | |
26 | - } | |
27 | - } | |
28 | - } | |
29 | - | |
30 | - -channel { | |
31 | - $homePageSection | |
32 | - | |
33 | - div.threads { | |
34 | - div.BlogCard { | |
35 | - div.context { | |
36 | - background: #fff | |
37 | - min-width: 8rem | |
38 | - padding: .1rem .3rem | |
39 | - border: 1px solid #ddd | |
40 | - border-radius: 2px | |
41 | - } | |
42 | - } | |
43 | - } | |
44 | - } | |
45 | - | |
46 | - -group { | |
47 | - $homePageSection | |
48 | - } | |
49 | - } | |
50 | - } | |
51 | -} | |
52 | - | |
53 | -$homePageSection { | |
54 | - $maxWidth | |
55 | - margin-left: auto | |
56 | - margin-right: auto | |
57 | - | |
58 | - display: flex | |
59 | - flex-direction: column | |
60 | - align-items: center | |
61 | - margin-bottom: 1.5rem | |
62 | - | |
63 | - h2 { | |
64 | - color: #888 | |
65 | - font-size: 1rem | |
66 | - text-align: center | |
67 | - padding-bottom: .3rem | |
68 | - width: 420px | |
69 | - margin-top: 0 | |
70 | - margin-bottom: .4rem | |
71 | - } | |
72 | - | |
73 | - div.threads { | |
74 | - div.BlogCard {} | |
75 | - } | |
76 | -} |
app/sync/initialize/clickHandler.js | ||
---|---|---|
@@ -1,0 +1,23 @@ | ||
1 | +const nest = require('depnest') | |
2 | + | |
3 | +exports.gives = nest('app.sync.initialize') | |
4 | + | |
5 | +exports.needs = nest({ | |
6 | + 'app.async.catchLinkClick': 'first', | |
7 | + 'history.sync.push': 'first', | |
8 | +}) | |
9 | + | |
10 | +exports.create = (api) => { | |
11 | + return nest({ | |
12 | + 'app.sync.initialize': function initializeClickHandling () { | |
13 | + const target = document.body | |
14 | + | |
15 | + api.app.async.catchLinkClick(target, (link, { isExternal }) => { | |
16 | + if (isExternal) return openExternal(link) | |
17 | + | |
18 | + api.history.sync.push(link) | |
19 | + }) | |
20 | + } | |
21 | + }) | |
22 | +} | |
23 | + |
app/sync/initialize/styles.js | ||
---|---|---|
@@ -1,0 +1,19 @@ | ||
1 | +const nest = require('depnest') | |
2 | +const insertCss = require('insert-css') | |
3 | +const values = require('lodash/values') | |
4 | + | |
5 | +exports.gives = nest('app.sync.initialize') | |
6 | + | |
7 | +exports.needs = nest({ | |
8 | + 'styles.css': 'reduce' | |
9 | +}) | |
10 | + | |
11 | +exports.create = (api) => { | |
12 | + return nest({ | |
13 | + 'app.sync.initialize': function initializeStyles () { | |
14 | + const css = values(api.styles.css()).join('\n') | |
15 | + insertCss(css) | |
16 | + } | |
17 | + }) | |
18 | +} | |
19 | + |
app/sync/initialize/suggests.js | ||
---|---|---|
@@ -1,0 +1,20 @@ | ||
1 | +const nest = require('depnest') | |
2 | + | |
3 | +exports.gives = nest('app.sync.initialize') | |
4 | + | |
5 | +exports.needs = nest({ | |
6 | + 'about.async.suggest': 'first', | |
7 | + 'channel.async.suggest': 'first' | |
8 | +}) | |
9 | + | |
10 | +exports.create = (api) => { | |
11 | + var nav = null | |
12 | + | |
13 | + return nest({ | |
14 | + 'app.sync.initialize': function initializeSuggests () { | |
15 | + api.about.async.suggest() | |
16 | + api.channel.async.suggest() | |
17 | + } | |
18 | + }) | |
19 | +} | |
20 | + |
config.js | ||
---|---|---|
@@ -2,9 +2,10 @@ | ||
2 | 2 | const nest = require('depnest') |
3 | 3 | const ssbKeys = require('ssb-keys') |
4 | 4 | const Path = require('path') |
5 | 5 | |
6 | -const appName = process.env.ssb_appname || 'ticktack' //'ticktack' TEMP: this is for the windowsSSB installer only | |
6 | +// const appName = process.env.ssb_appname || 'ticktack' //'ticktack' TEMP: this is for the windowsSSB installer only | |
7 | +const appName = 'ssb' | |
7 | 8 | const opts = appName == 'ssb' |
8 | 9 | ? null |
9 | 10 | : require('./default-config.json') |
10 | 11 |
main.js | ||
---|---|---|
@@ -18,8 +18,9 @@ | ||
18 | 18 | { |
19 | 19 | about: require('./about'), |
20 | 20 | app: require('./app'), |
21 | 21 | blob: require('./blob'), |
22 | + contact: require('./contact'), | |
22 | 23 | //config: require('./ssb-config'), |
23 | 24 | config: require('./config'), |
24 | 25 | // group: require('./group'), |
25 | 26 | message: require('./message'), |
@@ -30,8 +31,9 @@ | ||
30 | 31 | }, |
31 | 32 | { |
32 | 33 | suggestions: require('patch-suggest'), |
33 | 34 | profile: require('patch-profile'), |
35 | + history: require('patch-history'), | |
34 | 36 | core: require('patchcore') |
35 | 37 | } |
36 | 38 | ) |
37 | 39 |
message/html/compose.js | ||
---|---|---|
@@ -21,9 +21,17 @@ | ||
21 | 21 | |
22 | 22 | exports.create = function (api) { |
23 | 23 | return nest('message.html.compose', compose) |
24 | 24 | |
25 | - function compose ({ shrink = true, meta, prepublish, placeholder }, cb) { | |
25 | + function compose (options, cb) { | |
26 | + var { | |
27 | + meta, // required | |
28 | + placeholder, | |
29 | + shrink = true, | |
30 | + canAttach = true, canPreview = true, | |
31 | + prepublish | |
32 | + } = options | |
33 | + | |
26 | 34 | const strings = api.translations.sync.strings() |
27 | 35 | const getProfileSuggestions = api.about.async.suggest() |
28 | 36 | const getChannelSuggestions = api.channel.async.suggest() |
29 | 37 | const getEmojiSuggestions = api.emoji.async.suggest() |
@@ -80,12 +88,12 @@ | ||
80 | 88 | } |
81 | 89 | // if fileInput is null, send button moves to the left side |
82 | 90 | // and we don't want that. |
83 | 91 | else |
84 | - fileInput = h('input', { style: {visibility: 'hidden'}}) | |
92 | + fileInput = h('input', { style: {visibility: 'hidden'} }) | |
85 | 93 | |
86 | 94 | var showPreview = Value(false) |
87 | - var previewBtn = h('Button', | |
95 | + var previewBtn = h('Button -preview', | |
88 | 96 | { |
89 | 97 | className: when(showPreview, '-primary'), |
90 | 98 | 'ev-click': () => showPreview.set(!showPreview()) |
91 | 99 | }, |
@@ -95,10 +103,10 @@ | ||
95 | 103 | |
96 | 104 | var publishBtn = h('Button -primary', { 'ev-click': publish }, strings.sendMessage) |
97 | 105 | |
98 | 106 | var actions = h('section.actions', [ |
99 | - fileInput, | |
100 | - previewBtn, | |
107 | + canAttach ? fileInput : '', | |
108 | + canPreview ? previewBtn : '', | |
101 | 109 | publishBtn |
102 | 110 | ]) |
103 | 111 | |
104 | 112 | var composer = h('Compose', { |
message/html/compose.mcss | ||
---|---|---|
@@ -6,36 +6,23 @@ | ||
6 | 6 | |
7 | 7 | textarea { |
8 | 8 | $fontBasic |
9 | 9 | |
10 | - padding: .6rem | |
11 | - $borderSubtle | |
12 | - border-top-left-radius: 0 | |
13 | - border-top-right-radius: 0 | |
14 | - } | |
10 | + padding: 1rem | |
11 | + border-radius: 1rem | |
12 | + border: none | |
13 | + margin-bottom: .5rem | |
15 | 14 | |
16 | - input.channel { | |
17 | - $borderSubtle | |
18 | - border-bottom: none | |
19 | - border-bottom-left-radius: 0 | |
20 | - border-bottom-right-radius: 0 | |
21 | - padding: .5rem | |
22 | - | |
23 | 15 | :focus { |
24 | 16 | outline: none |
25 | - box-shadow: none | |
26 | 17 | } |
27 | - :disabled { | |
28 | - background-color: #f1f1f1 | |
29 | - cursor: not-allowed | |
30 | - } | |
31 | 18 | } |
32 | 19 | |
33 | 20 | section.actions { |
34 | 21 | display: flex |
35 | 22 | flex-direction: row |
36 | 23 | align-items: baseline |
37 | - justify-content: space-between | |
24 | + justify-content: flex-end | |
38 | 25 | |
39 | 26 | margin-top: .4rem |
40 | 27 | |
41 | 28 | input { flex-grow: 1 } |
message/html/channel.js | ||
---|---|---|
@@ -1,0 +1,24 @@ | ||
1 | +const nest = require('depnest') | |
2 | +const { h } = require('mutant') | |
3 | + | |
4 | +exports.gives = nest('message.html.channel') | |
5 | + | |
6 | +exports.needs = nest({ | |
7 | + 'history.sync.push': 'first' | |
8 | +}) | |
9 | + | |
10 | +exports.create = function (api) { | |
11 | + return nest('message.html.channel', channel) | |
12 | + | |
13 | + function channel (msg) { | |
14 | + const { channel } = msg.value.content | |
15 | + | |
16 | + if (!channel) return | |
17 | + | |
18 | + return h('Button -channel', { | |
19 | + // 'ev-click': () => history.sync.push({ page: 'channelIndex', channel }) // TODO | |
20 | + }, channel) | |
21 | + } | |
22 | +} | |
23 | + | |
24 | + |
message/html/likes.js | ||
---|---|---|
@@ -1,0 +1,44 @@ | ||
1 | +var { h, computed, when } = require('mutant') | |
2 | +var nest = require('depnest') | |
3 | + | |
4 | +exports.needs = nest({ | |
5 | + 'keys.sync.id': 'first', | |
6 | + 'message.obs.likes': 'first', | |
7 | + 'sbot.async.publish': 'first' | |
8 | +}) | |
9 | + | |
10 | +exports.gives = nest('message.html.likes') | |
11 | + | |
12 | +exports.create = (api) => { | |
13 | + return nest('message.html.likes', function likes (msg) { | |
14 | + var id = api.keys.sync.id() | |
15 | + var likes = api.message.obs.likes(msg.key) | |
16 | + var iLike = computed(likes, likes => likes.includes(id)) | |
17 | + var count = computed(likes, likes => likes.length ? likes.length : '') | |
18 | + | |
19 | + return h('Likes', {'ev-click': () => publishLike(msg, !iLike())}, [ | |
20 | + h('i.fa', { className: when(iLike, 'fa-heart', 'fa-heart-o') }), | |
21 | + h('div.count', count) | |
22 | + ]) | |
23 | + }) | |
24 | + | |
25 | + function publishLike (msg, status = true) { | |
26 | + var like = status ? { | |
27 | + type: 'vote', | |
28 | + channel: msg.value.content.channel, | |
29 | + vote: { link: msg.key, value: 1, expression: 'Like' } | |
30 | + } : { | |
31 | + type: 'vote', | |
32 | + channel: msg.value.content.channel, | |
33 | + vote: { link: msg.key, value: 0, expression: 'Unlike' } | |
34 | + } | |
35 | + if (msg.value.content.recps) { | |
36 | + like.recps = msg.value.content.recps.map(function (e) { | |
37 | + return e && typeof e !== 'string' ? e.link : e | |
38 | + }) | |
39 | + like.private = true | |
40 | + } | |
41 | + api.sbot.async.publish(like) | |
42 | + } | |
43 | +} | |
44 | + |
message/html/timeago.js | ||
---|---|---|
@@ -1,0 +1,18 @@ | ||
1 | +const nest = require('depnest') | |
2 | +const { h } = require('mutant') | |
3 | +const humanTime = require('human-time') | |
4 | + | |
5 | +exports.gives = nest('message.html.timeago') | |
6 | + | |
7 | +exports.create = function (api) { | |
8 | + return nest('message.html.timeago', timeago) | |
9 | + | |
10 | + function timeago (msg) { | |
11 | + const { timestamp } = msg.value | |
12 | + | |
13 | + // TODO implement the light auto-updating of this app-wide | |
14 | + // perhaps by adding an initializer which sweeps for data-timestamp elements and updates them | |
15 | + return h('Timeago', humanTime(new Date(timestamp))) | |
16 | + } | |
17 | +} | |
18 | + |
message/index.js | ||
---|---|---|
@@ -2,9 +2,12 @@ | ||
2 | 2 | async: { |
3 | 3 | publish: require('./async/publish'), |
4 | 4 | }, |
5 | 5 | html: { |
6 | + channel: require('./html/channel'), | |
6 | 7 | compose: require('./html/compose'), |
7 | - subject: require('./html/subject') | |
8 | + likes: require('./html/likes'), | |
9 | + subject: require('./html/subject'), | |
10 | + timeago: require('./html/timeago') | |
8 | 11 | } |
9 | 12 | } |
10 | 13 |
package-lock.json | ||
---|---|---|
The diff is too large to show. Use a local git client to view these changes. Old file size: 201691 bytes New file size: 211063 bytes |
package.json | ||
---|---|---|
@@ -7,9 +7,10 @@ | ||
7 | 7 | "rebuild": "cross-script npm rebuild --runtime=electron \"--target=$(electron -v)\" \"--abi=$(electron --abi)\" --disturl=https://atom.io/download/atom-shell", |
8 | 8 | "start": "electron .", |
9 | 9 | "dev": "ssb_appname=ssb electro main.js", |
10 | 10 | "postinstall": "echo 'REMEMBER: npm run rebuild'", |
11 | - "test": "echo \"Error: no test specified\" && exit 1" | |
11 | + "test": "npm run test:deps", | |
12 | + "test:deps": "dependency-check package.json --entry main.js --entry background-process.js && dependency-check package.json --entry main.js --entry background-process.js --extra --no-dev" | |
12 | 13 | }, |
13 | 14 | "repository": { |
14 | 15 | "type": "git", |
15 | 16 | "url": "ssb://%tkJPTTaxOzfLbsewZmgC9CslSER0ntjQOcyhIk6y/cQ=.sha256" |
@@ -33,10 +34,11 @@ | ||
33 | 34 | "markdown-summary": "^1.0.3", |
34 | 35 | "micro-css": "^2.0.1", |
35 | 36 | "morphdom": "^2.3.3", |
36 | 37 | "mutant": "^3.21.2", |
37 | - "obv-debounce": "^1.0.2", | |
38 | + "mutant-scroll": "0.0.3", | |
38 | 39 | "open-external": "^0.1.1", |
40 | + "patch-history": "^1.0.0", | |
39 | 41 | "patch-profile": "^1.0.2", |
40 | 42 | "patch-settings": "^1.0.0", |
41 | 43 | "patch-suggest": "^1.0.1", |
42 | 44 | "patchcore": "^1.12.0", |
@@ -45,16 +47,18 @@ | ||
45 | 47 | "pull-obv": "^1.3.0", |
46 | 48 | "pull-stream": "^3.6.0", |
47 | 49 | "read-directory": "^2.1.0", |
48 | 50 | "require-style": "^1.0.1", |
49 | - "scuttlebot": "^10.4.4", | |
51 | + "scuttlebot": "^10.4.10", | |
50 | 52 | "setimmediate": "^1.0.5", |
51 | 53 | "ssb-about": "^0.1.0", |
52 | 54 | "ssb-backlinks": "^0.4.0", |
53 | 55 | "ssb-blobs": "^1.1.3", |
56 | + "ssb-config": "^2.2.0", | |
54 | 57 | "ssb-contacts": "0.0.2", |
55 | - "ssb-friends": "^2.2.1", | |
58 | + "ssb-friends": "^2.3.5", | |
56 | 59 | "ssb-keys": "^7.0.10", |
60 | + "ssb-markdown": "^3.3.0", | |
57 | 61 | "ssb-mentions": "^0.4.0", |
58 | 62 | "ssb-private": "^0.1.2", |
59 | 63 | "ssb-query": "^0.1.2", |
60 | 64 | "ssb-reduce-stream": "^1.0.2", |
@@ -63,8 +67,9 @@ | ||
63 | 67 | "suggest-box": "^2.2.3", |
64 | 68 | "url": "^0.11.0" |
65 | 69 | }, |
66 | 70 | "devDependencies": { |
71 | + "dependency-check": "^2.9.1", | |
67 | 72 | "electron": "~1.6.11", |
68 | 73 | "flat": "^4.0.0" |
69 | 74 | } |
70 | 75 | } |
router/sync/routes.js | ||
---|---|---|
@@ -8,8 +8,9 @@ | ||
8 | 8 | exports.needs = nest({ |
9 | 9 | 'app.page.error': 'first', |
10 | 10 | 'app.page.blogIndex': 'first', |
11 | 11 | 'app.page.blogNew': 'first', |
12 | + 'app.page.blogShow': 'first', | |
12 | 13 | 'app.page.settings': 'first', |
13 | 14 | // 'app.page.channel': 'first', |
14 | 15 | // 'app.page.groupFind': 'first', |
15 | 16 | // 'app.page.groupIndex': 'first', |
@@ -30,9 +31,22 @@ | ||
30 | 31 | // route format: [ routeValidator, routeFunction ] |
31 | 32 | |
32 | 33 | const routes = [ |
33 | 34 | |
34 | - // Thread pages | |
35 | + // Blog pages | |
36 | + [ location => location.page === 'home', pages.blogIndex ], | |
37 | + [ location => location.page === 'discovery', pages.blogIndex ], | |
38 | + [ location => location.page === 'blogIndex', pages.blogIndex ], | |
39 | + [ location => location.page === 'blogNew', pages.blogNew ], | |
40 | + [ location => location.page === 'blogShow', pages.blogShow ], | |
41 | + [ location => isMsg(location.key) && get(location, 'value.content.type') === 'blog', pages.blogShow ], | |
42 | + [ location => { | |
43 | + return isMsg(location.key) | |
44 | + && get(location, 'value.content.type') === 'post' | |
45 | + && !get(location, 'value.private') // treats public posts as 'blogs' | |
46 | + }, pages.blogShow ], | |
47 | + | |
48 | + // Private Thread pages | |
35 | 49 | // [ location => location.page === 'threadNew' && location.channel, pages.threadNew ], |
36 | 50 | [ location => location.page === 'threadNew' && isFeed(location.feed), pages.threadNew ], |
37 | 51 | [ location => isMsg(location.key), pages.threadShow ], |
38 | 52 | |
@@ -47,14 +61,8 @@ | ||
47 | 61 | // [ location => location.page === 'groupNew', pages.groupNew ], |
48 | 62 | // // [ location => location.type === 'groupShow' && isMsg(location.key), pages.groupShow ], |
49 | 63 | // [ location => location.channel , pages.channel ], |
50 | 64 | |
51 | - // Blog pages | |
52 | - [ location => location.page === 'home', pages.blogIndex ], | |
53 | - [ location => location.page === 'discovery', pages.blogIndex ], | |
54 | - [ location => location.page === 'blogIndex', pages.blogIndex ], | |
55 | - [ location => location.page === 'blogNew', pages.blogNew ], | |
56 | - | |
57 | 65 | [ location => location.page === 'settings', pages.settings ], |
58 | 66 | |
59 | 67 | // [ location => isBlob(location.blob), pages.image ], |
60 | 68 | [ location => isBlob(location.blob), (location) => { |
state/obs.js | ||
---|---|---|
@@ -13,9 +13,10 @@ | ||
13 | 13 | exports.needs = nest({ |
14 | 14 | 'message.sync.unbox': 'first', |
15 | 15 | 'sbot.pull.log': 'first', |
16 | 16 | 'sbot.async.get': 'first', |
17 | - 'feed.pull.channel': 'first' | |
17 | + 'feed.pull.channel': 'first', | |
18 | + 'feed.pull.rollup': 'first' | |
18 | 19 | }) |
19 | 20 | |
20 | 21 | exports.create = function (api) { |
21 | 22 | var threadsObs |
@@ -38,29 +39,16 @@ | ||
38 | 39 | }), |
39 | 40 | pull.through(function (data) { |
40 | 41 | lastTimestamp = data.timestamp |
41 | 42 | }), |
42 | - pull.map(unbox), pull.filter(Boolean) | |
43 | + pull.map(unbox), | |
44 | + pull.filter(Boolean), | |
45 | + api.feed.pull.rollup() | |
43 | 46 | ), |
44 | 47 | //value recovered from localStorage |
45 | 48 | initial |
46 | 49 | ) |
47 | 50 | |
48 | - var getting = {} | |
49 | - obs(function (state) { | |
50 | - var effect = state.effect | |
51 | - if(!effect) return | |
52 | - | |
53 | - state.effect = null | |
54 | - if(getting[effect.key]) return | |
55 | - | |
56 | - getting[effect.key] = true | |
57 | - api.sbot.async.get(effect.key, (err, msg) => { | |
58 | - if (!msg) return | |
59 | - obs.set(STATE=reduce(obs.value, unbox({key: effect.key, value: msg}))) | |
60 | - }) | |
61 | - }) | |
62 | - | |
63 | 51 | //stream live messages. this *should* work. |
64 | 52 | //there is no back pressure on new events |
65 | 53 | //only a show more on the top (currently) |
66 | 54 | pull( |
@@ -103,9 +91,9 @@ | ||
103 | 91 | |
104 | 92 | }, |
105 | 93 | |
106 | 94 | 'state.obs.threads': function buildThreadObs() { |
107 | - if(threadsObs) return threadsObs | |
95 | + if (threadsObs) return threadsObs | |
108 | 96 | |
109 | 97 | // DISABLE localStorage cache. mainly disabling this to make debugging the other stuff |
110 | 98 | // easier. maybe re-enable this later? also, should this be for every channel too? not sure. |
111 | 99 |
styles/button.mcss | ||
---|---|---|
@@ -1,10 +1,10 @@ | ||
1 | 1 | Button { |
2 | 2 | font-family: arial |
3 | - background-color: #fff | |
3 | + $backgroundPrimaryText | |
4 | 4 | |
5 | 5 | min-width: 6rem |
6 | - height: 1.2rem | |
6 | + height: 1.2em | |
7 | 7 | padding: .2rem 1rem |
8 | 8 | |
9 | 9 | border: 1px #b9b9b9 solid |
10 | 10 | border-radius: 10rem |
@@ -23,15 +23,23 @@ | ||
23 | 23 | } |
24 | 24 | |
25 | 25 | -primary { |
26 | 26 | $colorPrimary |
27 | + $font | |
27 | 28 | $borderPrimary |
28 | 29 | |
29 | 30 | :hover { |
30 | 31 | opacity: .9 |
31 | 32 | } |
32 | 33 | } |
33 | 34 | |
35 | + -channel { | |
36 | + $backgroundPrimary | |
37 | + $colorFontPrimary | |
38 | + font-size: .9rem | |
39 | + min-width: initial | |
40 | + } | |
41 | + | |
34 | 42 | -showMore { |
35 | 43 | width: 100% |
36 | 44 | |
37 | 45 | padding: .2rem 0 |
styles/global.mcss | ||
---|---|---|
@@ -1,21 +1,11 @@ | ||
1 | 1 | body { |
2 | 2 | $fontBasic |
3 | - background-color: #fff | |
3 | + $backgroundPrimaryText | |
4 | 4 | |
5 | 5 | margin: 0 |
6 | 6 | |
7 | - // different to Page | |
8 | - div.page { | |
9 | 7 | |
10 | - overflow: hidden | |
11 | - position: absolute | |
12 | - top: 0 | |
13 | - bottom: 0 | |
14 | - right: 0 | |
15 | - left: 0 | |
16 | - } | |
17 | - | |
18 | 8 | (a) { |
19 | 9 | color: #2f63ad |
20 | 10 | text-decoration: none |
21 | 11 |
styles/markdown.mcss | ||
---|---|---|
@@ -5,8 +5,18 @@ | ||
5 | 5 | margin: .5rem 0 |
6 | 6 | border-radius: .5rem |
7 | 7 | } |
8 | 8 | |
9 | + // center blog images | |
10 | + p { | |
11 | + a { | |
12 | + img { | |
13 | + display: block | |
14 | + margin: auto | |
15 | + } | |
16 | + } | |
17 | + } | |
18 | + | |
9 | 19 | (img.emoji) { |
10 | 20 | margin: 0 |
11 | 21 | } |
12 | 22 | } |
styles/mixins.js | ||
---|---|---|
@@ -44,10 +44,10 @@ | ||
44 | 44 | $colorFontBasic { |
45 | 45 | color: #222 |
46 | 46 | } |
47 | 47 | |
48 | -$colorPrimaryFG { | |
49 | - color: #fff | |
48 | +$colorFontPrimary { | |
49 | + color: #5c6bc0 | |
50 | 50 | } |
51 | 51 | |
52 | 52 | $colorSubtle { |
53 | 53 | color: #999 |
@@ -56,28 +56,52 @@ | ||
56 | 56 | $backgroundPrimary { |
57 | 57 | background-color: #f5f6f7 |
58 | 58 | } |
59 | 59 | |
60 | +$backgroundPrimaryText { | |
61 | + background-color: #fff | |
62 | +} | |
63 | + | |
60 | 64 | $backgroundSelected { |
61 | 65 | background-color: #f0f1f2 |
62 | 66 | } |
63 | 67 | |
64 | 68 | $borderPrimary { |
65 | 69 | border: 1px #2f63ad solid |
66 | 70 | } |
67 | 71 | |
68 | -$avatarSmall { | |
69 | - width: 3rem | |
70 | - height: 3rem | |
71 | - border-radius: 1.5rem | |
72 | +$circleTiny { | |
73 | + min-width: 2rem | |
74 | + min-height: 2rem | |
75 | + width: 2rem | |
76 | + height: 2rem | |
77 | + border-radius: 1rem | |
72 | 78 | } |
73 | 79 | |
74 | -$avatarLarge { | |
75 | - width: 6rem | |
76 | - height: 6rem | |
77 | - border-radius: 3rem | |
80 | +$circleSmall { | |
81 | + min-width: 2.8rem | |
82 | + min-height: 2.8rem | |
83 | + width: 2.8rem | |
84 | + height: 2.8rem | |
85 | + border-radius: 1.4rem | |
78 | 86 | } |
79 | 87 | |
88 | +$circleMedium { | |
89 | + min-width: 3.5rem | |
90 | + min-height: 3.5rem | |
91 | + width: 3.5rem | |
92 | + height: 3.5rem | |
93 | + border-radius: 1.75rem | |
94 | +} | |
95 | + | |
96 | +$circlelarge { | |
97 | + min-width: 5rem | |
98 | + min-height: 5rem | |
99 | + width: 5rem | |
100 | + height: 5rem | |
101 | + border-radius: 2.5rem | |
102 | +} | |
103 | + | |
80 | 104 | $markdownSmall { |
81 | 105 | div.Markdown { |
82 | 106 | h1, h2, h3, h4, h5, h6, p { |
83 | 107 | font-size: .9rem |
unread.js | ||
---|---|---|
@@ -1,9 +1,11 @@ | ||
1 | 1 | var nest = require('depnest') |
2 | +var { Dict, Set } = require('mutant') | |
2 | 3 | |
3 | 4 | exports.gives = nest({ |
4 | 5 | 'unread.sync.isUnread': true, |
5 | 6 | 'unread.sync.markRead': true, |
7 | + 'unread.obs.userMessages': true | |
6 | 8 | }) |
7 | 9 | |
8 | 10 | //load current state of unread messages. |
9 | 11 | |
@@ -33,31 +35,33 @@ | ||
33 | 35 | }, 2e3) |
34 | 36 | } |
35 | 37 | |
36 | 38 | function isUnread(msg) { |
37 | - //ignore messages which do not have timestamps | |
38 | - if(!msg.timestamp) return false | |
39 | - if(msg.timestamp < unread.timestamp) return false | |
40 | - if(unread.filter[msg.key]) { | |
41 | - return false | |
42 | - } | |
43 | - return true | |
39 | + if(msg.timestamp && msg.timestamp < unread.timestamp) return false | |
40 | + return !unread.filter[msg.key] | |
44 | 41 | } |
45 | 42 | |
46 | 43 | function markRead(msg) { |
47 | - if('string' === typeof msg.key) { | |
48 | - //if(isUnread(msg)) { | |
44 | + if(msg && 'string' === typeof msg.key) { | |
45 | + //note: there is a quirk where some messages don't have a timestamp | |
46 | + if(isUnread(msg)) { | |
47 | + var userUser | |
49 | 48 | unread.filter[msg.key] = true |
50 | 49 | save() |
51 | 50 | return true |
52 | - //} | |
51 | + } | |
53 | 52 | } |
54 | 53 | } |
55 | 54 | |
55 | + function userMessages(feedId) { | |
56 | + | |
57 | + } | |
58 | + | |
56 | 59 | document.body.onunload = save |
57 | 60 | |
58 | 61 | return nest({ |
59 | 62 | 'unread.sync.isUnread': isUnread, |
60 | - 'unread.sync.markRead': markRead | |
63 | + 'unread.sync.markRead': markRead, | |
64 | + 'unread.obs.userMessages': userMessages | |
61 | 65 | }) |
62 | 66 | } |
63 | 67 |
contact/html/follow.js | ||
---|---|---|
@@ -1,0 +1,41 @@ | ||
1 | +const nest = require('depnest') | |
2 | +const { h, Array: MutantArray, computed, when, map } = require('mutant') | |
3 | + | |
4 | +exports.gives = nest('contact.html.follow') | |
5 | + | |
6 | +exports.needs = nest({ | |
7 | + 'contact.async.follow': 'first', | |
8 | + 'contact.async.unfollow': 'first', | |
9 | + 'contact.obs.followers': 'first', | |
10 | + 'keys.sync.id': 'first', | |
11 | + 'translations.sync.strings': 'first', | |
12 | +}) | |
13 | + | |
14 | +exports.create = (api) => { | |
15 | + return nest('contact.html.follow', follow) | |
16 | + | |
17 | + function follow (feed) { | |
18 | + const strings = api.translations.sync.strings() | |
19 | + const myId = api.keys.sync.id() | |
20 | + | |
21 | + if (feed === myId) return | |
22 | + | |
23 | + const { followers } = api.contact.obs | |
24 | + const theirFollowers = followers(feed) | |
25 | + const youFollowThem = computed(theirFollowers, followers => followers.includes(myId)) | |
26 | + | |
27 | + const { unfollow, follow } = api.contact.async | |
28 | + const className = when(youFollowThem, '-following') | |
29 | + | |
30 | + return h('Follow', { className }, | |
31 | + when(theirFollowers.sync, | |
32 | + when(youFollowThem, | |
33 | + h('Button', { 'ev-click': () => unfollow(feed) }, strings.userShow.action.unfollow), | |
34 | + h('Button', { 'ev-click': () => follow(feed) }, strings.userShow.action.follow) | |
35 | + ), | |
36 | + h('Button', { disabled: 'disabled' }, strings.loading ) | |
37 | + ) | |
38 | + ) | |
39 | + } | |
40 | +} | |
41 | + |
Built with git-ssb-web