git ssb

10+

Matt McKegg / patchwork



Commit 0405c2c6e56360eb92b2acbfa5f6ea97ddc20475

endless scrolling on feed page, remember view scroll position

Matt McKegg committed on 10/29/2016, 5:19:49 AM
Parent: 96276e96832b173700c73ba0f9e6b5bb4fc737dd

Files changed

README.mdchanged
lib/pull-scroll.jsadded
main-window.jschanged
package.jsonchanged
styles/main-window.mcsschanged
styles/loading.mcssadded
views/public-feed.jschanged
README.mdView
@@ -17,11 +17,14 @@
1717 ## TODO
1818
1919 - [x] Main navigation buttons
2020 - [x] Compressed feed (the algorithm :wink:)
21-- [ ] Endless scrolling (or load more) on main feed
22-- [ ] Power user tabs (right-click, Open in New Tab -> tab interface appears safari style)
23-- [ ] Preserve scroll on back button
21 +- [x] Endless scrolling (or load more) on main feed [fake paginate, add a new section, leave the current one and remove the top most]
22 +- [x] Display fixed banner at top of view when there are new updates [scrolls to top of page and reloads view when clicked]
23 +- [x] Preserve scroll on back button
24 +- [ ] Notifications drop down (show unread mentions, digs, replies)
25 + - [ ] to support this, we need to track subscriptions and read status
26 +- [ ] Treat the different "views" more like tabs. They preserve their state when switched between [scroll position, forms].
2427 - [ ] Search
2528 - [ ] Easy navigation sidebar
2629 - [ ] Contacts sidebar
2730 - [ ] Show digs on posts in a nicer way (make it clear that you've dug something)
lib/pull-scroll.jsView
@@ -1,0 +1,126 @@
1 +// FROM: https://raw.githubusercontent.com/dominictarr/pull-scroll/master/index.js
2 +var pull = require('pull-stream')
3 +var Pause = require('pull-pause')
4 +var isVisible = require('is-visible').isVisible
5 +
6 +var next = 'undefined' === typeof setImmediate ? setTimeout : setImmediate
7 +
8 +function isBottom (scroller, buffer) {
9 + var rect = scroller.getBoundingClientRect()
10 + var topmax = scroller.scrollTopMax || (scroller.scrollHeight - rect.height)
11 + return scroller.scrollTop >=
12 + + ((topmax) - (buffer || 0))
13 +}
14 +
15 +function isTop (scroller, buffer) {
16 + return scroller.scrollTop <= (buffer || 0)
17 +}
18 +
19 +function isFilled(content) {
20 + return (
21 + !isVisible(content)
22 + //check if the scroller is not visible.
23 + // && content.getBoundingClientRect().height == 0
24 + //and has children. if there are no children,
25 + //it might be size zero because it hasn't started yet.
26 +// &&
27 + && content.children.length > 10
28 + //&& !isVisible(scroller)
29 + )
30 +}
31 +
32 +function isEnd(scroller, buffer, top) {
33 + //if the element is display none, don't read anything into it.
34 + return (top ? isTop : isBottom)(scroller, buffer)
35 +}
36 +
37 +function append(scroller, list, el, top, sticky) {
38 + if(!el) return
39 + var s = scroller.scrollHeight
40 + if(top && list.firstChild)
41 + list.insertBefore(el, list.firstChild)
42 + else
43 + list.appendChild(el)
44 +
45 + //scroll down by the height of the thing added.
46 + //if it added to the top (in non-sticky mode)
47 + //or added it to the bottom (in sticky mode)
48 + if(top !== sticky) {
49 + var st = list.scrollTop, d = (scroller.scrollHeight - s) + 1
50 + scroller.scrollTop = scroller.scrollTop + d
51 + }
52 +}
53 +
54 +function overflow (el) {
55 + return el.style.overflowY || el.style.overflow || (function () {
56 + var style = getComputedStyle(el)
57 + return style.overflowY || el.style.overflow
58 + })()
59 +}
60 +
61 +var buffer = 1000
62 +module.exports = function Scroller(scroller, content, render, top, sticky, cb) {
63 + //if second argument is a function,
64 + //it means the scroller and content elements are the same.
65 + if('function' === typeof content) {
66 + cb = sticky
67 + top = render
68 + render = content
69 + content = scroller
70 + }
71 +
72 + if(!cb) cb = function (err) { if(err) throw err }
73 +
74 + var f = overflow(scroller)
75 + if(!/auto|scroll/.test(f))
76 + throw new Error('scroller.style.overflowY must be scroll or auto, was:' + f + '!')
77 + scroller.addEventListener('scroll', scroll)
78 + var pause = Pause(function () {}), queue = []
79 +
80 + //apply some changes to the dom, but ensure that
81 + //`element` is at the same place on screen afterwards.
82 +
83 + function add () {
84 + if(queue.length)
85 + append(scroller, content, render(queue.shift()), top, sticky)
86 + }
87 +
88 + function scroll (ev) {
89 + if (isEnd(scroller, buffer, top) || isFilled(content)) {
90 + pause.resume()
91 + add()
92 + }
93 + }
94 +
95 + // pause.pause()
96 + //
97 + // //wait until the scroller has been added to the document
98 + // next(function next () {
99 + // if(scroller.parentElement) pause.resume()
100 + // else setTimeout(next, 100)
101 + // })
102 +
103 + var stream = pull(
104 + pause,
105 + pull.drain(function (e) {
106 + queue.push(e)
107 + //we don't know the scroll bar positions if it's display none
108 + //so we have to wait until it becomes visible again.
109 + if(!isVisible(content)) {
110 + if(content.children.length < 10) add()
111 + }
112 + else if(isEnd(scroller, buffer, top)) add()
113 +
114 + if(queue.length > 5) pause.pause()
115 + }, function (err) {
116 + scroller.removeEventListener('scroll', scroll)
117 + if(err) console.error(err)
118 + cb ? cb(err) : console.error(err)
119 + })
120 + )
121 +
122 + stream.visible = add
123 +
124 + return stream
125 +
126 +}
main-window.jsView
@@ -6,8 +6,12 @@
66 var plugs = require('patchbay/plugs')
77 var Value = require('@mmckegg/mutant/value')
88 var when = require('@mmckegg/mutant/when')
99 var computed = require('@mmckegg/mutant/computed')
10 +var toCollection = require('@mmckegg/mutant/dict-to-collection')
11 +var MutantDict = require('@mmckegg/mutant/dict')
12 +var MutantMap = require('@mmckegg/mutant/map')
13 +var watch = require('@mmckegg/mutant/watch')
1014
1115 module.exports = function (config, ssbClient) {
1216 var api = SbotApi(ssbClient, config)
1317 var modules = combine(extend(Modules, {
@@ -26,18 +30,18 @@
2630
2731 var screenView = plugs.first(modules.plugs.screen_view)
2832 var forwardHistory = []
2933 var backHistory = []
30- var views = {
34 + var views = MutantDict({
3135 '/public': screenView('/public')
32- }
36 + })
37 +
3338 var canGoForward = Value(false)
3439 var canGoBack = Value(false)
35- var currentView = Value(['/public'])
36- var rootElement = computed(currentView, (data) => {
37- if (Array.isArray(data)) {
38- return views[data[0]]
39- }
40 + var currentView = Value('/public')
41 +
42 + watch(currentView, (view) => {
43 + window.location.hash = `#${view}`
4044 })
4145
4246 window.onhashchange = function (ev) {
4347 var path = window.location.hash.substring(1)
@@ -45,11 +49,13 @@
4549 setView(path)
4650 }
4751 }
4852
49- var mainElement = h('div.main', [
50- rootElement
51- ])
53 + var mainElement = h('div.main', MutantMap(toCollection(views), (item) => {
54 + return h('div.view', {
55 + hidden: computed([item.key, currentView], (a, b) => a !== b)
56 + }, [ item.value ])
57 + }))
5258
5359 return h('MainWindow', {
5460 classList: [ '-' + process.platform ]
5561 }, [
@@ -110,24 +116,24 @@
110116 canGoBack.set(true)
111117 }
112118 }
113119
114- function setView (view, ...args) {
115- var newView = [view, ...args]
116- views[view] = screenView(view, ...args)
117- if (!isSame(newView, currentView())) {
120 + function setView (view) {
121 + if (!views.has(view)) {
122 + views.put(view, screenView(view))
123 + }
124 + if (view !== currentView()) {
118125 canGoForward.set(false)
119126 canGoBack.set(true)
120127 forwardHistory.length = 0
121128 backHistory.push(currentView())
129 + currentView.set(view)
122130 }
123- currentView.set(newView)
124- currentView().scrollTop = 0
125131 }
126132
127133 function selected (view) {
128134 return computed([currentView, view], (currentView, view) => {
129- return currentView && currentView[0] === view
135 + return currentView === view
130136 })
131137 }
132138 }
133139
package.jsonView
@@ -9,25 +9,30 @@
99 "postinstall": "npm run rebuild",
1010 "rebuild": "npm rebuild --runtime=electron --target=1.4.3 --abi=50 --disturl=https://atom.io/download/atom-shell"
1111 },
1212 "author": "",
13- "license": "ISC",
13 + "license": "GPL",
1414 "dependencies": {
15- "@mmckegg/mutant": "^3.6.1",
15 + "@mmckegg/mutant": "~3.7.0",
1616 "data-uri-to-buffer": "0.0.4",
17- "electron": "^1.4.4",
18- "electron-default-menu": "^1.0.0",
19- "insert-css": "^1.0.0",
20- "micro-css": "^0.6.2",
21- "patchbay": "^3.5.0",
22- "pull-file": "^1.0.0",
17 + "electron": "~1.4.4",
18 + "electron-default-menu": "~1.0.0",
19 + "insert-css": "~1.0.0",
20 + "is-visible": "^2.1.1",
21 + "micro-css": "~0.6.2",
22 + "patchbay": "~3.5.0",
23 + "pull-abortable": "^4.1.0",
24 + "pull-file": "~1.0.0",
2325 "pull-identify-filetype": "^1.1.0",
24- "pull-stream": "^3.4.5",
25- "scuttlebot": "^9.2.0",
26- "sorted-array-functions": "^1.0.0",
27- "ssb-blobs": "^0.1.7",
28- "ssb-keys": "^7.0.0",
29- "ssb-links": "^2.0.0",
30- "ssb-query": "^0.1.1",
31- "ssb-ref": "^2.6.2"
26 + "pull-next": "0.0.2",
27 + "pull-pause": "0.0.0",
28 + "pull-pushable": "^2.0.1",
29 + "pull-stream": "~3.4.5",
30 + "scuttlebot": "~9.2.0",
31 + "sorted-array-functions": "~1.0.0",
32 + "ssb-blobs": "~0.1.7",
33 + "ssb-keys": "~7.0.0",
34 + "ssb-links": "~2.0.0",
35 + "ssb-query": "~0.1.1",
36 + "ssb-ref": "~2.6.2"
3237 }
3338 }
styles/main-window.mcssView
@@ -115,14 +115,38 @@
115115 }
116116
117117 div.main {
118118 flex: 1
119- overflow: auto
120119 display: flex
121120
122- div {
121 + div.view {
122 +
123 + [hidden] {
124 + display: none
125 + }
126 +
127 + display: flex
128 + flex-direction: column
123129 flex: 1
124- -webkit-user-select: text
130 +
131 + a.loader {
132 + padding: 10px;
133 + display: block;
134 + background: rgb(214, 228, 236);
135 + border: 1px solid rgb(187, 201, 210);
136 + text-align: center;
137 +
138 + :hover {
139 + background: rgb(220, 242, 255);
140 + }
141 +
142 + animation: 0.5s slide-in
143 + position: relative
144 + }
145 +
146 + div {
147 + -webkit-user-select: text
148 + }
125149 }
126150 }
127151
128152 div.bottom {
styles/loading.mcssView
@@ -1,0 +1,63 @@
1 +Loading {
2 + height: 50%
3 + display: flex
4 + align-items: center
5 + justify-content: center
6 +
7 + -inline {
8 + height: 16px
9 + width: 16px
10 + display: inline-block
11 + margin: -3px 3px
12 +
13 + ::before {
14 + display: block
15 + height: 16px
16 + width: 16px
17 + }
18 + }
19 +
20 + -large {
21 + ::before {
22 + height: 100px
23 + width: 100px
24 + }
25 + ::after {
26 + color: #CCC;
27 + content: 'Loading...'
28 + font-size: 200%
29 + }
30 + }
31 +
32 + ::before {
33 + content: ' '
34 + height: 50px
35 + width: 50px
36 + background-image: svg(waitingIcon)
37 + background-repeat: no-repeat
38 + background-position: center
39 + background-size: contain
40 + animation: spin 3s infinite linear
41 + }
42 +}
43 +
44 +@svg waitingIcon {
45 + width: 30px
46 + height: 30px
47 + content: "<circle cx='15' cy='15' r='10' /><circle cx='10' cy='10' r='2' /><circle cx='20' cy='20' r='3' />"
48 +
49 + circle {
50 + stroke: #CCC
51 + stroke-width: 3px
52 + fill: none
53 + }
54 +}
55 +
56 +@keyframes spin {
57 + 0% {
58 + transform: rotate(0deg);
59 + }
60 + 100% {
61 + transform: rotate(360deg);
62 + }
63 +}
views/public-feed.jsView
@@ -1,9 +1,15 @@
11 var SortedArray = require('sorted-array-functions')
22 var Value = require('@mmckegg/mutant/value')
3-var MutantMap = require('@mmckegg/mutant/map')
43 var h = require('@mmckegg/mutant/html-element')
54 var when = require('@mmckegg/mutant/when')
5 +var computed = require('@mmckegg/mutant/computed')
6 +var MutantArray = require('@mmckegg/mutant/array')
7 +var pullPushable = require('pull-pushable')
8 +var pullNext = require('pull-next')
9 +var Scroller = require('../lib/pull-scroll')
10 +var Abortable = require('pull-abortable')
11 +
612 var m = require('../lib/h')
713
814 var pull = require('pull-stream')
915
@@ -17,79 +23,22 @@
1723
1824 exports.screen_view = function (path, sbot) {
1925 if (path === '/public') {
2026 var sync = Value(false)
21- var events = Value([])
2227 var updates = Value(0)
2328
24- var updateLoader = m('a', {
29 + var updateLoader = m('a.loader', {
2530 href: '#',
26- style: {
27- 'padding': '10px',
28- 'display': 'block',
29- 'background': '#d6e4ec',
30- 'border': '1px solid #bbc9d2',
31- 'text-align': 'center'
32- },
3331 'ev-click': refresh
34- }, [ 'Load ', h('strong', [updates]), ' update(s)' ])
32 + }, [
33 + 'Show ',
34 + h('strong', [updates]), ' ',
35 + when(computed(updates, a => a === 1), 'update', 'updates')
36 + ])
3537
36- var content = h('div.column.scroller__content', [
37- when(updates, updateLoader),
38- MutantMap(events, (group) => {
39- if (group.type === 'message') {
40- var meta = null
41- var replies = group.replies.slice(-3).map(message_render)
42- var renderedMessage = group.message ? message_render(group.message) : null
43- if (renderedMessage) {
44- if (group.lastUpdateType === 'reply') {
45- meta = m('div.meta', [
46- manyPeople(group.repliesFrom), ' replied'
47- ])
48- } else if (group.lastUpdateType === 'dig') {
49- meta = m('div.meta', [
50- manyPeople(group.digs), ' dug this message'
51- ])
52- }
38 + var content = h('div.column.scroller__content')
5339
54- return m('FeedEvent', [
55- meta,
56- renderedMessage,
57- when(replies.length, [
58- when(group.replies.length > replies.length,
59- m('a.full', {href: `#${group.messageId}`}, ['View full thread'])
60- ),
61- m('div.replies', replies)
62- ])
63- ])
64- } else {
65- if (group.lastUpdateType === 'reply') {
66- meta = m('div.meta', [
67- manyPeople(group.repliesFrom), ' replied to ', message_link(group.messageId)
68- ])
69- } else if (group.lastUpdateType === 'dig') {
70- meta = m('div.meta', [
71- manyPeople(group.digs), ' dug ', message_link(group.messageId)
72- ])
73- }
74-
75- if (meta || replies.length) {
76- return m('FeedEvent', [
77- meta, m('div.replies', replies)
78- ])
79- }
80- }
81- } else if (group.type === 'follow') {
82- return m('FeedEvent -follow', [
83- m('div.meta', [
84- person(group.id), ' followed ', manyPeople(group.contacts)
85- ])
86- ])
87- }
88- }, {maxTime: 5})
89- ])
90-
91- var div = h('div.column.scroller', {
40 + var scrollElement = h('div.column.scroller', {
9241 style: {
9342 'overflow': 'auto'
9443 }
9544 }, [
@@ -98,37 +47,125 @@
9847 content
9948 ])
10049 ])
10150
102- refresh()
51 + setTimeout(refresh, 10)
10352
10453 pull(
10554 sbot_log({old: false}),
10655 pull.drain((item) => {
107- updates.set(updates() + 1)
56 + if (!item.value.content.type === 'vote') {
57 + updates.set(updates() + 1)
58 + }
10859 })
10960 )
11061
111- // pull(
112- // u.next(sbot_log, {reverse: true, limit: 100, live: false}),
113- // Scroller(div, content, message_render, false, false)
114- // )
62 + var abortLastFeed = null
11563
116- return div
64 + return MutantArray([
65 + when(updates, updateLoader),
66 + when(sync, scrollElement, m('Loading -large'))
67 + ])
11768 }
11869
11970 // scoped
12071 function refresh () {
72 + if (abortLastFeed) {
73 + abortLastFeed()
74 + }
75 + updates.set(0)
76 + sync.set(false)
77 + content.innerHTML = ''
78 +
79 + var abortable = Abortable()
80 + abortLastFeed = abortable.abort
81 +
12182 pull(
122- sbot_log({reverse: true, limit: 500, live: false}),
83 + FeedSummary(sbot_log, 100, () => {
84 + sync.set(true)
85 + }),
86 + abortable,
87 + Scroller(scrollElement, content, renderItem, false, false)
88 + )
89 + }
90 +}
91 +
92 +function FeedSummary (stream, windowSize, cb) {
93 + var last = null
94 + var returned = false
95 + return pullNext(() => {
96 + var next = {reverse: true, limit: windowSize, live: false}
97 + if (last) {
98 + next.lt = last.timestamp
99 + }
100 + var pushable = pullPushable()
101 + pull(
102 + sbot_log(next),
123103 pull.collect((err, values) => {
124104 if (err) throw err
125- events.set(groupMessages(values))
126- sync.set(true)
127- updates.set(0)
105 + groupMessages(values).forEach(v => pushable.push(v))
106 + last = values[values.length - 1]
107 + pushable.end()
108 + if (!returned) cb && cb()
109 + returned = true
128110 })
129111 )
112 + return pushable
113 + })
114 +}
115 +
116 +function renderItem (item) {
117 + if (item.type === 'message') {
118 + var meta = null
119 + var replies = item.replies.slice(-3).map(message_render)
120 + var renderedMessage = item.message ? message_render(item.message) : null
121 + if (renderedMessage) {
122 + if (item.lastUpdateType === 'reply' && item.repliesFrom.size) {
123 + meta = m('div.meta', [
124 + manyPeople(item.repliesFrom), ' replied'
125 + ])
126 + } else if (item.lastUpdateType === 'dig' && item.digs.size) {
127 + meta = m('div.meta', [
128 + manyPeople(item.digs), ' dug this message'
129 + ])
130 + }
131 +
132 + return m('FeedEvent', [
133 + meta,
134 + renderedMessage,
135 + when(replies.length, [
136 + when(item.replies.length > replies.length,
137 + m('a.full', {href: `#${item.messageId}`}, ['View full thread'])
138 + ),
139 + m('div.replies', replies)
140 + ])
141 + ])
142 + } else {
143 + if (item.lastUpdateType === 'reply' && item.repliesFrom.size) {
144 + meta = m('div.meta', [
145 + manyPeople(item.repliesFrom), ' replied to ', message_link(item.messageId)
146 + ])
147 + } else if (item.lastUpdateType === 'dig' && item.digs.size) {
148 + meta = m('div.meta', [
149 + manyPeople(item.digs), ' dug ', message_link(item.messageId)
150 + ])
151 + }
152 +
153 + if (meta || replies.length) {
154 + return m('FeedEvent', [
155 + meta, m('div.replies', replies)
156 + ])
157 + }
158 + }
159 + } else if (item.type === 'follow') {
160 + return m('FeedEvent -follow', [
161 + m('div.meta', [
162 + person(item.id), ' followed ', manyPeople(item.contacts)
163 + ])
164 + ])
130165 }
166 +
167 + return h('div')
131168 }
132169
133170 function person (id) {
134171 return avatar_link(id, avatar_name(id), '')
@@ -137,27 +174,29 @@
137174 function manyPeople (ids) {
138175 ids = Array.from(ids)
139176 var featuredIds = ids.slice(-3).reverse()
140177
141- if (ids.length > 3) {
142- return [
143- person(featuredIds[0]), ', ',
144- person(featuredIds[1]),
145- ' and ', ids.length - 2, ' others'
146- ]
147- } else if (ids.length === 3) {
148- return [
149- person(featuredIds[0]), ', ',
150- person(featuredIds[1]), ' and ',
151- person(featuredIds[2])
152- ]
153- } else if (ids.length === 2) {
154- return [
155- person(featuredIds[0]), ' and ',
156- person(featuredIds[1])
157- ]
158- } else {
159- return person(featuredIds[0])
178 + if (ids.length) {
179 + if (ids.length > 3) {
180 + return [
181 + person(featuredIds[0]), ', ',
182 + person(featuredIds[1]),
183 + ' and ', ids.length - 2, ' others'
184 + ]
185 + } else if (ids.length === 3) {
186 + return [
187 + person(featuredIds[0]), ', ',
188 + person(featuredIds[1]), ' and ',
189 + person(featuredIds[2])
190 + ]
191 + } else if (ids.length === 2) {
192 + return [
193 + person(featuredIds[0]), ' and ',
194 + person(featuredIds[1])
195 + ]
196 + } else {
197 + return person(featuredIds[0])
198 + }
160199 }
161200 }
162201
163202 function groupMessages (messages) {
@@ -178,8 +217,11 @@
178217 group.digs.add(msg.value.author)
179218 group.updated = msg.timestamp
180219 } else {
181220 group.digs.delete(msg.value.author)
221 + if (group.lastUpdateType === 'dig' && !group.digs.size && !group.replies.length) {
222 + group.lastUpdateType = 'reply'
223 + }
182224 }
183225 }
184226 }
185227 } else {

Built with git-ssb-web