git ssb

16+

Dominic / patchbay



Commit c31e472556bdb4d5a6a192709bcff72ce36f324a

Merge branch 'master' of github.com:ssbc/patchbay into dark_crystal

mixmix committed on 8/2/2018, 4:43:49 AM
Parent: 2ca0297e908c4698d545ebf83acc9644cdef00f8
Parent: 061795bee1aa4edbfd8a050dc27d10cc020a8360

Files changed

app/page/calendar.jschanged
app/page/notifications.jschanged
app/page/private.jschanged
app/page/search.jschanged
index.jschanged
package-lock.jsonchanged
package.jsonchanged
app/page/calendar.jsView
@@ -1,20 +1,35 @@
11 const nest = require('depnest')
22 const { h, Array: MutantArray, map, Struct, computed, watch, throttle, resolve } = require('mutant')
3 +
34 const pull = require('pull-stream')
45 const { isMsg } = require('ssb-ref')
56
6-exports.gives = nest('app.page.calendar')
7 +exports.gives = nest({
8 + 'app.page.calendar': true,
9 + 'app.html.menuItem': true
10 +})
711
812 exports.needs = nest({
913 'message.html.render': 'first',
14 + 'app.sync.goTo': 'first',
1015 'sbot.async.get': 'first',
1116 'sbot.pull.stream': 'first'
1217 })
1318
1419 exports.create = (api) => {
15- return nest('app.page.calendar', calendarPage)
20 + return nest({
21 + 'app.html.menuItem': menuItem,
22 + 'app.page.calendar': calendarPage
23 + })
1624
25 + function menuItem () {
26 + return h('a', {
27 + style: { order: 1 },
28 + 'ev-click': () => api.app.sync.goTo({ page: 'calendar' })
29 + }, '/calendar')
30 + }
31 +
1732 function calendarPage (location) {
1833 const d = new Date()
1934 const state = Struct({
2035 today: new Date(d.getFullYear(), d.getMonth(), d.getDate()),
@@ -25,90 +40,122 @@
2540 lt: new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1)
2641 })
2742 })
2843
29- watch(state.year, year => getEvents(year, state.events))
44 + watch(state.year, year => getEvents(year, state.events, api))
3045
3146 const page = h('CalendarPage', { title: '/calendar' }, [
3247 Calendar(state),
33- Events(state)
48 + Events(state, api)
3449 ])
3550
36- page.scroll = i => {
37- const gte = resolve(state.range.gte)
38- state.range.gte.set(new Date(gte.getFullYear(), gte.getMonth(), gte.getDate() + i))
39- const lt = resolve(state.range.lt)
40- state.range.lt.set(new Date(lt.getFullYear(), lt.getMonth(), lt.getDate() + i))
41- }
51 + page.scroll = (i) => scroll(state.range, i)
4252
4353 return page
4454 }
55 +}
4556
46- function Events (state) {
47- return h('CalendarEvents', computed([state.events, state.range], (events, range) => {
48- const keys = events
49- .filter(ev => ev.date >= range.gte && ev.date < range.lt)
50- .sort((a, b) => a.date - b.date)
51- .map(ev => ev.data.key)
57 +function scroll (range, i) {
58 + const { gte, lt } = resolve(range)
5259
53- const gatherings = MutantArray([])
60 + if (isMonthInterval(gte, lt)) {
61 + range.gte.set(new Date(gte.getFullYear(), gte.getMonth() + i, gte.getDate()))
62 + range.lt.set(new Date(lt.getFullYear(), lt.getMonth() + i, lt.getDate()))
63 + return
64 + }
5465
55- pull(
56- pull.values(keys),
57- pull.asyncMap((key, cb) => {
58- api.sbot.async.get(key, (err, value) => {
59- if (err) return cb(err)
60- cb(null, {key, value})
61- })
62- }),
63- pull.drain(msg => gatherings.push(msg))
64- )
66 + if (isWeekInterval(gte, lt)) {
67 + range.gte.set(new Date(gte.getFullYear(), gte.getMonth(), gte.getDate() + 7 * i))
68 + range.lt.set(new Date(lt.getFullYear(), lt.getMonth(), lt.getDate() + 7 * i))
69 + return
70 + }
6571
66- return map(gatherings, g => api.message.html.render(g))
67- }))
72 + range.gte.set(new Date(gte.getFullYear(), gte.getMonth(), gte.getDate() + i))
73 + range.lt.set(new Date(lt.getFullYear(), lt.getMonth(), lt.getDate() + i))
74 +
75 + function isMonthInterval (gte, lt) {
76 + return gte.getDate() === 1 && // 1st of month
77 + lt.getDate() === 1 && // to the 1st of the month
78 + gte.getMonth() + 1 === lt.getMonth() && // one month gap
79 + gte.getFullYear() === lt.getFullYear()
6880 }
6981
70- function getEvents (year, events) {
71- const query = [{
72- $filter: {
73- value: {
74- timestamp: {$gt: Number(new Date(year, 0, 1))}, // ordered by published time
75- content: {
76- type: 'about',
77- startDateTime: {
78- epoch: {$gt: 0}
79- }
82 + function isWeekInterval (gte, lt) {
83 + console.log(
84 + new Date(gte.getFullYear(), gte.getMonth(), gte.getDate() + 7).toISOString() === lt.toISOString(),
85 + new Date(gte.getFullYear(), gte.getMonth(), gte.getDate() + 7).toISOString(),
86 + lt.toISOString(),
87 + )
88 + return gte.getDay() === 1 && // from monday
89 + lt.getDay() === 1 && // to just inside monday
90 + new Date(gte.getFullYear(), gte.getMonth(), gte.getDate() + 7).toISOString() === lt.toISOString()
91 + }
92 +}
93 +
94 +function Events (state, api) {
95 + return h('CalendarEvents', computed([state.events, state.range], (events, range) => {
96 + const keys = events
97 + .filter(ev => ev.date >= range.gte && ev.date < range.lt)
98 + .sort((a, b) => a.date - b.date)
99 + .map(ev => ev.data.key)
100 +
101 + const gatherings = MutantArray([])
102 +
103 + pull(
104 + pull.values(keys),
105 + pull.asyncMap((key, cb) => {
106 + api.sbot.async.get(key, (err, value) => {
107 + if (err) return cb(err)
108 + cb(null, {key, value})
109 + })
110 + }),
111 + pull.drain(msg => gatherings.push(msg))
112 + )
113 +
114 + return map(gatherings, g => api.message.html.render(g))
115 + }))
116 +}
117 +
118 +function getEvents (year, events, api) {
119 + const query = [{
120 + $filter: {
121 + value: {
122 + timestamp: {$gt: Number(new Date(year, 0, 1))}, // ordered by published time
123 + content: {
124 + type: 'about',
125 + startDateTime: {
126 + epoch: {$gt: 0}
80127 }
81128 }
82129 }
83- }, {
84- $map: {
85- key: ['value', 'content', 'about'], // gathering
86- date: ['value', 'content', 'startDateTime', 'epoch']
87- }
88- }]
89-
90- const opts = {
91- reverse: false,
92- live: true,
93- query
94130 }
131 + }, {
132 + $map: {
133 + key: ['value', 'content', 'about'], // gathering
134 + date: ['value', 'content', 'startDateTime', 'epoch']
135 + }
136 + }]
95137
96- pull(
97- api.sbot.pull.stream(server => server.query.read(opts)),
98- pull.filter(m => !m.sync),
99- pull.filter(r => isMsg(r.key) && Number.isInteger(r.date)),
100- pull.map(r => {
101- return { key: r.key, date: new Date(r.date) }
102- }),
103- pull.drain(({ key, date }) => {
104- var target = events.find(ev => ev.data.key === key)
105- if (target && target.date <= date) events.delete(target)
138 + const opts = {
139 + reverse: false,
140 + live: true,
141 + query
142 + }
106143
107- events.push({ date, data: { key } })
108- })
109- )
110- }
144 + pull(
145 + api.sbot.pull.stream(server => server.query.read(opts)),
146 + pull.filter(m => !m.sync),
147 + pull.filter(r => isMsg(r.key) && Number.isInteger(r.date)),
148 + pull.map(r => {
149 + return { key: r.key, date: new Date(r.date) }
150 + }),
151 + pull.drain(({ key, date }) => {
152 + var target = events.find(ev => ev.data.key === key)
153 + if (target && target.date <= date) events.delete(target)
154 +
155 + events.push({ date, data: { key } })
156 + })
157 + )
111158 }
112159
113160 // ////////////////// extract below into a module ///////////////////////
114161
@@ -121,31 +168,27 @@
121168 function Calendar (state) {
122169 // TODO assert events is an Array of object
123170 // of form { date, data }
124171
125- const setRange = state.range.set
172 + const { gte, lt } = state.range
126173
127174 return h('Calendar', [
128- Header(state.year),
175 + h('div.header', [
176 + h('div.year', [
177 + state.year,
178 + h('a', { 'ev-click': () => state.year.set(state.year() - 1) }, '-'),
179 + h('a', { 'ev-click': () => state.year.set(state.year() + 1) }, '+')
180 + ])
181 + ]),
129182 h('div.months', computed(throttle(state, 100), ({ today, year, events, range }) => {
130183 return MONTHS.map((month, monthIndex) => {
131- return Month({ month, monthIndex, today, year, events, range, setRange })
184 + return Month({ month, monthIndex, today, year, events, range, gte, lt })
132185 })
133186 }))
134187 ])
135188 }
136189
137-function Header (year) {
138- return h('div.header', [
139- h('div.year', [
140- year,
141- h('a', { 'ev-click': () => year.set(year() - 1) }, '-'),
142- h('a', { 'ev-click': () => year.set(year() + 1) }, '+')
143- ])
144- ])
145-}
146-
147-function Month ({ month, monthIndex, today, year, events, range, setRange }) {
190 +function Month ({ month, monthIndex, today, year, events, range, gte, lt }) {
148191 const monthLength = new Date(year, monthIndex + 1, 0).getDate()
149192 // NOTE Date takes month as a monthIndex i.e. april = 3
150193 // and day = 0 goes back a day
151194 const days = Array(monthLength).fill().map((_, i) => i + 1)
@@ -153,12 +196,12 @@
153196 var weekday
154197 var week
155198 var offset = getDay(new Date(year, monthIndex, 1)) - 1
156199
157- const setMonthRange = () => setRange({
158- gte: new Date(year, monthIndex, 1),
159- lt: new Date(year, monthIndex + 1, 1)
160- })
200 + const setMonthRange = (ev) => {
201 + gte.set(new Date(year, monthIndex, 1))
202 + lt.set(new Date(year, monthIndex + 1, 1))
203 + }
161204
162205 return h('CalendarMonth', [
163206 h('div.month-name', { 'ev-click': setMonthRange }, month.substr(0, 2)),
164207 h('div.days', { style: {display: 'grid'} }, [
@@ -190,19 +233,22 @@
190233 classList: [
191234 date < today ? '-past' : '-future',
192235 eventsOnDay.length ? '-events' : '',
193236 date >= range.gte && date < range.lt ? '-selected' : ''
194- ]
237 + ],
238 + 'ev-click': (ev) => {
239 + if (ev.shiftKey) {
240 + dateEnd >= resolve(lt) ? lt.set(dateEnd) : gte.set(date)
241 + return
242 + }
243 +
244 + gte.set(date)
245 + lt.set(dateEnd)
246 + }
195247 }
196248
197249 if (!eventsOnDay.length) return h('CalendarDay', opts)
198250
199- opts['ev-click'] = () => setRange({
200- gte: date,
201- lt: dateEnd
202- })
203- opts['ev-hover'] = () => console.log(date)
204-
205251 return h('CalendarDay', opts, [
206252 // TODO add awareness of whether I'm going to events
207253 // TODO try a FontAwesome circle
208254 h('div.dot', [
app/page/notifications.jsView
@@ -1,9 +1,9 @@
11 const nest = require('depnest')
22 const { h } = require('mutant')
33 const pull = require('pull-stream')
44 const Scroller = require('pull-scroll')
5-const next = require('pull-next-step')
5 +const next = require('pull-next-query')
66
77 exports.gives = nest({
88 'app.html.menuItem': true,
99 'app.page.notifications': true
@@ -12,12 +12,13 @@
1212 exports.needs = nest({
1313 'app.html.filter': 'first',
1414 'app.html.scroller': 'first',
1515 'app.sync.goTo': 'first',
16- 'feed.pull.mentions': 'first',
1716 'feed.pull.public': 'first',
1817 'keys.sync.id': 'first',
19- 'message.html.render': 'first'
18 + 'message.html.render': 'first',
19 + 'message.sync.isBlocked': 'first',
20 + 'sbot.pull.stream': 'first'
2021 })
2122
2223 exports.create = function (api) {
2324 return nest({
@@ -32,30 +33,22 @@
3233 }, '/notifications')
3334 }
3435
3536 function notificationsPage (location) {
36- const id = api.keys.sync.id()
37-
3837 const { filterMenu, filterDownThrough, filterUpThrough, resetFeed } = api.app.html.filter(draw)
3938 const { container, content } = api.app.html.scroller({ prepend: [ filterMenu ] })
40- const removeMyMessages = () => pull.filter(msg => msg.value.author !== id)
41- const removePrivateMessages = () => pull.filter(msg => msg.value.private !== true)
4239
4340 function draw () {
4441 resetFeed({ container, content })
4542
4643 pull(
47- next(api.feed.pull.mentions(id), {old: false, limit: 100, property: ['timestamp']}),
48- removeMyMessages(),
49- removePrivateMessages(),
44 + pullMentions({old: false, live: true}),
5045 filterDownThrough(),
5146 Scroller(container, content, api.message.html.render, true, false)
5247 )
5348
5449 pull(
55- next(api.feed.pull.mentions(id), {reverse: true, limit: 100, live: false, property: ['timestamp']}),
56- removeMyMessages(),
57- removePrivateMessages(),
50 + pullMentions({reverse: true, live: false}),
5851 filterUpThrough(),
5952 Scroller(container, content, api.message.html.render, false, false)
6053 )
6154 }
@@ -63,5 +56,32 @@
6356
6457 container.title = '/notifications'
6558 return container
6659 }
60 +
61 + // NOTE - this currently hits mentions AND the patchwork message replies
62 + function pullMentions (opts) {
63 + const query = [{
64 + $filter: {
65 + dest: api.keys.sync.id(),
66 + timestamp: {$gt: 0},
67 + value: {
68 + author: {$ne: api.keys.sync.id()}, // not my messages!
69 + private: {$ne: true} // not private mentions
70 + }
71 + }
72 + }]
73 +
74 + const _opts = Object.assign({
75 + query,
76 + limit: 100,
77 + index: 'DTA'
78 + }, opts)
79 +
80 + return api.sbot.pull.stream(server => {
81 + return pull(
82 + next(server.backlinks.read, _opts, ['timestamp']),
83 + pull.filter(m => !api.message.sync.isBlocked(m))
84 + )
85 + })
86 + }
6787 }
app/page/private.jsView
@@ -2,9 +2,9 @@
22 const { h } = require('mutant')
33 const pull = require('pull-stream')
44 const Scroller = require('pull-scroll')
55 const ref = require('ssb-ref')
6-const next = require('pull-next-step')
6 +const next = require('pull-next-query')
77
88 exports.gives = nest({
99 'app.html.menuItem': true,
1010 'app.page.private': true
@@ -56,15 +56,15 @@
5656 function draw () {
5757 resetFeed({ container, content })
5858
5959 pull(
60- next(api.feed.pull.private, {old: false, limit: 100}, ['value', 'timestamp']),
60 + pullPrivate({old: false, live: true}),
6161 filterDownThrough(),
6262 Scroller(container, content, api.message.html.render, true, false)
6363 )
6464
6565 pull(
66- next(api.feed.pull.private, {reverse: true, limit: 100, live: false}, ['value', 'timestamp']),
66 + pullPrivate({reverse: true}),
6767 filterUpThrough(),
6868 Scroller(container, content, api.message.html.render, false, false)
6969 )
7070 }
@@ -72,5 +72,23 @@
7272
7373 container.title = '/private'
7474 return container
7575 }
76 +
77 + function pullPrivate (opts) {
78 + const query = [{
79 + $filter: {
80 + timestamp: {$gt: 0},
81 + value: {
82 + content: {
83 + recps: {$truthy: true}
84 + }
85 + }
86 + }
87 + }]
88 +
89 + const _opts = Object.assign({ query, limit: 100 }, opts)
90 +
91 + return next(api.feed.pull.private, _opts, ['timestamp'])
92 + }
7693 }
94 +
app/page/search.jsView
@@ -1,8 +1,8 @@
11 const nest = require('depnest')
2-const { h, Struct, Value, when, computed } = require('mutant')
2 +const { h, Struct, Value } = require('mutant')
33 const pull = require('pull-stream')
4-const next = require('pull-next-step')
4 +const next = require('pull-next-query')
55 const Scroller = require('pull-scroll')
66 const TextNodeSearcher = require('text-node-searcher')
77
88 exports.gives = nest('app.page.search')
@@ -16,130 +16,42 @@
1616 })
1717
1818 var whitespace = /\s+/
1919
20-function andSearch (terms, inputs) {
21- for (var i = 0; i < terms.length; i++) {
22- var match = false
23- for (var j = 0; j < inputs.length; j++) {
24- if (terms[i].test(inputs[j])) match = true
25- }
26- // if a term was not matched by anything, filter this one
27- if (!match) return false
28- }
29- return true
30-}
31-
32-function searchFilter (terms) {
33- return function (msg) {
34- var c = msg && msg.value && msg.value.content
35- return c && (
36- msg.key === terms[0] ||
37- andSearch(terms.map(function (term) {
38- return new RegExp('\\b' + term + '\\b', 'i')
39- }), [c.text, c.name, c.title])
40- )
41- }
42-}
43-
44-function createOrRegExp (ary) {
45- return new RegExp(ary.map(function (e) {
46- return '\\b' + e + '\\b'
47- }).join('|'), 'i')
48-}
49-
50-function highlight (el, query) {
51- var searcher = new TextNodeSearcher({container: el})
52- searcher.query = query
53- searcher.highlight()
54- return el
55-}
56-
57-function fallback (createReader) {
58- var fallbackRead
59- return function (read) {
60- return function (abort, cb) {
61- read(abort, function next (end, data) {
62- if (end && createReader && (fallbackRead = createReader(end))) {
63- createReader = null
64- read = fallbackRead
65- read(abort, next)
66- } else {
67- cb(end, data)
68- }
69- })
70- }
71- }
72-}
73-
7420 exports.create = function (api) {
7521 return nest('app.page.search', searchPage)
7622
7723 function searchPage (location) {
7824 const query = location.query.trim()
7925
80- var queryTerms = query.split(whitespace)
81- var matchesQuery = searchFilter(queryTerms)
82-
8326 const search = Struct({
84- isLinear: Value(false),
85- linear: Struct({
86- checked: Value(0)
87- }),
8827 fulltext: Struct({
8928 isDone: Value(false)
9029 }),
9130 matches: Value(0)
9231 })
93- const hasNoFulltextMatches = computed([search.fulltext.isDone, search.matches],
94- (done, matches) => done && matches === 0)
9532
9633 const searchHeader = h('Search', [
97- h('header', h('h1', query)),
98- when(search.isLinear,
99- h('section.details', [
100- h('div.searched', ['Searched: ', search.linear.checked]),
101- h('div.matches', [search.matches, ' matches'])
102- ]),
103- h('section.details', [
104- h('div.searched'),
105- when(hasNoFulltextMatches, h('div.matches', 'No matches'))
106- ])
107- )
34 + h('header', h('h1', query))
10835 ])
109- const { filterMenu, filterDownThrough, filterUpThrough, resetFeed } = api.app.html.filter(draw)
36 + const { filterMenu, filterDownThrough, resetFeed } = api.app.html.filter(draw)
11037 const { container, content } = api.app.html.scroller({ prepend: [searchHeader, filterMenu] })
11138
11239 function renderMsg (msg) {
11340 var el = api.message.html.render(msg)
41 + var queryTerms = query.split(whitespace)
42 +
11443 highlight(el, createOrRegExp(queryTerms))
11544 return el
11645 }
11746
11847 function draw () {
11948 resetFeed({ container, content })
12049
50 + // TODO figure out how to step on kinda orderless search results
12151 pull(
122- api.sbot.pull.log({old: false}),
123- pull.filter(matchesQuery),
124- filterUpThrough(),
125- Scroller(container, content, renderMsg, true, false)
126- )
127-
128- pull(
129- api.sbot.pull.stream(sbot => next(sbot.search.query, { query, limit: 500 })),
130- fallback((err) => {
131- if (err === true) {
132- search.fulltext.isDone.set(true)
133- } else if (/^no source/.test(err.message)) {
134- search.isLinear.set(true)
135- return pull(
136- next(api.sbot.pull.log, {reverse: true, limit: 500, live: false}),
137- pull.through(() => search.linear.checked.set(search.linear.checked() + 1)),
138- pull.filter(matchesQuery)
139- )
140- }
141- }),
52 + // api.sbot.pull.stream(sbot => next(sbot.search.query, { query, limit: 500 })),
53 + api.sbot.pull.stream(sbot => sbot.search.query({ query, limit: 500 })),
14254 filterDownThrough(),
14355 pull.through(() => search.matches.set(search.matches() + 1)),
14456 Scroller(container, content, renderMsg, false, false)
14557 )
@@ -150,4 +62,18 @@
15062 container.title = '?' + query
15163 return container
15264 }
15365 }
66 +
67 +function createOrRegExp (ary) {
68 + return new RegExp(ary.map(function (e) {
69 + return '\\b' + e + '\\b'
70 + }).join('|'), 'i')
71 +}
72 +
73 +function highlight (el, query) {
74 + var searcher = new TextNodeSearcher({container: el})
75 + searcher.query = query
76 + searcher.highlight()
77 + return el
78 +}
79 +
index.jsView
@@ -134,9 +134,9 @@
134134 window.webContents.on('dom-ready', function () {
135135 window.webContents.executeJavaScript(`
136136 var electron = require('electron')
137137 var h = require('mutant/h')
138- electron.webFrame.setZoomLevelLimits(1, 1)
138 + electron.webFrame.setVisualZoomLevelLimits(1, 1)
139139 var title = ${JSON.stringify(opts.title || 'Patchbay')}
140140 document.documentElement.querySelector('head').appendChild(
141141 h('title', title)
142142 )
package-lock.jsonView
The diff is too large to show. Use a local git client to view these changes.
Old file size: 359968 bytes
New file size: 329995 bytes
package.jsonView
@@ -62,13 +62,13 @@
6262 "patch-context": "^2.0.1",
6363 "patch-drafts": "0.0.6",
6464 "patch-history": "^1.0.0",
6565 "patch-hub": "^1.1.0",
66- "patch-inbox": "^1.1.5",
66 + "patch-inbox": "^1.1.6",
6767 "patch-settings": "^1.1.2",
6868 "patch-suggest": "^2.0.2",
69- "patchbay-book": "^1.0.7",
7069 "patchbay-dark-crystal": "0.0.3",
70 + "patchbay-book": "^1.0.8",
7171 "patchbay-gatherings": "^2.0.2",
7272 "patchbay-poll": "^1.0.5",
7373 "patchcore": "^1.28.0",
7474 "pull-abortable": "^4.1.1",
@@ -83,30 +83,30 @@
8383 "setimmediate": "^1.0.5",
8484 "ssb-about": "^0.1.2",
8585 "ssb-backlinks": "^0.7.3",
8686 "ssb-blobs": "^1.1.5",
87- "ssb-chess": "^2.2.8",
87 + "ssb-chess": "^2.2.9",
8888 "ssb-chess-db": "^1.0.2",
8989 "ssb-client": "^4.5.7",
9090 "ssb-ebt": "^5.2.2",
9191 "ssb-friends": "^2.4.0",
9292 "ssb-horcrux": "^1.0.0",
9393 "ssb-keys": "^7.0.15",
9494 "ssb-meme": "^1.0.4",
95- "ssb-mentions": "^0.4.1",
95 + "ssb-mentions": "^0.5.0",
9696 "ssb-mutual": "^0.1.0",
9797 "ssb-private": "^0.2.2",
9898 "ssb-query": "^2.1.0",
9999 "ssb-search": "^1.1.2",
100100 "ssb-sort": "^1.1.0",
101- "ssb-ws": "^1.0.3",
102- "style-resolve": "^1.1.0",
101 + "ssb-ws": "^2.1.1",
102 + "style-resolve": "^1.0.1",
103103 "suggest-box": "^2.2.3",
104104 "text-node-searcher": "^1.1.1",
105105 "xtend": "^4.0.1"
106106 },
107107 "devDependencies": {
108108 "electro": "^2.1.1",
109- "electron": "^1.8.6",
109 + "electron": "^2.0.5",
110110 "standard": "^8.6.0"
111111 }
112112 }

Built with git-ssb-web