on cffdf2b help sign in
webring/scripts/hallway.js
228 lines 8.9 kB history raw
1'use strict'
3const reChannel = /(\s|^)\/([a-zA-Z0-9]+)/g
4const reUser = /((\s|^)@<[a-zA-Z0-9./:\-_+~#= ]*>)/g
5const reTag = /(^|\s)(#[a-z\d-]+)/ig
6const reUrl = /((https?):\/\/(([-a-zA-Z0-9@:%._+~#=]{2,256}\.[a-z]{2,6}\b)([-a-zA-Z0-9@:%_+.~#?&//=]*)))/g
8function Hallway (sites) {
9 const feeds = {}
10 this.sites = sites
11 this._el = document.createElement('div')
12 this._el.id = 'hallway'
13 const filterAndPage = window.location.hash.split('^')
14 this.finder = { filter: stripHash(filterAndPage[0]), page: filterAndPage[1] || 1 }
15 this.cache = null
17 this._main = document.createElement('main')
18 this._main.id = 'entries'
19 this._footer = document.createElement('footer')
20 this._footer.id = 'footer'
21 this.readme = '<p>The <strong>Hallway</strong> is a decentralized forum, to join the conversation, add a <a href="https://github.com/XXIIVV/webring#joining-the-hallway">feed:</a> field to your entry in the <a href="https://github.com/XXIIVV/Webring/">webring</a>.</p>'
22 this.nav = '<p class="buttons"><a href="https://github.com/XXIIVV/webring">Information</a> | <a href="index.html">The Portal</a> | <a href="wiki.html">The Wiki</a> | <a id="opml">OPML</a></p>'
23 this._footer.innerHTML = `${this.readme}${this.nav}`
25 this.install = function (host) {
26 this._el.appendChild(this._main)
27 this._el.appendChild(this._footer)
28 host.appendChild(this._el)
29 this.findFeeds()
30 }
32 this.start = function () {
33 this._main.innerHTML = 'Loading..'
34 this.fetchFeeds()
35 }
37 this.refresh = function (feeds = this.cache) {
38 const entries = this.findEntries(feeds)
39 const localEntries = entries.filter(entry => this.filterExternals(entry))
40 const channels = this.find(localEntries, 'channel')
41 const users = this.find(localEntries, 'author')
42 const tags = this.findTags(localEntries)
43 const relevantEntries = localEntries
44 .filter(entry => !this.finder.filter || (entry.author === this.finder.filter || entry.channel === this.finder.filter || entry.tags.includes(this.finder.filter) || entry.body.includes(this.finder.filter)))
46 const mainHtml = `
47 <div><ul>
48 ${relevantEntries.filter((_, id) => id < Number(this.finder.page) * 20 && id >= (Number(this.finder.page) - 1) * 20).reduce((acc, val) => acc + this.templateEntry(val) + '\n', '')}
49 </ul>
50 <div id='pagination'>
51 ${[...Array(Math.ceil(relevantEntries.length / 20)).keys()].reduce((acc, num) => `${acc}<span class='${Number(hallway.finder.page) === num + 1 ? 'selected' : ''}' onclick='filter("${this.finder.filter}^${num + 1}")'>${num + 1}</span>`, '')}
52 </div>
53 <a id='showbar' onclick="toggleVisibility('aside');"></a></div>`
55 const aside = `
56 <aside id='aside'>
57 <a id='hidebar' onclick="toggleVisibility('aside');"></a>
58 <ul id='channels'>
59 <li onclick='filter("")' class='${hallway.finder.filter === '' ? 'selected' : ''}'><a href='#'>hallway <span class='right'>${localEntries.length}</span></a></li>
60 ${Object.keys(channels).slice(0, 15).reduce((acc, val) => acc + `<li onclick='filter("${val}")' class='${hallway.finder.filter === val ? 'selected' : ''}'><a href='#${val}'>${val} <span class='right'>${channels[val]}</span></a></li>\n`, '')}
61 </ul>
62 <ul id='users'>
63 ${Object.keys(users).slice(0, 15).reduce((acc, val) => acc + `<li onclick='filter("${val}")' class='${hallway.finder.filter === val ? 'selected' : ''}' href='#${val}'>${val} <span class='right'>${users[val]}</span></li>\n`, '')}
64 </ul>
65 <ul id='tags'>
66 ${Object.keys(tags).slice(0, 15).reduce((acc, val) => acc + `<li onclick='filter("${val}")' class='${hallway.finder.filter === val ? 'selected' : ''}' href='#${val}'>#${val} <span class='right'>${tags[val]}</span></li>\n`, '')}
67 </ul>
68 </aside>`
70 this._main.innerHTML = `${mainHtml}${aside}`
72 if (feeds) {
73 this.cache = feeds
74 }
75 }
77 // Entries
79 this.filterExternals = function (entry) {
80 const matches = entry.body.match(reUser)
81 const locals = Object.keys(feeds)
82 return matches ? matches.some(match => locals.some(local => match.indexOf(feeds[local].path) !== -1)) : true
83 }
85 this.find = function (entries, key) {
86 const h = {}
87 for (const entry of entries) {
88 if (entry && entry[key]) {
89 h[entry[key]] = h[entry[key]] ? h[entry[key]] + 1 : 1
90 }
91 }
92 return h
93 }
95 this.findTags = function (entries) {
96 const tags = {}
97 for (const entry of entries) {
98 entry.tags.map(tag => {
99 tags[tag] = tags[tag] ? tags[tag] + 1 : 1
100 })
101 }
102 return tags
103 }
105 this.findEntries = function (feeds) {
106 const a = []
107 for (const id in feeds) {
108 for (const i in feeds[id].content) {
109 a.push(feeds[id].content[i])
110 }
111 }
112 return a.sort((a, b) => a.offset - b.offset)
113 }
115 this.findMention = function (found) {
116 const mention = Object.keys(feeds).filter(user => found.indexOf(feeds[user].path) > -1)
117 return ` <span class='user local'>${mention[0]}</span>`
118 }
120 this.templateEntry = function (entry) {
121 entry.html = entry.body
122 .replace(reChannel, '$1<span class="channel">/$2</span>')
123 .replace(reUser, this.findMention)
124 .replace(reTag, '$1<span class="tag">$2</span>')
125 .replace(reUrl, '<a target="_blank" href="$1">$1</a>')
127 const filter = window.location.hash.substr(1).replace(/\+/g, ' ').toLowerCase()
128 const highlight = filter === entry.author.toLowerCase()
129 const origin = feeds[entry.author].path
131 return `<li class='entry ${highlight ? 'highlight' : ''}'><span class='date'>${timeAgo(Date.parse(entry.date))}</span> <a class='author' href='${origin}' target='_blank'>${entry.author}</a> <span class='body'>${entry.html}</span></li>`
132 }
134 // Feeds
136 this.findFeeds = function () {
137 console.log('Finding feeds..')
138 for (const site of sites) {
139 if (site.feed && site.author) {
140 feeds[site.author] = { path: site.feed }
141 }
142 }
143 console.log(`Found ${Object.keys(feeds).length} feeds.`)
144 }
146 this.fetchFeeds = function () {
147 console.log(`Fetching ${Object.keys(feeds).length} feeds..`)
148 for (const id in feeds) {
149 this.fetchFeed(id, feeds[id])
150 }
151 }
153 this.fetchFeed = function (id, feed) {
154 console.log(`Fetching ${id}(${feed.path})..`)
155 fetch(feed.path, { cache: 'no-store' }).then(x => x.text()).then((content) => {
156 feeds[id].content = parseFeed(id, content)
157 this.refresh(feeds)
158 }).catch((err) => {
159 console.warn(`${id}`, err)
160 })
161 }
163 // Utils
165 function parseFeed (author, feed) {
166 const lines = feed.split('\n').filter((line) => line.substr(0, 1) !== '#')
167 const entries = []
168 for (const id in lines) {
169 const line = lines[id].trim()
170 if (line === '') { continue }
171 const parts = line.replace(' ', '\t').split('\t')
172 const date = parts[0].trim()
173 const body = escapeHtml(parts[1].trim()).trim()
174 const channel = body.substr(0, 1) === '/' ? body.split(' ')[0].substr(1).toLowerCase() : body.substr(0, 1) === '@' ? 'veranda' : 'lobby'
175 const tags = (body.match(reTag) || []).map(a => a.substr(a.indexOf('#') + 1).toLowerCase())
176 const offset = new Date() - new Date(date)
177 entries.push({ date, body, author, offset, channel, tags })
178 }
179 return entries
180 }
182 function timeAgo (dateParam) {
183 const date = typeof dateParam === 'object' ? dateParam : new Date(dateParam)
184 const today = new Date()
185 const yesterday = new Date(today - 86400000)
186 const seconds = Math.round((today - date) / 1000)
187 const minutes = Math.round(seconds / 60)
188 const isToday = today.toDateString() === date.toDateString()
189 const isYesterday = yesterday.toDateString() === date.toDateString()
191 if (seconds < 5) {
192 return 'just now'
193 } else if (seconds < 60) {
194 return `${seconds} seconds ago`
195 } else if (seconds < 90) {
196 return 'a minute ago'
197 } else if (minutes < 60) {
198 return `${minutes} minutes ago`
199 } else if (isToday) {
200 return `${Math.floor(minutes / 60)} hours ago`
201 } else if (isYesterday) {
202 return 'yesterday'
203 }
205 return `${Math.floor(minutes / 1440)} days ago`
206 }
209function toggleVisibility (id) {
210 const e = document.getElementById(id)
211 if (e.style.display === 'block') { e.style.display = 'none' } else { e.style.display = 'block' }
214function escapeHtml (unsafe) {
215 return unsafe.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;')
218function stripHash (hash) {
219 const decoded = decodeURIComponent(hash)
220 return decoded.charAt(0) === '#' ? decoded.substring(1) : decoded
223function filter (name) {
224 window.location.hash = name
225 const filterAndPage = window.location.hash.split('^')
226 hallway.finder = { filter: stripHash(filterAndPage[0]), page: filterAndPage[1] || 1 }
227 hallway.refresh()