Commit d6637ae89d381d174f5c5aac3dafebebe079c33a
working public feed
mix irving committed on 2/23/2017, 8:51:33 AMParent: 75fbecbdcbff096403ae2a6adeda63498269fd05
Files changed
index.js | changed |
main/html/scroller.js | added |
package.json | changed |
router/html/page/public.js | changed |
about/async/suggest.js | added |
channel/async/suggest.js | added |
junk/next-stepper.js | added |
message/html/compose.js | added |
index.js | ||
---|---|---|
@@ -9,11 +9,9 @@ | ||
9 | 9 | // from more specialized to more general |
10 | 10 | const sockets = combine( |
11 | 11 | // require(patchgit) |
12 | 12 | bulk(__dirname, [ |
13 | - 'main/**/*.js', | |
14 | - 'router/**/*.js', | |
15 | - 'styles/**/*.js' | |
13 | + '!(node_modules|junk)/**/*.js' | |
16 | 14 | ]), |
17 | 15 | require('patchcore') |
18 | 16 | ) |
19 | 17 |
main/html/scroller.js | ||
---|---|---|
@@ -1,0 +1,26 @@ | ||
1 | +const { h } = require('mutant') | |
2 | +const nest = require('depnest') | |
3 | + | |
4 | +exports.gives = nest('main.html.scroller') | |
5 | + | |
6 | +exports.create = function (api) { | |
7 | + return nest('main.html.scroller', build_scroller) | |
8 | + | |
9 | + function build_scroller ({ prepend = [], append = [] } = {}) { | |
10 | + const content = h('section.content') | |
11 | + | |
12 | + const container = h('Scroller', { style: { overflow: 'auto' } }, [ | |
13 | + h('div.wrapper', [ | |
14 | + h('header', prepend), | |
15 | + content, | |
16 | + h('footer', append) | |
17 | + ]) | |
18 | + ]) | |
19 | + | |
20 | + return { | |
21 | + content, | |
22 | + container | |
23 | + } | |
24 | + } | |
25 | +} | |
26 | + |
package.json | ||
---|---|---|
@@ -27,9 +27,17 @@ | ||
27 | 27 | "insert-css": "^2.0.0", |
28 | 28 | "libnested": "^1.2.1", |
29 | 29 | "micro-css": "^1.0.0", |
30 | 30 | "mutant": "^3.16.0", |
31 | + "mutant-pull-reduce": "^1.0.1", | |
31 | 32 | "open-external": "^0.1.1", |
33 | + "pull-cat": "^1.1.11", | |
34 | + "pull-next": "0.0.2", | |
35 | + "pull-scroll": "^1.0.3", | |
36 | + "pull-stream": "^3.5.0", | |
32 | 37 | "read-directory": "^2.0.0", |
33 | - "setimmediate": "^1.0.5" | |
38 | + "setimmediate": "^1.0.5", | |
39 | + "ssb-mentions": "^0.1.1", | |
40 | + "suggest-box": "^2.2.3", | |
41 | + "xtend": "^4.0.1" | |
34 | 42 | } |
35 | 43 | } |
router/html/page/public.js | ||
---|---|---|
@@ -1,13 +1,37 @@ | ||
1 | 1 | const { h } = require('mutant') |
2 | 2 | const nest = require('depnest') |
3 | +const pull = require('pull-stream') | |
4 | +const Scroller = require('pull-scroll') | |
3 | 5 | |
6 | +const next = require('../../../junk/next-stepper') | |
7 | + | |
4 | 8 | exports.gives = nest('router.html.page') |
5 | 9 | |
10 | +exports.needs = nest({ | |
11 | + 'sbot.pull.log': 'first', | |
12 | + 'message.html.compose': 'first', | |
13 | + 'message.html.render': 'first', | |
14 | + 'main.html.scroller': 'first' | |
15 | +}) | |
16 | + | |
6 | 17 | exports.create = function (api) { |
7 | 18 | return nest('router.html.page', (path) => { |
8 | 19 | if (path !== '/public') return |
9 | 20 | |
10 | - return h('div.public', 'public') | |
21 | + const composer = api.message.html.compose({ meta: { type: 'post' }, placeholder: 'Write a public message'}) | |
22 | + var { container, content } = api.main.html.scroller({ prepend: composer }) | |
23 | + | |
24 | + pull( | |
25 | + next(api.sbot.pull.log, {old: false, limit: 100}), | |
26 | + Scroller(container, content, api.message.html.render, true, false) | |
27 | + ) | |
28 | + | |
29 | + pull( | |
30 | + next(api.sbot.pull.log, {reverse: true, limit: 100, live: false}), | |
31 | + Scroller(container, content, api.message.html.render, false, false) | |
32 | + ) | |
33 | + | |
34 | + return container | |
11 | 35 | }) |
12 | 36 | } |
13 | 37 |
about/async/suggest.js | ||
---|---|---|
@@ -1,0 +1,71 @@ | ||
1 | +var nest = require('depnest') | |
2 | +var { Struct, map, computed, watch } = require('mutant') | |
3 | + | |
4 | +exports.gives = nest('about.async.suggest') | |
5 | + | |
6 | +exports.needs = nest({ | |
7 | + 'about.obs': { | |
8 | + name: 'first', | |
9 | + imageUrl: 'first' | |
10 | + }, | |
11 | + 'contact.obs.following': 'first', | |
12 | + 'feed.obs.recent': 'first', | |
13 | + 'keys.sync.id': 'first' | |
14 | +}) | |
15 | + | |
16 | +exports.create = function (api) { | |
17 | + var suggestions = null | |
18 | + var recentSuggestions = null | |
19 | + | |
20 | + return nest('about.async.suggest', function () { | |
21 | + loadSuggestions() | |
22 | + return function (word) { | |
23 | + if (!word) { | |
24 | + return recentSuggestions() | |
25 | + } else { | |
26 | + return suggestions().filter((item) => { | |
27 | + return item.title.toLowerCase().startsWith(word.toLowerCase()) | |
28 | + }) | |
29 | + } | |
30 | + } | |
31 | + }) | |
32 | + | |
33 | + function loadSuggestions () { | |
34 | + if (!suggestions) { | |
35 | + var id = api.keys.sync.id() | |
36 | + var following = api.contact.obs.following(id) | |
37 | + var recentlyUpdated = api.feed.obs.recent() | |
38 | + var contacts = computed([following, recentlyUpdated], function (a, b) { | |
39 | + var result = Array.from(a) | |
40 | + b.forEach((item, i) => { | |
41 | + if (!result.includes(item)) { | |
42 | + result.push(item) | |
43 | + } | |
44 | + }) | |
45 | + return result | |
46 | + }) | |
47 | + | |
48 | + recentSuggestions = map(computed(recentlyUpdated, (items) => Array.from(items).slice(0, 10)), suggestion, {idle: true}) | |
49 | + suggestions = map(contacts, suggestion, {idle: true}) | |
50 | + watch(recentSuggestions) | |
51 | + watch(suggestions) | |
52 | + } | |
53 | + } | |
54 | + | |
55 | + function suggestion (id) { | |
56 | + var name = api.about.obs.name(id) | |
57 | + return Struct({ | |
58 | + title: name, | |
59 | + id, | |
60 | + subtitle: id.substring(0, 10), | |
61 | + value: computed([name, id], mention), | |
62 | + image: api.about.obs.imageUrl(id), | |
63 | + showBoth: true | |
64 | + }) | |
65 | + } | |
66 | +} | |
67 | + | |
68 | +function mention (name, id) { | |
69 | + return `[@${name}](${id})` | |
70 | +} | |
71 | + |
channel/async/suggest.js | ||
---|---|---|
@@ -1,0 +1,70 @@ | ||
1 | +const nest = require('depnest') | |
2 | +const { computed, watch, map, Struct } = require('mutant') | |
3 | + | |
4 | +exports.gives = nest('channel.async.suggest') | |
5 | + | |
6 | +exports.needs = nest({ | |
7 | + 'channel.obs': { | |
8 | + recent: 'first', | |
9 | + subscribed: 'first' | |
10 | + }, | |
11 | + 'keys.sync.id': 'first' | |
12 | +}) | |
13 | + | |
14 | +exports.create = function (api) { | |
15 | + var suggestions = null | |
16 | + var subscribed = null | |
17 | + | |
18 | + return nest('channel.async.suggest', function () { | |
19 | + loadSuggestions() | |
20 | + return function (word) { | |
21 | + if (!word) { | |
22 | + return suggestions().slice(0, 100) | |
23 | + } else { | |
24 | + return suggestions().filter((item) => { | |
25 | + return item.title.toLowerCase().startsWith(word.toLowerCase()) | |
26 | + }) | |
27 | + } | |
28 | + } | |
29 | + }) | |
30 | + | |
31 | + function loadSuggestions () { | |
32 | + if (!suggestions) { | |
33 | + var id = api.keys.sync.id() | |
34 | + subscribed = api.channel.obs.subscribed(id) | |
35 | + var recentlyUpdated = api.channel.obs.recent() | |
36 | + var contacts = computed([subscribed, recentlyUpdated], function (a, b) { | |
37 | + var result = Array.from(a) | |
38 | + b.forEach((item, i) => { | |
39 | + if (!result.includes(item)) { | |
40 | + result.push(item) | |
41 | + } | |
42 | + }) | |
43 | + return result | |
44 | + }) | |
45 | + | |
46 | + suggestions = map(contacts, suggestion, {idle: true}) | |
47 | + watch(suggestions) | |
48 | + } | |
49 | + } | |
50 | + | |
51 | + function suggestion (id) { | |
52 | + return Struct({ | |
53 | + title: id, | |
54 | + id: `#${id}`, | |
55 | + subtitle: computed([id, subscribed], subscribedCaption), | |
56 | + value: computed([id], mention) | |
57 | + }) | |
58 | + } | |
59 | +} | |
60 | + | |
61 | +function subscribedCaption (id, subscribed) { | |
62 | + if (subscribed.has(id)) { | |
63 | + return 'subscribed' | |
64 | + } | |
65 | +} | |
66 | + | |
67 | +function mention (id) { | |
68 | + return `[#${id}](#${id})` | |
69 | +} | |
70 | + |
junk/next-stepper.js | ||
---|---|---|
@@ -1,0 +1,59 @@ | ||
1 | +const pull = require('pull-stream') | |
2 | +const Next = require('pull-next') | |
3 | + | |
4 | +module.exports = nextStepper | |
5 | + | |
6 | +// TODO - this should be another module? | |
7 | + | |
8 | +function nextStepper (createStream, opts, property, range) { | |
9 | + range = range || (opts.reverse ? 'lt' : 'gt') | |
10 | + property = property || 'timestamp' | |
11 | + | |
12 | + var last = null | |
13 | + var count = -1 | |
14 | + | |
15 | + return Next(function () { | |
16 | + if (last) { | |
17 | + if (count === 0) return | |
18 | + var value = opts[range] = get(last, property) | |
19 | + if (value == null) return | |
20 | + last = null | |
21 | + } | |
22 | + return pull( | |
23 | + createStream(clone(opts)), | |
24 | + pull.through(function (msg) { | |
25 | + count++ | |
26 | + if (!msg.sync) { | |
27 | + last = msg | |
28 | + } | |
29 | + }, function (err) { | |
30 | + // retry on errors... | |
31 | + if (err) { | |
32 | + count = -1 | |
33 | + return count | |
34 | + } | |
35 | + // end stream if there were no results | |
36 | + if (last == null) last = {} | |
37 | + }) | |
38 | + ) | |
39 | + }) | |
40 | +} | |
41 | + | |
42 | +function get (obj, path) { | |
43 | + if (!obj) return undefined | |
44 | + if (typeof path === 'string') return obj[path] | |
45 | + if (Array.isArray(path)) { | |
46 | + for (var i = 0; obj && i < path.length; i++) { | |
47 | + obj = obj[path[i]] | |
48 | + } | |
49 | + return obj | |
50 | + } | |
51 | +} | |
52 | + | |
53 | +function clone (obj) { | |
54 | + var _obj = {} | |
55 | + for (var k in obj) _obj[k] = obj[k] | |
56 | + return _obj | |
57 | +} | |
58 | + | |
59 | + |
message/html/compose.js | ||
---|---|---|
@@ -1,0 +1,147 @@ | ||
1 | +const { h, when, send, resolve, Value, computed } = require('mutant') | |
2 | +const nest = require('depnest') | |
3 | +const mentions = require('ssb-mentions') | |
4 | +const extend = require('xtend') | |
5 | +const addSuggest = require('suggest-box') | |
6 | + | |
7 | +exports.needs = nest({ | |
8 | + 'about.async.suggest': 'first', | |
9 | + 'blob.html.input': 'first', | |
10 | + 'channel.async.suggest': 'first', | |
11 | + 'emoji.sync': { | |
12 | + names: 'first', | |
13 | + url: 'first' | |
14 | + }, | |
15 | + 'message.async.publish': 'first' | |
16 | +}) | |
17 | + | |
18 | +exports.gives = nest('message.html.compose') | |
19 | + | |
20 | +exports.create = function (api) { | |
21 | + return nest('message.html.compose', compose) | |
22 | + | |
23 | + function compose ({ shrink = true, meta, prepublish, placeholder = 'Write a message' }, cb) { | |
24 | + var files = [] | |
25 | + var filesById = {} | |
26 | + var focused = Value(false) | |
27 | + var hasContent = Value(false) | |
28 | + var getProfileSuggestions = api.about.async.suggest() | |
29 | + var getChannelSuggestions = api.channel.async.suggest() | |
30 | + | |
31 | + var blurTimeout = null | |
32 | + | |
33 | + var expanded = computed([shrink, focused, hasContent], (shrink, focused, hasContent) => { | |
34 | + if (!shrink || hasContent) { | |
35 | + return true | |
36 | + } else { | |
37 | + return focused | |
38 | + } | |
39 | + }) | |
40 | + | |
41 | + var textArea = h('textarea', { | |
42 | + 'ev-input': function () { | |
43 | + hasContent.set(!!textArea.value) | |
44 | + }, | |
45 | + 'ev-blur': () => { | |
46 | + clearTimeout(blurTimeout) | |
47 | + blurTimeout = setTimeout(() => focused.set(false), 200) | |
48 | + }, | |
49 | + 'ev-focus': send(focused.set, true), | |
50 | + placeholder | |
51 | + }) | |
52 | + | |
53 | + var fileInput = api.blob.html.input(file => { | |
54 | + files.push(file) | |
55 | + filesById[file.link] = file | |
56 | + | |
57 | + var embed = file.type.indexOf('image/') === 0 ? '!' : '' | |
58 | + | |
59 | + textArea.value += embed + `[${file.name}](${file.link})` | |
60 | + console.log('added:', file) | |
61 | + }) | |
62 | + | |
63 | + fileInput.onclick = function () { | |
64 | + hasContent.set(true) | |
65 | + } | |
66 | + | |
67 | + var publishBtn = h('button', { 'ev-click': publish }, 'Publish') | |
68 | + | |
69 | + var actions = h('section.actions', [ | |
70 | + fileInput, | |
71 | + publishBtn | |
72 | + ]) | |
73 | + | |
74 | + var composer = h('Compose', { | |
75 | + classList: when(expanded, '-expanded', '-contracted') | |
76 | + }, [ | |
77 | + textArea, | |
78 | + actions | |
79 | + ]) | |
80 | + | |
81 | + addSuggest(textArea, (inputText, cb) => { | |
82 | + if (inputText[0] === '@') { | |
83 | + cb(null, getProfileSuggestions(inputText.slice(1))) | |
84 | + } else if (inputText[0] === '#') { | |
85 | + cb(null, getChannelSuggestions(inputText.slice(1))) | |
86 | + } else if (inputText[0] === ':') { | |
87 | + // suggest emojis | |
88 | + var word = inputText.slice(1) | |
89 | + if (word[word.length - 1] === ':') { | |
90 | + word = word.slice(0, -1) | |
91 | + } | |
92 | + // TODO: when no emoji typed, list some default ones | |
93 | + cb(null, api.emoji.sync.names().filter(function (name) { | |
94 | + return name.slice(0, word.length) === word | |
95 | + }).slice(0, 100).map(function (emoji) { | |
96 | + return { | |
97 | + image: api.emoji.sync.url(emoji), | |
98 | + title: emoji, | |
99 | + subtitle: emoji, | |
100 | + value: ':' + emoji + ':' | |
101 | + } | |
102 | + })) | |
103 | + } | |
104 | + }, {cls: 'SuggestBox'}) | |
105 | + | |
106 | + return composer | |
107 | + | |
108 | + // scoped | |
109 | + | |
110 | + function publish () { | |
111 | + publishBtn.disabled = true | |
112 | + | |
113 | + meta = extend(resolve(meta), { | |
114 | + text: textArea.value, | |
115 | + mentions: mentions(textArea.value).map(mention => { | |
116 | + // merge markdown-detected mention with file info | |
117 | + var file = filesById[mention.link] | |
118 | + if (file) { | |
119 | + if (file.type) mention.type = file.type | |
120 | + if (file.size) mention.size = file.size | |
121 | + } | |
122 | + return mention | |
123 | + }) | |
124 | + }) | |
125 | + | |
126 | + try { | |
127 | + if (typeof prepublish === 'function') { | |
128 | + meta = prepublish(meta) | |
129 | + } | |
130 | + } catch (err) { | |
131 | + publishBtn.disabled = false | |
132 | + if (cb) cb(err) | |
133 | + else throw err | |
134 | + } | |
135 | + | |
136 | + return api.message.async.publish(meta, done) | |
137 | + | |
138 | + function done (err, msg) { | |
139 | + publishBtn.disabled = false | |
140 | + if (err) throw err | |
141 | + else if (msg) textArea.value = '' | |
142 | + if (cb) cb(err, msg) | |
143 | + } | |
144 | + } | |
145 | + } | |
146 | +} | |
147 | + |
Built with git-ssb-web