git ssb

2+

mixmix / ticktack



Commit 4a58e38afe8d7cd5659d286607457aeb91ee7638

Merge branch 'master' into blogSearch

mix irving committed on 11/29/2017, 1:47:44 AM
Parent: 35e506868ea54929c5978b29572a923888e2518d
Parent: 325cc232dd44d379082757ecd6a63f9305c81f9f

Files changed

about/html/avatar.jschanged
about/html/avatar.mcsschanged
app/async/catch-link-click.jschanged
app/html/app.jschanged
app/html/context.jschanged
app/html/context.mcsschanged
app/html/header.jschanged
app/html/thread.jschanged
app/html/thread.mcsschanged
app/html/blog-card.jsdeleted
app/html/app.mcssadded
app/html/blog-card.mcssdeleted
app/html/blogCard.jsadded
app/html/blogCard.mcssadded
app/html/comments.jsadded
app/html/comments.mcssadded
app/html/scroller.jsadded
app/html/scroller.mcssadded
app/index.jschanged
app/page/blogIndex.jschanged
app/page/blogIndex.mcsschanged
app/page/error.jschanged
app/page/groupFind.mcsschanged
app/page/settings.jschanged
app/page/threadNew.mcsschanged
app/page/threadShow.jschanged
app/page/userFind.mcsschanged
app/page/userShow.jschanged
app/page/userShow.mcsschanged
app/page/blogShow.jsadded
app/page/blogShow.mcssadded
app/page/error.mcssadded
app/page/home.jsdeleted
app/page/home.mcssdeleted
app/sync/initialize/clickHandler.jsadded
app/sync/initialize/styles.jsadded
app/sync/initialize/suggests.jsadded
config.jschanged
main.jschanged
message/html/compose.jschanged
message/html/compose.mcsschanged
message/html/channel.jsadded
message/html/likes.jsadded
message/html/likes.mcssadded
message/html/timeago.jsadded
message/html/timeago.mcssadded
message/index.jschanged
package-lock.jsonchanged
package.jsonchanged
router/sync/routes.jschanged
state/obs.jschanged
styles/button.mcsschanged
styles/global.mcsschanged
styles/markdown.mcsschanged
styles/mixins.jschanged
unread.jschanged
contact/html/follow.jsadded
contact/index.jsadded
about/html/avatar.jsView
@@ -1,22 +1,26 @@
11 const nest = require('depnest')
22 const { h } = require('mutant')
33
4+exports.gives = nest('about.html.avatar')
5+
46 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'
710 })
811
9-exports.gives = nest('about.html.avatar')
10-
1112 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+ })
2024 })
2125 }
2226
about/html/avatar.mcssView
@@ -1,5 +1,21 @@
11 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+ }
420 }
521
app/async/catch-link-click.jsView
@@ -1,9 +1,14 @@
@@ -29,8 +34,16 @@
app/html/app.jsView
@@ -1,79 +1,56 @@
11 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')
53
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')
95
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-
176 exports.needs = nest({
18- 'about.async.suggest': 'first',
7+ 'app.sync.initialize': 'map',
198 'app.html.header': 'first',
20- 'app.async.catchLinkClick': 'first',
21- 'channel.async.suggest': 'first',
9+ 'history.obs.location': 'first',
10+ 'history.sync.push': 'first',
2211 'keys.sync.id': 'first',
2312 'router.sync.router': 'first',
2413 'settings.sync.get': 'first',
2514 'settings.sync.set': 'first',
26- 'styles.css': 'reduce',
2715 })
2816
2917 exports.create = (api) => {
30- var nav = null
31-
3218 return nest({
3319 'app.html.app': function app () {
20+ api.app.sync.initialize()
3421
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+ }
3832
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-
5233 const isOnboarded = api.settings.sync.get('onboarded')
5334 if (isOnboarded)
54- nav.push({page: 'home'})
35+ api.history.sync.push({page: 'home'})
5536 else {
56- nav.push({
37+ api.history.sync.push({
5738 page:'userEdit',
5839 feed: api.keys.sync.id(),
5940 callback: (err, didEdit) => {
6041 if (err) throw new Error ('Error editing profile', err)
6142
6243 if (didEdit)
6344 api.settings.sync.set({ onboarded: true })
6445
65- nav.push({ page: 'home' })
46+ api.history.sync.push({ page: 'home' })
6647 }
6748 })
6849 }
6950
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+ }
7554 })
7655 }
7756
78-
79-
app/html/context.jsView
@@ -1,135 +1,245 @@
11 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')
33 const pull = require('pull-stream')
44 const next = require('pull-next-step')
55 const get = require('lodash/get')
66 const isEmpty = require('lodash/isEmpty')
77
88 exports.gives = nest('app.html.context')
99
1010 exports.needs = nest({
11+ 'app.html.scroller': 'first',
1112 'about.html.avatar': 'first',
1213 'about.obs.name': 'first',
1314 'feed.pull.private': 'first',
1415 'feed.pull.rollup': 'first',
16+ 'feed.pull.public': 'first',
1517 'keys.sync.id': 'first',
1618 'history.sync.push': 'first',
1719 'message.html.subject': 'first',
1820 'sbot.obs.localPeers': 'first',
1921 'translations.sync.strings': 'first',
22+ 'unread.sync.isUnread': 'first'
2023 })
2124
22-
2325 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 ] }
2529
30+ return nest('app.html.context', context)
31+
32+ function context (location) {
2633 const strings = api.translations.sync.strings()
2734 const myKey = api.keys.sync.id()
2835
2936 var nearby = api.sbot.obs.localPeers()
30- var recentPeersContacted = Dict()
31- // TODO - extract as contact.obs.recentPrivate or something
3237
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+ }
3347 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+ )
4352
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)
4757 )
4858
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+
4967 return h('Context -feed', [
5068 LevelOneContext(),
5169 LevelTwoContext()
5270 ])
5371
5472 function LevelOneContext () {
55- const PAGES_UNDER_DISCOVER = ['blogIndex', 'blogShow', 'home']
73+ function isDiscoverContext (loc) {
74+ const PAGES_UNDER_DISCOVER = ['blogIndex', 'blogShow', 'home']
5675
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 = [
5881 // Nearby
5982 computed(nearby, n => !isEmpty(n) ? h('header', strings.peopleNearby) : null),
6083 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'),
6386 label: api.about.obs.name(feedId),
6487 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)
6790 return lastMsg
6891 ? Object.assign(lastMsg, { feed: feedId })
6992 : { page: 'threadNew', feed: feedId }
7093 }),
71- })),
94+ }), { comparer: (a, b) => a === b }),
95+
96+ // ---------------------
7297 computed(nearby, n => !isEmpty(n) ? h('hr') : null),
7398
7499 // Discover
75100 Option({
76- notifications: Math.floor(Math.random()*5+1),
101+ // notifications: '!', //TODO - count this!
77102 imageEl: h('i.fa.fa-binoculars'),
78103 label: strings.blogIndex.title,
79- selected: PAGES_UNDER_DISCOVER.includes(location.page),
104+ selected: isDiscoverContext(location),
80105 location: { page: 'blogIndex' },
81- }),
106+ })
107+ ]
82108
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
88121
89122 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?
95129 })
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+ }
96158 })
97- ])
159+ }
160+
98161 }
99162
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+
100180 function LevelTwoContext () {
101181 const { key, value, feed: targetUser, page } = location
102182 const root = get(value, 'content.root', key)
103183 if (!targetUser) return
104184
105- var threads = MutantArray()
106185
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+ )
113210 ),
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)
125216 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 }),
129220 })
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+ }
130240 })
131- ])
241+ }
132242 }
133243
134244 function Option ({ notifications = 0, imageEl, label, location, selected }) {
135245 const className = selected ? '-selected' : ''
@@ -152,7 +262,24 @@
152262 ]),
153263 h('div.label', { 'ev-click': goToLocation }, label)
154264 ])
155265 }
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+ }
157275 }
158276
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.mcssView
@@ -1,9 +1,9 @@
11 Context {
22 flex-shrink: 0
33 flex-grow: 0
44 overflow: hidden
5- background-color: #fff
5+ $backgroundPrimaryText
66
77 display: flex
88
99 div.level {
@@ -12,25 +12,33 @@
1212 overflow-x: hidden
1313
1414 border-right: 1px gainsboro solid
1515
16- div.Option {}
16+ div.wrapper {
17+ section {
18+ header {
19+ $colorSubtle
20+ padding: .5rem 1rem
21+ }
1722
18- header {
19- $colorSubtle
20- padding: .5rem 1rem
21- }
23+ div.Option {}
2224
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+ }
2731 }
2832
2933 -one {}
3034 -two {
31- div.Option {
32- padding: 0 1rem
35+ div.wrapper {
36+ section {
37+ div.Option {
38+ padding: 0 1rem
39+ }
40+ }
3341 }
3442 }
3543 }
3644 }
@@ -74,9 +82,9 @@
7482 a img {
7583
7684 }
7785 i {
78- $avatarSmall
86+ $circleSmall
7987 $colorPrimary
8088 font-size: 1.3rem
8189 display: flex
8290 justify-content: center
app/html/header.jsView
@@ -25,11 +25,10 @@
2525
2626 const loc = computed(location, location => {
2727 if (typeof location != 'object') return {}
2828
29- return location.location || {}
29+ return location || {}
3030 })
31- // Dominics nav location api is slightly different than mine - it nest location in nav.location.location
3231
3332 const isFeed = computed(loc, loc => {
3433 return FEED_PAGES.includes(loc.page) || (loc.key && loc.feed)
3534 })
app/html/thread.jsView
@@ -1,8 +1,9 @@
11 const nest = require('depnest')
22 const { h, Array: MutantArray, map, computed, when } = require('mutant')
33 const get = require('lodash/get')
44
5+// TODO - rename threadPrivate
56 exports.gives = nest('app.html.thread')
67
78 exports.needs = nest({
89 'about.html.avatar': 'first',
@@ -26,18 +27,18 @@
2627 const author = computed([chunk], chunk => get(chunk, '[0].value.author'))
2728
2829 return author() === myId
2930 ? h('div.my-chunk', [
30- h('div.avatar'),
31+ h('Avatar -small'),
3132 h('div.msgs', map(chunk, msg => {
3233 return h('div.msg-row', [
3334 h('div.spacer'),
3435 message(msg)
3536 ])
3637 }))
3738 ])
3839 : h('div.other-chunk', [
39- h('div.avatar', when(author, api.about.html.avatar(author()))),
40+ when(author, api.about.html.avatar(author()), 'small'),
4041 h('div.msgs', map(chunk, msg => {
4142 return h('div.msg-row', [
4243 message(msg),
4344 h('div.spacer')
@@ -48,9 +49,9 @@
4849 )
4950
5051 function message (msg) {
5152 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' : ''
5354 api.unread.sync.markRead(msg)
5455 return h('div.msg'+unread, api.message.html.markdown(raw))
5556 }
5657
@@ -96,11 +97,4 @@
9697 // TODO (mix) use lodash/get
9798 return msgA.value.author === msgB.value.author
9899 }
99100
100-
101-
102-
103-
104-
105-
106-
app/html/thread.mcssView
@@ -9,9 +9,9 @@
99 $chunk
1010
1111 justify-content: space-between
1212
13- div.avatar {
13+ img.Avatar {
1414 visibility: hidden
1515 }
1616
1717 div.msgs {
@@ -32,8 +32,12 @@
3232 div.msg-row {
3333 div.msg {
3434 border: 1px #fff solid
3535 $roundRight
36+ -unread {
37+ font-weight: bold
38+ }
39+
3640 }
3741 }
3842 }
3943 }
@@ -42,16 +46,9 @@
4246 $chunk {
4347 display: flex
4448 margin-bottom: .5rem
4549
46- div.avatar {
47- background-color: #333
48- $avatarSmall
49-
50- img {
51- $avatarSmall
52- }
53-
50+ img.Avatar {
5451 margin-right: 1rem
5552 }
5653
5754 div.msgs {
@@ -69,9 +66,9 @@
6966 }
7067
7168 div.msg {
7269 line-height: 1.2
73- background-color: #fff
70+ $backgroundPrimaryText
7471 padding: 0 .7rem
7572 border-radius: 4px
7673 }
7774 div.spacer {
@@ -80,4 +77,6 @@
8077 }
8178 }
8279 }
8380
81+
82+
app/html/blog-card.jsView
@@ -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.mcssView
@@ -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.mcssView
@@ -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.jsView
@@ -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.mcssView
@@ -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.jsView
@@ -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.mcssView
@@ -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.jsView
@@ -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.mcssView
@@ -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.jsView
@@ -3,32 +3,41 @@
33 catchLinkClick: require('./async/catch-link-click'),
44 },
55 html: {
66 app: require('./html/app'),
7+ comments: require('./html/comments'),
78 context: require('./html/context'),
89 header: require('./html/header'),
910 thread: require('./html/thread'),
1011 link: require('./html/link'),
11- blogCard: require('./html/blog-card'),
12+ blogCard: require('./html/blogCard'),
1213 blogHeader: require('./html/blogHeader'),
14+ scroller: require('./html/scroller'),
1315 },
1416 page: {
1517 blogIndex: require('./page/blogIndex'),
1618 blogNew: require('./page/blogNew'),
19+ blogShow: require('./page/blogShow'),
1720 error: require('./page/error'),
1821 settings: require('./page/settings'),
1922 // channel: require('./page/channel'),
2023 // image: require('./page/image'),
2124 // groupFind: require('./page/groupFind'),
2225 // groupIndex: require('./page/groupIndex'),
2326 // groupNew: require('./page/groupNew'),
2427 // groupShow: require('./page/groupShow'),
25- // home: require('./page/home'),
2628 // threadShow: require('./page/threadShow'),
2729 userEdit: require('./page/userEdit'),
2830 // userFind: require('./page/userFind'),
2931 userShow: require('./page/userShow'),
3032 threadNew: require('./page/threadNew'),
3133 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+ },
3241 }
3342 }
3443
app/page/blogIndex.jsView
@@ -1,116 +1,60 @@
11 const nest = require('depnest')
22 const { h } = require('mutant')
3+const pull = require('pull-stream')
34
4-const isString = require('lodash/isString')
5-const More = require('hypermore')
6-const morphdom = require('morphdom')
7-const Debounce = require('obv-debounce')
8-
95 exports.gives = nest('app.page.blogIndex')
106
117 exports.needs = nest({
128 'app.html.context': 'first',
139 'app.html.blogCard': 'first',
10+ 'app.html.scroller': 'first',
11+ 'feed.pull.public': 'first',
1412 'history.sync.push': 'first',
1513 'keys.sync.id': 'first',
1614 'translations.sync.strings': 'first',
17- 'state.obs.threads': 'first',
1815 'unread.sync.isUnread': 'first'
1916 })
2017
2118 exports.create = (api) => {
22- var contentHtmlObs
23-
2419 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+
2722 var strings = api.translations.sync.strings()
2823
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+
2943 return h('Page -blogIndex', {title: strings.home}, [
3044 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
3646 ])
3747 })
3848
39- function blogs () {
40- // TODO - replace with actual blogs
41- var morePlease = false
42- var threadsObs = api.state.obs.threads()
4349
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- }
5750
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' }))
6256
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 })
11458 }
11559 }
11660
app/page/blogIndex.mcssView
@@ -1,17 +1,34 @@
11 Page -blogIndex {
2+ $backgroundPrimary
23
34 div.content {
4- $backgroundPrimary
5+ padding: 0
6+ }
57
6- div.Button {
7- margin: 1rem 0
8- }
8+ div.Scroller.content {
9+ background-color: #fff
910
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+ }
1318 }
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+ }
1431 }
1532 }
1633 }
1734
app/page/error.jsView
@@ -13,13 +13,10 @@
1313 return nest('app.page.error', error)
1414
1515 function error (location) {
1616 return h('Page -error', {title: strings.error}, [
17- strings.errorNotFound,
17+ h('div.message', strings.errorNotFound),
1818 h('pre', [JSON.stringify(location, null, 2)])
1919 ])
2020 }
2121 }
2222
23-
24-
25-
app/page/groupFind.mcssView
@@ -1,9 +1,9 @@
11 Page -groupFind {
22 div.content {
33 $maxWidthSmaller
44 div.search {
5- background-color: #fff
5+ $backgroundPrimaryText
66
77 margin-bottom: 1rem
88
99 display: flex
@@ -29,17 +29,17 @@
2929 $maxWidthSmaller
3030
3131 div.Link {
3232 div.result {
33- background-color: #fff
33+ $backgroundPrimaryText
3434
3535 padding: .5rem
3636
3737 display: flex
3838 align-items: center
3939
4040 img {
41- $avatarSmall
41+ $circleSmall
4242 margin-right: 1rem
4343 }
4444
4545 div.alias {
app/page/settings.jsView
@@ -6,9 +6,9 @@
66 exports.needs = nest({
77 'about.html.image': 'first',
88 'about.obs.name': 'first',
99 'history.sync.push': 'first',
10- 'history.obs.history': 'first',
10+ 'history.obs.store': 'first',
1111 'keys.sync.id': 'first',
1212 'settings.sync.get': 'first',
1313 'settings.sync.set': 'first',
1414 'settings.obs.get': 'first',
@@ -31,9 +31,9 @@
3131 // RESET the app when the settings are changed
3232 api.settings.obs.get('language')(() => {
3333 console.log('language changed, resetting view')
3434
35- api.history.obs.history().set([]) // wipe nav cache
35+ api.history.obs.store().set([]) // wipe nav cache
3636 api.history.sync.push({page: 'home'}) // queue up basic pages
3737 api.history.sync.push({page: 'settings'})
3838 })
3939
app/page/threadNew.mcssView
@@ -33,9 +33,9 @@
3333 padding: .3rem
3434 min-width: 5rem
3535 $borderSubtle
3636 border-radius: 6rem
37- background-color: #fff
37+ $backgroundPrimaryText
3838
3939 margin-right: 1rem
4040
4141 display: flex
app/page/threadShow.jsView
@@ -7,9 +7,10 @@
77
88 exports.needs = nest({
99 'app.html.context': 'first',
1010 'app.html.thread': 'first',
11- 'message.html.compose': 'first'
11+ 'message.html.compose': 'first',
12+ 'unread.sync.markRead': 'first'
1213 })
1314
1415 exports.create = (api) => {
1516 return nest('app.page.threadShow', threadShow)
@@ -19,13 +20,15 @@
1920 const { key, value } = location
2021 const root = get(value, 'content.root', key)
2122 const channel = get(value, 'content.channel')
2223
24+ //unread state is set in here...
2325 const thread = api.app.html.thread(root)
2426
2527 const meta = {
2628 type: 'post',
2729 root,
30+ //XXX incorrect branch
2831 branch: get(last(location.replies), 'key'),
2932 // >> lastId? CHECK THIS LOGIC
3033 channel,
3134 recps: get(location, 'value.content.recps')
@@ -43,4 +46,8 @@
4346 }
4447 }
4548
4649
50+
51+
52+
53+
app/page/userFind.mcssView
@@ -1,9 +1,9 @@
11 Page -userFind {
22 div.content {
33 $maxWidthSmaller
44 div.search {
5- background-color: #fff
5+ $backgroundPrimaryText
66
77 margin-bottom: 1rem
88
99 display: flex
@@ -29,17 +29,17 @@
2929 $maxWidthSmaller
3030
3131 div.Link {
3232 div.result {
33- background-color: #fff
33+ $backgroundPrimaryText
3434
3535 padding: .5rem
3636
3737 display: flex
3838 align-items: center
3939
4040 img {
41- $avatarSmall
41+ $circleSmall
4242 margin-right: 1rem
4343 }
4444
4545 div.alias {
app/page/userShow.jsView
@@ -5,21 +5,22 @@
55
66 exports.gives = nest('app.page.userShow')
77
88 exports.needs = nest({
9- 'about.html.image': 'first',
9+ 'about.html.avatar': 'first',
1010 'about.obs.name': 'first',
1111 'app.html.link': 'first',
1212 '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',
1615 'sbot.pull.userFeed': 'first',
1716 'keys.sync.id': 'first',
1817 'translations.sync.strings': 'first',
18+ 'unread.sync.isUnread': 'first'
1919 })
2020
2121 exports.create = (api) => {
22+ var isUnread = api.unread.sync.isUnread
2223 return nest('app.page.userShow', userShow)
2324
2425 function userShow (location) {
2526
@@ -28,11 +29,11 @@
2829 const name = api.about.obs.name(feed)
2930
3031 const strings = api.translations.sync.strings()
3132
32- const { followers } = api.contact.obs
33+ // const { followers } = api.contact.obs
3334
34- const youFollowThem = computed(followers(feed), followers => followers.includes(myId))
35+ // const youFollowThem = computed(followers(feed), followers => followers.includes(myId))
3536 // const theyFollowYou = computed(followers(myId), followers => followers.includes(feed))
3637 // const youAreFriends = computed([youFollowThem, theyFollowYou], (a, b) => a && b)
3738
3839 // const ourRelationship = computed(
@@ -42,46 +43,46 @@
4243 // if (theyFollowYou) return strings.userShow.state.theyFollow
4344 // if (youFollowThem) return strings.userShow.state.youFollow
4445 // }
4546 // )
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- )
5447
5548 const Link = api.app.html.link
5649 const userEditButton = Link({ page: 'userEdit', feed }, h('i.fa.fa-pencil'))
5750 const directMessageButton = Link({ page: 'threadNew', feed }, h('Button', strings.userShow.action.directMessage))
5851
5952 const BLOG_TYPES = ['blog', 'post']
6053 const blogs = MutantArray()
6154 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}),
6455 api.sbot.pull.userFeed({id: feed, reverse: true, live: false}),
6556 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+ }),
6768 pull.drain(blogs.push)
68- // Scroller(content, scrollerContent, render, false, false)
69+ // TODO - new Scroller ?
6970 )
7071
7172 return h('Page -userShow', {title: name}, [
7273 h('div.content', [
7374 h('section.about', [
74- api.about.html.image(feed),
75+ api.about.html.avatar(feed, 'large'),
7576 h('h1', [
7677 name,
7778 feed === myId // Only expose own profile editing right now
7879 ? userEditButton
7980 : ''
8081 ]),
8182 feed !== myId
8283 ? h('div.actions', [
83- h('div.friendship', followButton),
84+ api.contact.html.follow(feed),
8485 h('div.directMessage', directMessageButton)
8586 ])
8687 : '',
8788 ]),
@@ -90,5 +91,4 @@
9091 ])
9192 }
9293 }
9394
94-
app/page/userShow.mcssView
@@ -8,22 +8,24 @@
88 flex-direction: column
99 align-items: center
1010
1111 img.Avatar {
12- width: 5rem
13- height: 5rem
14- border-radius: 3rem
1512 }
1613
1714 h1 {
1815 font-weight: 300
1916 font-size: 1rem
17+
18+ display: flex
19+ div.Link {
20+ margin-left: .5rem
21+ }
2022 }
2123
2224 div.actions {
2325 display: flex
2426
25- div.friendship {
27+ div.Follow {
2628 margin-right: 1rem
2729 }
2830
2931 div.directMessage {
app/page/blogShow.jsView
@@ -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.mcssView
@@ -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/error.mcssView
@@ -1,0 +1,6 @@
1+Page -error {
2+ padding: 1rem
3+
4+ flex-direction: column
5+}
6+
app/page/home.jsView
@@ -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.mcssView
@@ -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.jsView
@@ -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.jsView
@@ -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.jsView
@@ -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.jsView
@@ -2,9 +2,10 @@
22 const nest = require('depnest')
33 const ssbKeys = require('ssb-keys')
44 const Path = require('path')
55
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'
78 const opts = appName == 'ssb'
89 ? null
910 : require('./default-config.json')
1011
main.jsView
@@ -18,8 +18,9 @@
1818 {
1919 about: require('./about'),
2020 app: require('./app'),
2121 blob: require('./blob'),
22+ contact: require('./contact'),
2223 //config: require('./ssb-config'),
2324 config: require('./config'),
2425 // group: require('./group'),
2526 message: require('./message'),
@@ -30,8 +31,9 @@
3031 },
3132 {
3233 suggestions: require('patch-suggest'),
3334 profile: require('patch-profile'),
35+ history: require('patch-history'),
3436 core: require('patchcore')
3537 }
3638 )
3739
message/html/compose.jsView
@@ -21,9 +21,17 @@
2121
2222 exports.create = function (api) {
2323 return nest('message.html.compose', compose)
2424
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+
2634 const strings = api.translations.sync.strings()
2735 const getProfileSuggestions = api.about.async.suggest()
2836 const getChannelSuggestions = api.channel.async.suggest()
2937 const getEmojiSuggestions = api.emoji.async.suggest()
@@ -80,12 +88,12 @@
8088 }
8189 // if fileInput is null, send button moves to the left side
8290 // and we don't want that.
8391 else
84- fileInput = h('input', { style: {visibility: 'hidden'}})
92+ fileInput = h('input', { style: {visibility: 'hidden'} })
8593
8694 var showPreview = Value(false)
87- var previewBtn = h('Button',
95+ var previewBtn = h('Button -preview',
8896 {
8997 className: when(showPreview, '-primary'),
9098 'ev-click': () => showPreview.set(!showPreview())
9199 },
@@ -95,10 +103,10 @@
95103
96104 var publishBtn = h('Button -primary', { 'ev-click': publish }, strings.sendMessage)
97105
98106 var actions = h('section.actions', [
99- fileInput,
100- previewBtn,
107+ canAttach ? fileInput : '',
108+ canPreview ? previewBtn : '',
101109 publishBtn
102110 ])
103111
104112 var composer = h('Compose', {
message/html/compose.mcssView
@@ -6,36 +6,23 @@
66
77 textarea {
88 $fontBasic
99
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
1514
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-
2315 :focus {
2416 outline: none
25- box-shadow: none
2617 }
27- :disabled {
28- background-color: #f1f1f1
29- cursor: not-allowed
30- }
3118 }
3219
3320 section.actions {
3421 display: flex
3522 flex-direction: row
3623 align-items: baseline
37- justify-content: space-between
24+ justify-content: flex-end
3825
3926 margin-top: .4rem
4027
4128 input { flex-grow: 1 }
message/html/channel.jsView
@@ -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.jsView
@@ -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/likes.mcssView
@@ -1,0 +1,4 @@
1+Likes {
2+ cursor: pointer
3+}
4+
message/html/timeago.jsView
@@ -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/html/timeago.mcssView
@@ -1,0 +1,5 @@
1+Timeago {
2+ font-size: .8rem
3+ $colorSubtle
4+}
5+
message/index.jsView
@@ -2,9 +2,12 @@
22 async: {
33 publish: require('./async/publish'),
44 },
55 html: {
6+ channel: require('./html/channel'),
67 compose: require('./html/compose'),
7- subject: require('./html/subject')
8+ likes: require('./html/likes'),
9+ subject: require('./html/subject'),
10+ timeago: require('./html/timeago')
811 }
912 }
1013
package-lock.jsonView
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.jsonView
@@ -7,9 +7,10 @@
77 "rebuild": "cross-script npm rebuild --runtime=electron \"--target=$(electron -v)\" \"--abi=$(electron --abi)\" --disturl=https://atom.io/download/atom-shell",
88 "start": "electron .",
99 "dev": "ssb_appname=ssb electro main.js",
1010 "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"
1213 },
1314 "repository": {
1415 "type": "git",
1516 "url": "ssb://%tkJPTTaxOzfLbsewZmgC9CslSER0ntjQOcyhIk6y/cQ=.sha256"
@@ -33,10 +34,11 @@
3334 "markdown-summary": "^1.0.3",
3435 "micro-css": "^2.0.1",
3536 "morphdom": "^2.3.3",
3637 "mutant": "^3.21.2",
37- "obv-debounce": "^1.0.2",
38+ "mutant-scroll": "0.0.3",
3839 "open-external": "^0.1.1",
40+ "patch-history": "^1.0.0",
3941 "patch-profile": "^1.0.2",
4042 "patch-settings": "^1.0.0",
4143 "patch-suggest": "^1.0.1",
4244 "patchcore": "^1.12.0",
@@ -45,16 +47,18 @@
4547 "pull-obv": "^1.3.0",
4648 "pull-stream": "^3.6.0",
4749 "read-directory": "^2.1.0",
4850 "require-style": "^1.0.1",
49- "scuttlebot": "^10.4.4",
51+ "scuttlebot": "^10.4.10",
5052 "setimmediate": "^1.0.5",
5153 "ssb-about": "^0.1.0",
5254 "ssb-backlinks": "^0.4.0",
5355 "ssb-blobs": "^1.1.3",
56+ "ssb-config": "^2.2.0",
5457 "ssb-contacts": "0.0.2",
55- "ssb-friends": "^2.2.1",
58+ "ssb-friends": "^2.3.5",
5659 "ssb-keys": "^7.0.10",
60+ "ssb-markdown": "^3.3.0",
5761 "ssb-mentions": "^0.4.0",
5862 "ssb-private": "^0.1.2",
5963 "ssb-query": "^0.1.2",
6064 "ssb-reduce-stream": "^1.0.2",
@@ -63,8 +67,9 @@
6367 "suggest-box": "^2.2.3",
6468 "url": "^0.11.0"
6569 },
6670 "devDependencies": {
71+ "dependency-check": "^2.9.1",
6772 "electron": "~1.6.11",
6873 "flat": "^4.0.0"
6974 }
7075 }
router/sync/routes.jsView
@@ -8,8 +8,9 @@
88 exports.needs = nest({
99 'app.page.error': 'first',
1010 'app.page.blogIndex': 'first',
1111 'app.page.blogNew': 'first',
12+ 'app.page.blogShow': 'first',
1213 'app.page.settings': 'first',
1314 // 'app.page.channel': 'first',
1415 // 'app.page.groupFind': 'first',
1516 // 'app.page.groupIndex': 'first',
@@ -30,9 +31,22 @@
3031 // route format: [ routeValidator, routeFunction ]
3132
3233 const routes = [
3334
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
3549 // [ location => location.page === 'threadNew' && location.channel, pages.threadNew ],
3650 [ location => location.page === 'threadNew' && isFeed(location.feed), pages.threadNew ],
3751 [ location => isMsg(location.key), pages.threadShow ],
3852
@@ -47,14 +61,8 @@
4761 // [ location => location.page === 'groupNew', pages.groupNew ],
4862 // // [ location => location.type === 'groupShow' && isMsg(location.key), pages.groupShow ],
4963 // [ location => location.channel , pages.channel ],
5064
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-
5765 [ location => location.page === 'settings', pages.settings ],
5866
5967 // [ location => isBlob(location.blob), pages.image ],
6068 [ location => isBlob(location.blob), (location) => {
state/obs.jsView
@@ -13,9 +13,10 @@
1313 exports.needs = nest({
1414 'message.sync.unbox': 'first',
1515 'sbot.pull.log': 'first',
1616 'sbot.async.get': 'first',
17- 'feed.pull.channel': 'first'
17+ 'feed.pull.channel': 'first',
18+ 'feed.pull.rollup': 'first'
1819 })
1920
2021 exports.create = function (api) {
2122 var threadsObs
@@ -38,29 +39,16 @@
3839 }),
3940 pull.through(function (data) {
4041 lastTimestamp = data.timestamp
4142 }),
42- pull.map(unbox), pull.filter(Boolean)
43+ pull.map(unbox),
44+ pull.filter(Boolean),
45+ api.feed.pull.rollup()
4346 ),
4447 //value recovered from localStorage
4548 initial
4649 )
4750
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-
6351 //stream live messages. this *should* work.
6452 //there is no back pressure on new events
6553 //only a show more on the top (currently)
6654 pull(
@@ -103,9 +91,9 @@
10391
10492 },
10593
10694 'state.obs.threads': function buildThreadObs() {
107- if(threadsObs) return threadsObs
95+ if (threadsObs) return threadsObs
10896
10997 // DISABLE localStorage cache. mainly disabling this to make debugging the other stuff
11098 // easier. maybe re-enable this later? also, should this be for every channel too? not sure.
11199
styles/button.mcssView
@@ -1,10 +1,10 @@
11 Button {
22 font-family: arial
3- background-color: #fff
3+ $backgroundPrimaryText
44
55 min-width: 6rem
6- height: 1.2rem
6+ height: 1.2em
77 padding: .2rem 1rem
88
99 border: 1px #b9b9b9 solid
1010 border-radius: 10rem
@@ -23,15 +23,23 @@
2323 }
2424
2525 -primary {
2626 $colorPrimary
27+ $font
2728 $borderPrimary
2829
2930 :hover {
3031 opacity: .9
3132 }
3233 }
3334
35+ -channel {
36+ $backgroundPrimary
37+ $colorFontPrimary
38+ font-size: .9rem
39+ min-width: initial
40+ }
41+
3442 -showMore {
3543 width: 100%
3644
3745 padding: .2rem 0
styles/global.mcssView
@@ -1,21 +1,11 @@
11 body {
22 $fontBasic
3- background-color: #fff
3+ $backgroundPrimaryText
44
55 margin: 0
66
7- // different to Page
8- div.page {
97
10- overflow: hidden
11- position: absolute
12- top: 0
13- bottom: 0
14- right: 0
15- left: 0
16- }
17-
188 (a) {
199 color: #2f63ad
2010 text-decoration: none
2111
styles/markdown.mcssView
@@ -5,8 +5,18 @@
55 margin: .5rem 0
66 border-radius: .5rem
77 }
88
9+ // center blog images
10+ p {
11+ a {
12+ img {
13+ display: block
14+ margin: auto
15+ }
16+ }
17+ }
18+
919 (img.emoji) {
1020 margin: 0
1121 }
1222 }
styles/mixins.jsView
@@ -44,10 +44,10 @@
4444 $colorFontBasic {
4545 color: #222
4646 }
4747
48-$colorPrimaryFG {
49- color: #fff
48+$colorFontPrimary {
49+ color: #5c6bc0
5050 }
5151
5252 $colorSubtle {
5353 color: #999
@@ -56,28 +56,52 @@
5656 $backgroundPrimary {
5757 background-color: #f5f6f7
5858 }
5959
60+$backgroundPrimaryText {
61+ background-color: #fff
62+}
63+
6064 $backgroundSelected {
6165 background-color: #f0f1f2
6266 }
6367
6468 $borderPrimary {
6569 border: 1px #2f63ad solid
6670 }
6771
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
7278 }
7379
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
7886 }
7987
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+
80104 $markdownSmall {
81105 div.Markdown {
82106 h1, h2, h3, h4, h5, h6, p {
83107 font-size: .9rem
unread.jsView
@@ -1,9 +1,11 @@
11 var nest = require('depnest')
2+var { Dict, Set } = require('mutant')
23
34 exports.gives = nest({
45 'unread.sync.isUnread': true,
56 'unread.sync.markRead': true,
7+ 'unread.obs.userMessages': true
68 })
79
810 //load current state of unread messages.
911
@@ -33,31 +35,33 @@
3335 }, 2e3)
3436 }
3537
3638 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]
4441 }
4542
4643 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
4948 unread.filter[msg.key] = true
5049 save()
5150 return true
52- //}
51+ }
5352 }
5453 }
5554
55+ function userMessages(feedId) {
56+
57+ }
58+
5659 document.body.onunload = save
5760
5861 return nest({
5962 'unread.sync.isUnread': isUnread,
60- 'unread.sync.markRead': markRead
63+ 'unread.sync.markRead': markRead,
64+ 'unread.obs.userMessages': userMessages
6165 })
6266 }
6367
contact/html/follow.jsView
@@ -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+
contact/index.jsView
@@ -1,0 +1,7 @@
1+module.exports = {
2+ html: {
3+ follow: require('./html/follow'),
4+ }
5+}
6+
7+

Built with git-ssb-web