git ssb

2+

mixmix / ticktack



Commit e421e6f554c1320df904394b27f186dbdac65de5

Merge pull request #138 from ticktackim/notification-hanger

Notifications / Stats
Andre Alves Garzia authored on 5/8/2018, 7:00:52 PM
GitHub committed on 5/8/2018, 7:00:52 PM
Parent: 7132de8591e3a3e7dde989835cf9c5fb52382791
Parent: 996a5056487e4eca14e036e0f7175b60e587e77c

Files changed

app/html/comments.jschanged
app/html/comments.mcsschanged
app/html/header.jschanged
app/html/header.mcsschanged
app/html/scroller.jschanged
app/html/scroller.mcsschanged
app/html/sideNav/sideNavNotifications.jsadded
app/html/sideNav/sideNavNotifications.mcssadded
app/index.jschanged
app/page/addressBook.jschanged
app/page/statsShow.jschanged
app/page/notifications.jsadded
app/page/notifications.mcssadded
background-process.jschanged
message/html/comment.jsadded
message/html/comment.mcssadded
message/html/notification.jsadded
message/html/notification.mcssadded
message/index.jschanged
package.jsonchanged
router/sync/routes.jschanged
translations/en.jschanged
ssb-server-blog-stats.jsdeleted
ssb-server-ticktack.jsadded
app/html/comments.jsView
@@ -1,21 +1,13 @@
11 const nest = require('depnest')
2-const { h, Value, map, computed, when, resolve, throttle } = require('mutant')
2+const { h, map, Struct, computed, throttle } = require('mutant')
33 const get = require('lodash/get')
44
55 exports.gives = nest('app.html.comments')
66
77 exports.needs = nest({
8- 'about.html.avatar': 'first',
9- 'about.obs.name': 'first',
10- 'backlinks.obs.for': 'first',
11- 'feed.obs.thread': 'first',
8+ 'message.html.comment': 'first',
129 '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',
1810 'translations.sync.strings': 'first'
1911 })
2012
2113 exports.create = (api) => {
@@ -27,13 +19,15 @@
2719
2820 // TODO - move this up into Patchcore
2921 const messagesTree = computed(throttle(messages, 200), msgs => {
3022 return msgs
31- .filter(msg => forkOf(msg) === undefined)
32- .map(threadMsg => {
33- const nestedReplies = msgs.filter(msg => forkOf(msg) === threadMsg.key)
34- threadMsg.replies = nestedReplies
35- return threadMsg
23+ .filter(msg => forkOf(msg) === undefined) // exclude nested replies / forks
24+ .filter(msg => msg.value.content.root) // exclude root message / blog
25+ .map(comment => {
26+ return Struct({
27+ comment,
28+ replies: msgs.filter(msg => forkOf(msg) === comment.key)
29+ })
3630 })
3731 })
3832
3933 const root = computed(messages, ary => ary[0].key)
@@ -54,95 +48,22 @@
5448 })
5549
5650 return h('Comments', [
5751 // when(twoComposers, compose({ meta, shrink: true, canAttach: false })),
58- map(messagesTree, msg => Comment(msg, root, branch)),
52+ map(
53+ messagesTree,
54+ msg => api.message.html.comment({ comment: msg.comment, replies: msg.replies, branch }),
55+ {
56+ comparer: (a, b) => {
57+ if (a === undefined || b === undefined) return false
58+
59+ return a.comment().key === b.comment().key && a.replies().length === b.replies().length
60+ }
61+ }
62+ ),
5963 compose({ meta, feedIdsInThread, shrink: false, canAttach: true, placeholder: strings.writeComment })
6064 ])
6165 }
62-
63- function Comment (msgObs, root, branch) {
64- const strings = api.translations.sync.strings()
65- const msg = resolve(msgObs)
66-
67- const raw = get(msg, 'value.content.text')
68- var className = api.unread.sync.isUnread(msg) ? ' -unread' : ' -read'
69- api.unread.sync.markRead(msg)
70-
71- if (!get(msg, 'value.content.root')) return
72-
73- const { author, content } = msg.value
74-
75- // // TODO - move this upstream into patchcore:feed.obs.thread ??
76- // // OR change strategy to use forks
77- // const backlinks = api.backlinks.obs.for(msg.key)
78- // const nestedReplies = computed(backlinks, backlinks => {
79- // return backlinks.filter(backlinker => {
80- // const { type, root } = backlinker.value.content
81- // return type === 'post' && root === msg.key
82- // })
83- // })
84-
85- var nestedReplyCompose = Value(false)
86- const toggleCompose = () => nestedReplyCompose.set(!nestedReplyCompose())
87- const nestedReplyComposer = api.message.html.compose({
88- meta: {
89- type: 'post',
90- root,
91- fork: msg.key,
92- branch,
93- channel: content.channel
94- },
95- shrink: false,
96- canAttach: true,
97- canPreview: false,
98- placeholder: strings.writeComment
99- }, toggleCompose)
100-
101- return h('Comment', { className }, [
102- h('div.left', api.about.html.avatar(author, 'tiny')),
103- h('div.right', [
104- h('section.context', [
105- h('div.name', api.about.obs.name(author)),
106- api.message.html.timeago(msg)
107- ]),
108- h('section.content', api.message.html.markdown(raw)),
109- when(msgObs.replies,
110- h('section.replies',
111- map(msgObs.replies, NestedComment)
112- )
113- ),
114- h('section.actions', [
115- h('div.reply', { 'ev-click': toggleCompose }, [
116- h('i.fa.fa-commenting-o')
117- ]),
118- api.message.html.likes(msg)
119- ]),
120- when(nestedReplyCompose, nestedReplyComposer)
121- ])
122- ])
123- }
124-
125- function NestedComment (msgObs) {
126- const msg = resolve(msgObs)
127- const raw = get(msg, 'value.content.text')
128- if (!raw) return
129-
130- const { author } = msg.value
131-
132- return h('Comment -nested', [
133- h('div.left'),
134- h('div.right', [
135- h('section.context', [
136- h('div.name', api.about.obs.name(author)),
137- api.message.html.timeago(msg)
138- ]),
139- h('section.content', api.message.html.markdown(raw))
140- ])
141- ])
142-
143- api.message.html.markdown(raw)
144- }
14566 }
14667
14768 function forkOf (msg) {
14869 return get(msg, 'value.content.fork')
app/html/comments.mcssView
@@ -7,79 +7,4 @@
77 margin-bottom: 1.5rem
88 }
99 }
1010
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- $borderBottomLight
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: .95rem
40- line-height: 1.4
41- div.Markdown {}
42- }
43-
44- section.actions {
45- font-size: 1rem
46- color: #555
47- margin-right: .5rem
48-
49- display: flex
50- justify-content: flex-end
51- align-items: baseline
52-
53- div.reply {
54- cursor: pointer
55-
56- margin-right: 1.5rem
57- i.fa {}
58- }
59-
60- div.Likes { }
61- }
62-
63- div.Compose {
64- margin: 1rem 0
65- }
66- }
67-}
68-
69-Comment -nested {
70- padding: 1rem 1rem 0 1rem
71- $backgroundPrimaryText
72- $roundTop
73- $roundBottom
74-
75- margin-bottom: 1rem
76-
77- div.left { margin: 0 }
78- div.right {
79- padding: 0
80- border: 0
81- margin: 0
82-
83- }
84-}
85-
app/html/header.jsView
@@ -22,9 +22,10 @@
2222 if (loc().page === 'splash') return
2323
2424 const isSettings = computed(loc, loc => SETTINGS_PAGES.includes(loc.page))
2525 const isAddressBook = computed(loc, loc => loc.page === 'addressBook')
26- const isFeed = computed([isAddressBook, isSettings], (p, s) => !p && !s)
26+ const isNotifications = computed(loc, loc => loc.page === 'notifications' || loc.page === 'statsShow')
27+ const isFeed = computed([isAddressBook, isSettings, isNotifications], (p, s, n) => !p && !s && !n)
2728
2829 return h('Header', [
2930 windowControls(),
3031 h('nav', [
@@ -39,9 +40,10 @@
3940 h('img.settings', {
4041 src: when(isSettings, assetPath('settings_on.png'), assetPath('settings.png')),
4142 'ev-click': () => push({page: 'settings'})
4243 }),
43- h('i.fa.fa-bell', {
44+ h('i.fa', {
45+ className: when(isNotifications, 'fa-bell', 'fa-bell-o'),
4446 'ev-click': () => push({page: 'statsShow'})
4547 })
4648 ])
4749 ])
@@ -77,5 +79,4 @@
7779
7880 function assetPath (name) {
7981 return path.join(__dirname, '../../assets', name)
8082 }
81-
app/html/header.mcssView
@@ -35,8 +35,11 @@
3535
3636 margin: 0 2rem
3737 }
3838
39+ i { font-size: 1.4rem }
40+ i.fa-bell-o { color: #7da9ea }
41+
3942 (a) {
4043 color: #222
4144 margin-right: 1rem
4245
app/html/scroller.jsView
@@ -13,20 +13,20 @@
1313 return nest('app.html.scroller', createScroller)
1414
1515 function createScroller (opts = {}) {
1616 const {
17- stream,
17+ stream, // TODO - rename this to createStream (rename across app)
1818 filter = () => pull.filter((msg) => true),
1919 indexProperty = ['value', 'timestamp']
2020 } = opts
2121
2222 const streamToTop = pull(
23- next(stream, {old: false, limit: 100, property: indexProperty }),
23+ next(stream, { live: true, reverse: false, old: false, limit: 100, property: indexProperty }),
2424 filter() // is a pull-stream through
2525 )
2626
2727 const streamToBottom = pull(
28- next(stream, {reverse: true, limit: 100, live: false, property: indexProperty }),
28+ next(stream, { live: false, reverse: true, limit: 100, property: indexProperty }),
2929 filter()
3030 )
3131
3232 return Scroller(Object.assign({}, opts, { streamToTop, streamToBottom }))
app/html/scroller.mcssView
@@ -1,8 +1,8 @@
11 Scroller {
22 overflow: auto
33 width: 100%
4- height: 100%
4+ /* height: 100% */
55 min-height: 0px
66
77 section.top {
88 /* position: sticky */
app/html/sideNav/sideNavNotifications.jsView
@@ -1,0 +1,69 @@
1+const nest = require('depnest')
2+const { h, computed } = require('mutant')
3+
4+exports.gives = nest({
5+ 'app.html.sideNav': true
6+})
7+
8+exports.needs = nest({
9+ 'history.sync.push': 'first',
10+ 'translations.sync.strings': 'first'
11+})
12+
13+const SECTIONS = ['comments', 'likes', 'shares']
14+
15+const ICONS = {
16+ stats: 'bar-chart',
17+ comments: 'commenting-o',
18+ likes: 'heart-o',
19+ shares: 'share-alt'
20+}
21+
22+exports.create = (api) => {
23+ return nest({
24+ 'app.html.sideNav': sideNav
25+ })
26+
27+ function sideNav (location) {
28+ if (location.page !== 'statsShow' && location.page !== 'notifications') return
29+ if (location.page === 'notifications' && !SECTIONS.includes(location.section)) return
30+
31+ const strings = api.translations.sync.strings()
32+ const goTo = (loc) => () => api.history.sync.push(loc)
33+
34+ return h('SideNav -notifications', [
35+ LevelOneSideNav()
36+ ])
37+
38+ function LevelOneSideNav () {
39+ return h('div.level.-one', [
40+ h('section', [
41+ h('Option',
42+ {
43+ className: location.page === 'statsShow' ? '-selected' : '',
44+ 'ev-click': goTo({page: 'statsShow'})
45+ },
46+ [
47+ h('i.fa', { className: `fa-${ICONS['stats']}` }),
48+ strings['stats']
49+ ]
50+ ),
51+ SECTIONS.map(section => SectionOption(section))
52+ ])
53+ ])
54+ }
55+
56+ function SectionOption (section) {
57+ return h('Option',
58+ {
59+ className: location.section === section ? '-selected' : '',
60+ 'ev-click': goTo({page: 'notifications', section})
61+ },
62+ [
63+ h('i.fa', { className: `fa-${ICONS[section]}` }),
64+ strings[section]
65+ ]
66+ )
67+ }
68+ }
69+}
app/html/sideNav/sideNavNotifications.mcssView
@@ -1,0 +1,35 @@
1+SideNav -notifications {
2+ div.level {
3+ section{
4+ margin-top: 1rem
5+
6+ header {
7+ font-size: .8rem
8+ }
9+
10+ div.Option {
11+ padding: 1rem
12+ padding-left: 1.5rem
13+
14+ display: flex
15+
16+ div.Button {
17+ flex-grow: 1
18+ margin: .5rem 0
19+ }
20+
21+ i.fa {
22+ margin-right: .5rem
23+ }
24+
25+ div.count {
26+ flex-grow: 1
27+
28+ display: flex
29+ justify-content: flex-end
30+ }
31+ }
32+ }
33+ }
34+}
35+
app/index.jsView
@@ -18,9 +18,10 @@
1818 },
1919 scroller: require('./html/scroller'),
2020 sideNav: {
2121 addressBook: require('./html/sideNav/sideNavAddressBook'),
22- discovery: require('./html/sideNav/sideNavDiscovery')
22+ discovery: require('./html/sideNav/sideNavDiscovery'),
23+ notifications: require('./html/sideNav/sideNavNotifications')
2324 },
2425 warning: require('./html/warning')
2526 },
2627 obs: {
@@ -32,21 +33,22 @@
3233 blogNew: require('./page/blogNew'),
3334 blogSearch: require('./page/blogSearch'),
3435 blogShow: require('./page/blogShow'),
3536 channelShow: require('./page/channelShow'),
36- error: require('./page/error'),
37- settings: require('./page/settings'),
3837 channelSubscriptions: require('./page/channelSubscriptions'),
3938 // channel: require('./page/channel'),
4039 // image: require('./page/image'),
4140 // groupFind: require('./page/groupFind'),
4241 // groupIndex: require('./page/groupIndex'),
4342 // groupNew: require('./page/groupNew'),
4443 // groupShow: require('./page/groupShow'),
4544 // threadShow: require('./page/threadShow'),
45+ notifications: require('./page/notifications'),
46+ error: require('./page/error'),
4647 userEdit: require('./page/userEdit'),
4748 // userFind: require('./page/userFind'),
4849 userShow: require('./page/userShow'),
50+ settings: require('./page/settings'),
4951 splash: require('./page/splash'),
5052 statsShow: require('./page/statsShow'),
5153 threadNew: require('./page/threadNew'),
5254 threadShow: require('./page/threadShow')
app/page/addressBook.jsView
@@ -1,7 +1,6 @@
11 const nest = require('depnest')
22 const { h, Value, computed, map } = require('mutant')
3-const pull = require('pull-stream')
43
54 exports.gives = nest('app.page.addressBook')
65
76 // declare consts to avoid magic-string errors
@@ -13,10 +12,8 @@
1312 exports.needs = nest({
1413 'about.html.avatar': 'first',
1514 'about.async.suggest': 'first',
1615 'about.obs.name': 'first',
17- 'app.html.topNav': 'first',
18- // 'app.html.scroller': 'first',
1916 'app.html.sideNav': 'first',
2017 'app.html.topNav': 'first',
2118 'contact.html.follow': 'first',
2219 'contact.obs.relationships': 'first',
@@ -26,9 +23,9 @@
2623 })
2724
2825 exports.create = (api) => {
2926 return nest('app.page.addressBook', function (location) {
30- // location here can expected to be: { page: 'addressBook'}
27+ // location here can expected to be: { page: 'addressBook', section: '...'}
3128
3229 const strings = api.translations.sync.strings()
3330 const myKey = api.keys.sync.id()
3431 const relationships = api.contact.obs.relationships(myKey)
app/page/statsShow.jsView
@@ -11,11 +11,12 @@
1111
1212 exports.gives = nest('app.page.statsShow')
1313
1414 exports.needs = nest({
15- 'sbot.obs.connection': 'first',
15+ 'app.html.sideNav': 'first',
1616 'history.sync.push': 'first',
1717 'message.html.markdown': 'first',
18+ 'sbot.obs.connection': 'first',
1819 'translations.sync.strings': 'first'
1920 })
2021
2122 const COMMENTS = 'comments'
@@ -93,8 +94,9 @@
9394
9495 const canvas = h('canvas', { height: 200, width: 600, style: { height: '200px', width: '600px' } })
9596
9697 const page = h('Page -statsShow', [
98+ api.app.html.sideNav(location),
9799 h('Scroller.content', [
98100 h('div.content', [
99101 h('h1', t.title),
100102 h('section.totals', [COMMENTS, LIKES, SHARES].map(focus => {
@@ -184,9 +186,9 @@
184186
185187 function fetchBlogData ({ server, store }) {
186188 const myKey = server.id
187189
188- server.blogStats.getBlogs({}, (err, blogs) => {
190+ server.ticktack.getBlogs({}, (err, blogs) => {
189191 if (err) console.error(err)
190192
191193 // TODO - change this once merge in the new notifications-hanger work
192194 // i.e. do one query for ALL comments on my blogs as opposed to N queries
@@ -203,9 +205,9 @@
203205 function fetchComments ({ server, store, blog }) {
204206 if (!store.comments.has(blog.key)) store.comments.put(blog.key, MutantArray())
205207
206208 pull(
207- server.blogStats.readComments(blog),
209+ server.ticktack.readComments(blog),
208210 pull.drain(msg => {
209211 if (msg.value.author === myKey) return
210212 store.comments.get(blog.key).push(msg)
211213 })
@@ -215,9 +217,9 @@
215217 function fetchLikes ({ server, store, blog }) {
216218 if (!store.likes.has(blog.key)) store.likes.put(blog.key, MutantArray())
217219
218220 pull(
219- server.blogStats.readLikes(blog),
221+ server.ticktack.readLikes(blog),
220222 pull.drain(msg => {
221223 if (msg.value.author === myKey) return
222224
223225 const isUnlike = get(msg, 'value.content.vote.value', 1) < 1
app/page/notifications.jsView
@@ -1,0 +1,69 @@
1+const nest = require('depnest')
2+const { h, onceTrue } = require('mutant')
3+const defer = require('pull-defer')
4+
5+exports.gives = nest('app.page.notifications')
6+
7+exports.needs = nest({
8+ 'app.html.scroller': 'first',
9+ 'app.html.sideNav': 'first',
10+ 'message.html.comment': 'first',
11+ 'message.html.notification': 'first',
12+ 'sbot.obs.connection': 'first',
13+ 'translations.sync.strings': 'first'
14+})
15+
16+const SOURCES = {
17+ comments: 'readAllComments',
18+ likes: 'readAllLikes',
19+ shares: 'readAllShares'
20+}
21+
22+exports.create = (api) => {
23+ return nest('app.page.notifications', function (location) {
24+ // location here can expected to be: { page: 'notifications', section: * }
25+ if (!Object.keys(SOURCES).includes(location.section)) return
26+
27+ var scroller = api.app.html.scroller({
28+ classList: ['content'],
29+ stream: createCreateStream(location.section),
30+ render: createRender(location.section)
31+ })
32+
33+ return h('Page -notifications', [
34+ api.app.html.sideNav(location),
35+ scroller
36+ ])
37+
38+ function createCreateStream (section) {
39+ return function (opts) {
40+ const source = defer.source()
41+ var resolved = false
42+
43+ onceTrue(api.sbot.obs.connection, server => {
44+ if (resolved) return
45+
46+ source.resolve(server.ticktack[SOURCES[section]](opts))
47+ resolved = true
48+ })
49+
50+ return source
51+ }
52+ }
53+
54+ function createRender (section) {
55+ return function (msg) {
56+ switch (section) {
57+ case 'comments':
58+ return api.message.html.comment({ comment: msg, showRootLink: true })
59+
60+ case 'likes':
61+ return api.message.html.notification(msg)
62+
63+ case 'shares':
64+ return api.message.html.notification(msg)
65+ }
66+ }
67+ }
68+ })
69+}
app/page/notifications.mcssView
@@ -1,0 +1,25 @@
1+Page -notifications {
2+ div.Scroller.content {
3+
4+ section.content {
5+ padding-top: 2rem
6+
7+ div.Comment {
8+ flex-basis: 100%
9+
10+ div.right {
11+ section.replies {
12+
13+ div.Comment.-nested {
14+ $backgroundPrimary
15+ }
16+ }
17+ }
18+ }
19+
20+ div.Notification {
21+ flex-basis: 100%
22+ }
23+ }
24+ }
25+}
background-process.jsView
@@ -21,9 +21,9 @@
2121 .use(require('ssb-about'))
2222 // .use(require('ssb-ebt'))
2323 .use(require('ssb-ws'))
2424 .use(require('ssb-server-channel'))
25- .use(require('./ssb-server-blog-stats'))
25+ .use(require('./ssb-server-ticktack'))
2626
2727 Client(config.keys, config, (err, ssbServer) => {
2828 if (err) {
2929 console.log('> starting sbot')
message/html/comment.jsView
@@ -1,0 +1,135 @@
1+const nest = require('depnest')
2+const { h, Value, map, when, resolve, computed, onceTrue } = require('mutant')
3+const get = require('lodash/get')
4+const { heads } = require('ssb-sort')
5+
6+exports.gives = nest('message.html.comment')
7+
8+exports.needs = nest({
9+ 'about.html.avatar': 'first',
10+ 'about.obs.name': 'first',
11+ 'backlinks.obs.for': 'first',
12+ 'blog.html.title': 'first',
13+ 'message.html.compose': 'first',
14+ 'message.html.markdown': 'first',
15+ 'message.html.timeago': 'first',
16+ 'message.html.likes': 'first',
17+ 'unread.sync.markRead': 'first',
18+ 'unread.sync.isUnread': 'first',
19+ 'sbot.obs.connection': 'first',
20+ 'translations.sync.strings': 'first'
21+})
22+
23+exports.create = (api) => {
24+ return nest('message.html.comment', Comment)
25+
26+ function Comment ({ comment, replies, branch, showRootLink = false }) {
27+ const strings = api.translations.sync.strings()
28+ const msg = resolve(comment)
29+ var root = get(msg, 'value.content.root')
30+ if (!root) return
31+
32+ const { author, content } = msg.value
33+
34+ if (!replies) {
35+ replies = computed(api.backlinks.obs.for(msg.key), backlinks => {
36+ return backlinks.filter(backlinker => {
37+ const { type, root: _root, fork } = backlinker.value.content
38+ return type === 'post' && fork === msg.key && _root === root
39+ })
40+ })
41+ }
42+ if (!branch) {
43+ branch = computed(api.backlinks.obs.for(root), backlinks => {
44+ return heads(backlinks)
45+ })
46+ }
47+
48+ var className = api.unread.sync.isUnread(msg) ? ' -unread' : ' -read'
49+ api.unread.sync.markRead(msg)
50+
51+ var title = Value()
52+ if (showRootLink) {
53+ processMessage(root, msg => {
54+ var t = api.blog.html.title(msg)
55+ title.set(t.innerText ? t.innerText : t)
56+ })
57+ }
58+
59+ var nestedReplyCompose = Value(false)
60+ const toggleCompose = () => nestedReplyCompose.set(!nestedReplyCompose())
61+ const nestedReplyComposer = api.message.html.compose({
62+ meta: {
63+ type: 'post',
64+ root,
65+ fork: msg.key,
66+ branch,
67+ channel: content.channel
68+ },
69+ shrink: false,
70+ canAttach: true,
71+ canPreview: false,
72+ placeholder: strings.writeComment
73+ }, toggleCompose)
74+
75+ return h('Comment', { className }, [
76+ h('div.left', api.about.html.avatar(author, 'tiny')),
77+ h('div.right', [
78+ h('section.context', [
79+ h('div.name', api.about.obs.name(author)),
80+ api.message.html.timeago(msg),
81+ when(showRootLink, h('a.rootLink', {href: root}, ['<< ', title, ' >>']))
82+ // TODO don't link to root, link to position of message within blog!
83+ ]),
84+ h('section.content', api.message.html.markdown(get(msg, 'value.content.text'))),
85+ h('section.actions', [
86+ h('div.reply', { 'ev-click': toggleCompose }, [
87+ h('i.fa.fa-commenting-o')
88+ ]),
89+ api.message.html.likes(msg)
90+ ]),
91+ when(replies,
92+ h('section.replies',
93+ map(
94+ replies,
95+ NestedComment,
96+ {
97+ comparer: (a, b) => {
98+ if (a === undefined || b === undefined) return false
99+ return a.key === b.key
100+ }
101+ }
102+ )
103+ )
104+ ),
105+ when(nestedReplyCompose, nestedReplyComposer)
106+ ])
107+ ])
108+ }
109+
110+ function NestedComment (comment) {
111+ const msg = resolve(comment)
112+ const raw = get(msg, 'value.content.text')
113+ if (!raw) return
114+
115+ const { author } = msg.value
116+
117+ return h('Comment -nested', [
118+ h('div.left'),
119+ h('div.right', [
120+ h('section.context', [
121+ h('div.name', api.about.obs.name(author)),
122+ api.message.html.timeago(msg)
123+ ]),
124+ h('section.content', api.message.html.markdown(raw))
125+ ])
126+ ])
127+ }
128+
129+ function processMessage (key, fn) {
130+ onceTrue(api.sbot.obs.connection, server => server.get(key, (err, value) => {
131+ if (err) return console.error(err)
132+ fn({ key, value })
133+ }))
134+ }
135+}
message/html/comment.mcssView
@@ -1,0 +1,85 @@
1+Comment {
2+ display: flex
3+
4+ div.left {
5+ margin-right: 1rem
6+
7+ div.Avatar {}
8+ }
9+
10+ div.right {
11+ flex-grow: 1
12+
13+ $borderBottomLight
14+ padding-bottom: 1rem
15+ margin-bottom: 1rem
16+
17+ section.context {
18+ display: flex
19+ align-items: baseline
20+
21+ div.name {
22+ font-size: 1.2rem
23+ margin-right: 1rem
24+ }
25+ div.Timeago {
26+ margin-right: 1.5rem
27+ }
28+ a.rootLink {
29+ font-size: .8rem
30+ }
31+ }
32+
33+ section.content {
34+ font-size: .95rem
35+ line-height: 1.4
36+ div.Markdown {}
37+ }
38+
39+ section.actions {
40+ font-size: 1rem
41+ color: #555
42+ margin-right: .5rem
43+
44+ display: flex
45+ justify-content: flex-end
46+ align-items: baseline
47+
48+ div.reply {
49+ cursor: pointer
50+
51+ margin-right: 1.5rem
52+ i.fa {}
53+ }
54+
55+ div.Likes { }
56+ }
57+
58+ section.replies {
59+ margin-top: 1rem
60+ }
61+
62+ div.Compose {
63+ margin: 1rem 0
64+ }
65+ }
66+}
67+
68+Comment -nested {
69+ padding: 1rem 1rem 0 1rem
70+ $backgroundPrimaryText
71+ $roundTop
72+ $roundBottom
73+
74+ margin-bottom: 1rem
75+
76+ div.left { margin: 0 }
77+ div.right {
78+ padding: 0
79+ border: 0
80+ margin: 0
81+
82+ }
83+}
84+
85+
message/html/notification.jsView
@@ -1,0 +1,69 @@
1+const nest = require('depnest')
2+const { h, Value, onceTrue } = require('mutant')
3+const get = require('lodash/get')
4+const getType = (msg) => get(msg, 'value.content.type')
5+const getLikeRoot = (msg) => get(msg, 'value.content.vote.link')
6+const getShareRoot = (msg) => get(msg, 'value.content.share.link')
7+const getRoot = (msg) => {
8+ switch (getType(msg)) {
9+ case 'vote':
10+ return getLikeRoot(msg)
11+ case 'share':
12+ return getShareRoot(msg)
13+ }
14+}
15+
16+exports.gives = nest('message.html.notification')
17+
18+exports.needs = nest({
19+ 'about.html.avatar': 'first',
20+ 'about.obs.name': 'first',
21+ 'blog.html.title': 'first',
22+ // 'message.html.markdown': 'first',
23+ 'message.html.timeago': 'first',
24+ 'message.html.likes': 'first',
25+ 'unread.sync.markRead': 'first',
26+ 'unread.sync.isUnread': 'first',
27+ 'sbot.obs.connection': 'first',
28+ 'translations.sync.strings': 'first'
29+})
30+
31+exports.create = (api) => {
32+ return nest('message.html.notification', Notification)
33+
34+ function Notification (msg) {
35+ var root = getRoot(msg)
36+ if (!root) return
37+
38+ const { author } = msg.value
39+
40+ var className = api.unread.sync.isUnread(msg) ? ' -unread' : ' -read'
41+ api.unread.sync.markRead(msg)
42+
43+ var title = Value()
44+ processMessage(root, msg => {
45+ var t = api.blog.html.title(msg)
46+ title.set(t.innerText ? t.innerText : t)
47+ })
48+
49+ return h('Notification', { className }, [
50+ h('div.left', api.about.html.avatar(author, 'tiny')),
51+ h('div.right', [
52+ h('section.context', [
53+ h('div.name', api.about.obs.name(author)),
54+ api.message.html.timeago(msg),
55+ h('a.rootLink', {href: root}, ['<< ', title, ' >>'])
56+ ])
57+ // TODO display the share text ??
58+ // h('section.content', api.message.html.markdown(get(msg, 'value.content.text'))),
59+ ])
60+ ])
61+ }
62+
63+ function processMessage (key, fn) {
64+ onceTrue(api.sbot.obs.connection, server => server.get(key, (err, value) => {
65+ if (err) return console.error(err)
66+ fn({ key, value })
67+ }))
68+ }
69+}
message/html/notification.mcssView
@@ -1,0 +1,42 @@
1+Notification {
2+ display: flex
3+
4+ div.left {
5+ padding-top: 1rem
6+ margin-right: 1rem
7+
8+ div.Avatar {}
9+ }
10+
11+ div.right {
12+ flex-grow: 1
13+
14+ padding-top: 1rem
15+ padding-bottom: 2rem
16+ $borderBottomLight
17+ margin-bottom: 1rem
18+
19+ section.context {
20+ display: flex
21+ align-items: baseline
22+
23+ div.name {
24+ font-size: 1.2rem
25+ margin-right: 1rem
26+ }
27+ div.Timeago {
28+ margin-right: 1.5rem
29+ }
30+ a.rootLink {
31+ font-size: .8rem
32+ }
33+ }
34+
35+ section.content {
36+ font-size: .95rem
37+ line-height: 1.4
38+ div.Markdown {}
39+ }
40+
41+ }
42+}
message/index.jsView
@@ -3,10 +3,12 @@
33 publish: require('./async/publish')
44 },
55 html: {
66 channel: require('./html/channel'),
7+ comment: require('./html/comment'),
78 compose: require('./html/compose'),
89 likes: require('./html/likes'),
10+ notification: require('./html/notification'),
911 shares: require('./html/shares'),
1012 webshares: require('./html/webshares'),
1113 subject: require('./html/subject'),
1214 timeago: require('./html/timeago')
package.jsonView
@@ -40,17 +40,18 @@
4040 "medium-editor": "^5.23.3",
4141 "medium-editor-markdown": "^2.6.0",
4242 "micro-css": "^2.0.1",
4343 "morphdom": "^2.3.3",
44- "mutant": "^3.21.2",
44+ "mutant": "^3.22.1",
4545 "mutant-scroll": "0.0.5",
4646 "open-external": "^0.1.1",
4747 "patch-drafts": "0.0.6",
4848 "patch-history": "^1.0.0",
4949 "patch-profile": "^1.0.4",
5050 "patch-settings": "^1.1.2",
5151 "patch-suggest": "^1.1.0",
5252 "patchcore": "^1.23.3",
53+ "pull-defer": "^0.2.2",
5354 "pull-next": "^1.0.1",
5455 "pull-next-step": "^1.0.0",
5556 "pull-obv": "^1.3.0",
5657 "pull-stream": "^3.6.0",
router/sync/routes.jsView
@@ -14,8 +14,9 @@
1414 'app.page.blogShow': 'first',
1515 'app.page.settings': 'first',
1616 'app.page.channelSubscriptions': 'first',
1717 'app.page.channelShow': 'first',
18+ 'app.page.notifications': 'first',
1819 // 'app.page.channel': 'first',
1920 // 'app.page.groupFind': 'first',
2021 // 'app.page.groupIndex': 'first',
2122 // 'app.page.groupNew': 'first',
@@ -55,10 +56,11 @@
5556 [ location => location.page === 'channelSubscriptions', pages.channelSubscriptions ],
5657 [ location => location.page === 'channelShow', pages.channelShow ],
5758 [ location => location.channel, pages.channelShow ],
5859
59- // Stats pages
60+ // Stats / Notifications pages
6061 [ location => location.page === 'statsShow', pages.statsShow ],
62+ [ location => location.page === 'notifications', pages.notifications ],
6163
6264 // AddressBook pages
6365 [ location => location.page === 'addressBook', pages.addressBook ],
6466
translations/en.jsView
@@ -1,5 +1,6 @@
11 module.exports = {
2+ stats: 'Stats',
23 comments: 'Comments',
34 likes: 'Likes',
45 shares: 'Shares',
56 splash: {
ssb-server-blog-stats.jsView
@@ -1,146 +1,0 @@
1-const FlumeView = require('flumeview-level')
2-const get = require('lodash/get')
3-const pull = require('pull-stream')
4-const isBlog = require('scuttle-blog/isBlog')
5-const { isMsg: isMsgRef } = require('ssb-ref')
6-
7-const getType = (msg) => get(msg, 'value.content.type')
8-const getAuthor = (msg) => get(msg, 'value.author')
9-const getCommentRoot = (msg) => get(msg, 'value.content.root')
10-const getLikeRoot = (msg) => get(msg, 'value.content.vote.link')
11-const getTimestamp = (msg) => get(msg, 'value.timestamp')
12-
13-const FLUME_VIEW_VERSION = 1
14-
15-module.exports = {
16- name: 'blogStats',
17- version: 1,
18- manifest: {
19- get: 'async',
20- read: 'source',
21- readBlogs: 'source',
22- getBlogs: 'async',
23- readComments: 'source',
24- readLikes: 'source'
25- },
26- init: (server, config) => {
27- console.log('initialising blog-stats plugin')
28- const myKey = server.keys.id
29-
30- const view = server._flumeUse(
31- 'internalblogStats',
32- FlumeView(FLUME_VIEW_VERSION, map)
33- )
34-
35- return {
36- get: view.get,
37- read: view.read,
38- readBlogs,
39- getBlogs,
40- readComments,
41- readLikes
42- // readShares
43- }
44-
45- function map (msg, seq) {
46- var root
47-
48- switch (getType(msg)) {
49- case 'blog':
50- if (isBlog(msg) && isMyMsg(msg)) return [['B', msg.key, getTimestamp(msg)]]
51- else return []
52-
53- case 'vote':
54- root = getLikeRoot(msg)
55- // TODO figure out how to only store likes I care about
56- if (root) return [['L', root, getTimestamp(msg)]]
57- else return []
58-
59- // Note this catches:
60- // - all likes, on all things D:
61- // - likes AND unlikes
62-
63- case 'post':
64- root = getCommentRoot(msg)
65- // TODO figure out how to only store comments I care about
66- if (!root && isMyMsg(msg) && isPlog(msg)) return [['B', msg.key, getTimestamp(msg)]]
67- else if (root) return [['C', root, getTimestamp(msg)]]
68- else return []
69-
70- // Note this catches:
71- // - all comments, on all things D:
72-
73- default:
74- return []
75- }
76- }
77-
78- // a Plog is a Blog shaped Post!
79- function isPlog (msg) {
80- // if (get(msg, 'value.content.text', '').length >= 2500) console.log(get(msg, 'value.content.text', '').length)
81- return get(msg, 'value.content.text', '').length >= 2500
82- }
83-
84- function readBlogs (options = {}) {
85- const query = Object.assign({}, {
86- gte: ['B', null, null],
87- // null is the 'minimum' structure in bytewise ordering
88- lte: ['B', undefined, undefined],
89- reverse: true,
90- values: true,
91- keys: false,
92- seqs: false
93- }, options)
94-
95- return view.read(query)
96- }
97-
98- function getBlogs (options, cb) {
99- pull(
100- readBlogs(options),
101- pull.collect(cb)
102- )
103- }
104-
105- function readComments (blog, options = {}) {
106- var key = getBlogKey(blog)
107-
108- const query = Object.assign({}, {
109- gt: ['C', key, null],
110- lt: ['C', key, undefined],
111- // undefined is the 'maximum' structure in bytewise ordering https://www.npmjs.com/package/bytewise#order-of-supported-structures
112- reverse: true,
113- values: true,
114- keys: false,
115- seqs: false
116- }, options)
117-
118- return view.read(query)
119- }
120-
121- function readLikes (blog, options = {}) {
122- var key = getBlogKey(blog)
123-
124- const query = Object.assign({}, {
125- gt: ['L', key, null],
126- lt: ['L', key, undefined],
127- reverse: true,
128- values: true,
129- keys: false,
130- seqs: false
131- }, options)
132-
133- return view.read(query)
134- }
135-
136- function getBlogKey (blog) {
137- if (isMsgRef(blog)) return blog
138- // else if (isMsgRef(blog.key) && isBlog(blog)) return blog.key
139- else if (isMsgRef(blog.key) && (isBlog(blog) || isPlog(blog))) return blog.key
140- }
141-
142- function isMyMsg (msg) {
143- return getAuthor(msg) === myKey
144- }
145- }
146-}
ssb-server-ticktack.jsView
@@ -1,0 +1,222 @@
1+const FlumeView = require('flumeview-level')
2+const get = require('lodash/get')
3+const pull = require('pull-stream')
4+const defer = require('pull-defer')
5+const isBlog = require('scuttle-blog/isBlog')
6+const { isMsg: isMsgRef } = require('ssb-ref')
7+
8+const getType = (msg) => get(msg, 'value.content.type')
9+const getAuthor = (msg) => get(msg, 'value.author')
10+const getCommentRoot = (msg) => get(msg, 'value.content.root')
11+const getLikeRoot = (msg) => get(msg, 'value.content.vote.link')
12+const getShareRoot = (msg) => get(msg, 'value.content.share.link')
13+const getTimestamp = (msg) => get(msg, 'value.timestamp')
14+
15+const FLUME_VIEW_VERSION = 1
16+
17+module.exports = {
18+ name: 'ticktack',
19+ version: 1,
20+ manifest: {
21+ get: 'async',
22+ read: 'source',
23+ readBlogs: 'source',
24+ getBlogs: 'async',
25+ readComments: 'source',
26+ readAllComments: 'source',
27+ readAllLikes: 'source',
28+ readAllShares: 'source',
29+ readLikes: 'source'
30+ },
31+ init: (server, config) => {
32+ console.log('> initialising ticktack plugin')
33+ const myKey = server.keys.id
34+
35+ const view = server._flumeUse(
36+ 'ticktack',
37+ FlumeView(FLUME_VIEW_VERSION, map)
38+ )
39+
40+ return {
41+ get: view.get,
42+ read: view.read,
43+ readBlogs,
44+ getBlogs,
45+ readComments,
46+ readAllComments,
47+ readAllLikes,
48+ readAllShares,
49+ readLikes
50+ // readShares
51+ }
52+
53+ function map (msg, seq) {
54+ var root
55+
56+ switch (getType(msg)) {
57+ case 'blog':
58+ if (isBlog(msg) && isMyMsg(msg)) return [['B', msg.key, getTimestamp(msg)]]
59+ else return []
60+
61+ case 'vote':
62+ root = getLikeRoot(msg)
63+ // TODO figure out how to only store likes I care about
64+ if (root) return [['L', root, getTimestamp(msg)]]
65+ else return []
66+
67+ // Note this catches:
68+ // - all likes, on all things D:
69+ // - likes AND unlikes
70+
71+ case 'post':
72+ root = getCommentRoot(msg)
73+ // TODO figure out how to only store comments I care about
74+ if (!root && isMyMsg(msg) && isPlog(msg)) return [['B', msg.key, getTimestamp(msg)]]
75+ else if (root) return [['C', root, getTimestamp(msg)]]
76+ else return []
77+
78+ // Note this catches:
79+ // - all comments, on all things D:
80+
81+ default:
82+ return []
83+ }
84+ }
85+
86+ function readBlogs (options = {}) {
87+ const query = Object.assign({}, {
88+ gte: ['B', null, null],
89+ // null is the 'minimum' structure in bytewise ordering
90+ lte: ['B', undefined, undefined],
91+ reverse: true,
92+ values: true,
93+ keys: false,
94+ seqs: false
95+ }, options)
96+
97+ return view.read(query)
98+ }
99+
100+ function getBlogs (options, cb) {
101+ if (typeof options === 'function') {
102+ cb = options
103+ options = {}
104+ }
105+
106+ pull(
107+ readBlogs(options),
108+ pull.collect(cb)
109+ )
110+ }
111+
112+ function readComments (blog, options = {}) {
113+ var key = getBlogKey(blog)
114+
115+ const query = Object.assign({}, {
116+ gt: ['C', key, null],
117+ lt: ['C', key, undefined],
118+ // undefined is the 'maximum' structure in bytewise ordering https://www.npmjs.com/package/bytewise#order-of-supported-structures
119+ reverse: true,
120+ values: true,
121+ keys: false,
122+ seqs: false
123+ }, options)
124+
125+ return view.read(query)
126+ }
127+
128+ function readLikes (blog, options = {}) {
129+ var key = getBlogKey(blog)
130+
131+ const query = Object.assign({}, {
132+ gt: ['L', key, null],
133+ lt: ['L', key, undefined],
134+ reverse: true,
135+ values: true,
136+ keys: false,
137+ seqs: false
138+ }, options)
139+
140+ return view.read(query)
141+ }
142+
143+ function readAllComments (opts = {}) {
144+ return readAllSource({
145+ type: 'post',
146+ makeFilter: blogIds => msg => {
147+ if (getAuthor(msg) === myKey) return false // exclude my posts
148+ if (getCommentRoot(msg) === undefined) return false // want only 'comments' (reply posts)
149+ // NOTE - this one will get nested replies too
150+
151+ return blogIds.includes(getCommentRoot(msg)) // is about one of my blogs
152+ },
153+ opts
154+ })
155+ }
156+
157+ function readAllLikes (opts = {}) {
158+ return readAllSource({
159+ type: 'vote',
160+ makeFilter: blogIds => msg => {
161+ if (getAuthor(msg) === myKey) return false // exclude my likes
162+
163+ return blogIds.includes(getLikeRoot(msg)) // is about one of my blogs
164+ },
165+ opts
166+ })
167+ }
168+
169+ function readAllShares (opts = {}) {
170+ return readAllSource({
171+ type: 'share',
172+ makeFilter: (blogIds) => msg => {
173+ if (getAuthor(msg) === myKey) return false // exclude my shares
174+
175+ return blogIds.includes(getShareRoot(msg)) // is about one of my blogs
176+ },
177+ opts
178+ })
179+ }
180+
181+ function readAllSource ({ type, makeFilter, opts = {} }) {
182+ var source = defer.source()
183+
184+ getBlogs({ keys: true, values: false }, (err, data) => {
185+ if (err) throw err
186+
187+ const blogIds = data.map(d => d[1])
188+
189+ opts.type = type
190+ var limit = opts.limit
191+ delete opts.limit
192+ // have to remove limit from the query otherwise Next stalls out if it doesn't get a new result
193+
194+ const _source = pull(
195+ server.messagesByType(opts),
196+ pull.filter(makeFilter(blogIds)),
197+ limit ? pull.take(limit) : true
198+ )
199+
200+ source.resolve(_source)
201+ })
202+
203+ return source
204+ }
205+
206+ function isMyMsg (msg) {
207+ return getAuthor(msg) === myKey
208+ }
209+ }
210+}
211+
212+function getBlogKey (blog) {
213+ if (isMsgRef(blog)) return blog
214+ // else if (isMsgRef(blog.key) && isBlog(blog)) return blog.key
215+ else if (isMsgRef(blog.key) && (isBlog(blog) || isPlog(blog))) return blog.key
216+}
217+
218+// a Plog is a Blog shaped Post!
219+function isPlog (msg) {
220+ // if (get(msg, 'value.content.text', '').length >= 2500) console.log(get(msg, 'value.content.text', '').length)
221+ return get(msg, 'value.content.text', '').length >= 2500
222+}

Built with git-ssb-web