app.js (13244B)
1 // Kopher UI logic 2 3 function saveBlob(blob, fname, successCb, errorCb) { 4 var storage = navigator.getDeviceStorage(blob.type.startsWith('image/') ? 'pictures' : 'sdcard'), 5 freeSpaceReq = storage.freeSpace() 6 freeSpaceReq.onsuccess = function() { 7 var freeSize = freeSpaceReq.result 8 if(freeSize > blob.size) { 9 var req = storage.addNamed(blob, fname) 10 req.onsuccess = successCb 11 req.onerror = errorCb 12 } 13 else errorCb(new Error('No free space available')) 14 } 15 freeSpaceReq.onerror = errorCb 16 } 17 18 function hi2gopher(hiurl) { 19 if(!hiurl.startsWith('hi:')) return hiurl 20 var parts = hiurl.split(':')[1].split('|') 21 return 'gopher://'+parts[2]+':'+parts[3]+'/'+parts[0]+parts[1] 22 } 23 24 function openURL(url, successCb, errorCb) { 25 if(url.startsWith('about:')) { // handle about: pages first 26 var pageName = url.split(':')[1], allowedPageNames = ['kopher', 'blank', 'help'] 27 if(allowedPageNames.indexOf(pageName) > -1) { 28 if(pageName === 'blank') 29 successCb({content: '', serviceMsg: ''}) 30 else { 31 var req = new XMLHttpRequest() 32 req.addEventListener('load', function() { 33 successCb({content: Hi01379.render(req.responseText), serviceMsg: ''}) 34 }) 35 req.open('GET', '/pages/' + pageName + '.txt') 36 req.send() 37 } 38 } 39 return 40 } // then handle everything else 41 if(url.startsWith('gopher://')) { // convert the external URL format to the internal one 42 var parts = new URL(url.replace('gopher://', 'http://')) 43 var type = parts.pathname[2] || '1', sel = parts.pathname.substr(2) 44 url = 'hi:' + [type, sel, parts.hostname, (parts.port|0) || 70].join('|') 45 } 46 if(url.startsWith('hi:')) { // Internal Gopher URL format 47 var resource = url.split(':').slice(1).join(':').split('|'), input = null 48 if(resource.length > 4) input = resource[4] 49 Hi01379.load(resource, input, function(res) { 50 if(res.content && res.content instanceof Blob) { // handle the download here 51 if(res.contentType.startsWith('image/') && confirm('View the image? (press Cancel to save it)')) { // try to open it in an external image viewer 52 window.open(URL.createObjectURL(res.content)) 53 successCb({content: null, serviceMsg: 'Viewed ' + res.contentName, updateAddr: false}) 54 } else // proceed with storing the file 55 saveBlob(res.content, res.contentName, function() { 56 successCb({content: null, serviceMsg: 'Saved ' + res.contentName, updateAddr: false}) 57 navigator.mozNotification.createNotification('File saved', res.contentName).show() 58 }, errorCb) 59 } 60 else successCb(res) // proceed to UI with non-downloads 61 }, errorCb) 62 } 63 else { // handle non-Gopher URLs with the Web Activities 64 var opener = new MozActivity({name: 'view', data: {type: 'url', url: url}}) 65 successCb({content: null, serviceMsg: 'Opening an external resource ' + url, updateAddr: false}) 66 } 67 } 68 69 // App entry point 70 addEventListener('DOMContentLoaded', function() { 71 var keycmd = '', currentUrl = '', 72 addrBar = document.querySelector('header div.addr'), 73 logoStatus = document.querySelector('div.logo'), 74 logoDefaultChar = '\u{1f310}', 75 logoLoadingChar = '\u23f3', 76 contentZone = document.querySelector('main div.content'), 77 statusBar = document.querySelector('footer div.status'), 78 themeKey = 'uitheme', bookmarkKey = 'bookmarks', bkNumber, 79 history = [], historyIndex = 0, linkIndex = 0, 80 verticalScrollStep = 40, horizontalScrollStep = 20, 81 buf = '', emptyBk = '["","","","","","","","","","",""]' 82 83 function getTheme() { 84 return (localStorage.getItem(themeKey) == 'dark') ? 'dark' : 'light' 85 } 86 87 function updateTheme() { 88 document.body.dataset.theme = getTheme() 89 } 90 91 function toggleTheme() { 92 var theme = getTheme() 93 localStorage.setItem(themeKey, (theme == 'dark') ? 'light' : 'dark') 94 updateTheme() 95 } 96 97 function setBookmark(index, url) { // update a bookmark or a homepage (which is just bookmark #0) 98 var bmArray = JSON.parse(localStorage.getItem(bookmarkKey) || emptyBk) 99 bmArray[index] = url 100 localStorage.setItem(bookmarkKey, JSON.stringify(bmArray)) 101 } 102 103 function getBookmark(index) { //retrieve a bookmark or a homepage 104 var bmArray = JSON.parse(localStorage.getItem(bookmarkKey) || emptyBk) 105 return bmArray[index] 106 } 107 108 var activeContent={'prop': 'textContent', 'nowrap': '', 'wrap': ''} // placeholder to hold the wrapped and non-wrapped content 109 110 function loadURL(url, noNavUpdate) { // wrapper to openURL that actually updates all the UI and history 111 logoStatus.textContent = logoLoadingChar 112 statusBar.textContent = 'Loading ' + hi2gopher(url) 113 openURL(url, function(res) { // success callback 114 logoStatus.textContent = logoDefaultChar 115 statusBar.textContent = '' 116 var updateHistory = !('updateAddr' in res && res.updateAddr === false), // true by default 117 updateContent = !(res.content === null) // true by default 118 if(updateContent) { // update the content zone 119 activeContent.nowrap = res.content 120 activeContent.wrap = res.contentWf || res.content 121 contentZone.innerHTML = '' 122 contentZone.textContent = '' 123 contentZone.dataset.wrap = 0 // reset line wrapping 124 if(res.contentType === 'text/plain') { 125 activeContent.prop = 'textContent' 126 contentZone.dataset.format='plain' 127 contentZone.textContent = activeContent.nowrap 128 } else { 129 contentZone.dataset.format='native' 130 activeContent.prop='innerHTML' 131 contentZone.innerHTML = res.content 132 var contentLinks = contentZone.querySelectorAll('a'), l = contentLinks.length, i 133 for(i=0;i<l;i++) { // dynamically index all the links in the rendered content 134 contentLinks[i].dataset.index = i 135 contentLinks[i].dataset.focused = (i === 0) ? 1 : 0 // automatically focus the first link 136 } 137 } 138 contentZone.scrollTop = 0 // reset vert. scrolling 139 contentZone.scrollLeft = 0 // reset horiz. scrolling 140 } 141 if(updateHistory && !noNavUpdate) { // first, trim the history to the current history index, then push the new URL 142 history = history.slice(0, historyIndex+1) 143 history.push(url) 144 historyIndex = history.length - 1 145 currentUrl = url 146 } 147 if(url.startsWith('about:')) 148 addrBar.textContent = url 149 else 150 addrBar.textContent = url.startsWith('hi:') ? url.split(':')[1].split('|')[2] : url.split('/')[2] // display the hostname 151 statusBar.textContent = res.serviceMsg // update statusbar 152 }, function(errObj) { // error callback 153 logoStatus.textContent = logoDefaultChar 154 statusBar.textContent = 'Error: ' + errObj.message 155 alert('Error: ' + errObj.message) 156 }) 157 } 158 159 function focusLink(linkDelta) { 160 var contentLinks = contentZone.querySelectorAll('a'), l = contentLinks.length 161 if(l) { // ignore the logic if there are no links 162 var curLink = contentZone.querySelector('a[data-focused="1"]'), 163 curIndex = curLink.dataset.index|0, 164 nextIndex = curIndex + linkDelta 165 if(nextIndex < 0) nextIndex = l - 1 166 else if(nextIndex >= l) nextIndex = 0 167 var nextLink = contentLinks[nextIndex] 168 curLink.dataset.focused = 0 169 nextLink.dataset.focused = 1 170 // try to center the link 171 contentZone.scrollTop = nextLink.offsetTop - (contentZone.offsetHeight >> 1) + (nextLink.offsetHeight >> 1) 172 } 173 } 174 175 function clickSelectedLink() { 176 var curLink = contentZone.querySelector('a[data-focused="1"]') 177 if(curLink) { 178 var input = null, href = decodeURI(curLink.href) 179 if(href.startsWith('hi:7')) { // internal format 180 href = href.split(':')[1].split('|') // type|sel|host|port 181 input = encodeURIComponent(prompt('Input data for ' + href[2])) 182 href.push(input) 183 href = 'hi:'+href.join('|') 184 } 185 loadURL(href) 186 } 187 } 188 189 function execKeyCmd(cmd) { //cmd can be a digit, digit combo, or key symbolic name 190 switch(cmd) { 191 case 'ArrowUp': // scroll up 192 contentZone.scrollTop -= verticalScrollStep 193 break 194 case 'ArrowDown': // scroll down 195 contentZone.scrollTop += verticalScrollStep 196 break 197 case 'ArrowLeft': // scroll left 198 contentZone.scrollLeft -= horizontalScrollStep 199 break 200 case 'ArrowRight': // scroll right 201 contentZone.scrollLeft += horizontalScrollStep 202 break 203 case 'SoftLeft': // go to the address (no relative URLs in this case) 204 buf = prompt('Enter address').trim() 205 if(buf) { // only proceed if the address was entered 206 if(buf.indexOf('://') == -1) buf = 'gopher://' + buf 207 loadURL(buf) 208 } 209 break 210 case 'Enter': // click on the current link 211 clickSelectedLink() 212 break 213 case '4': // toggle the UI theme 214 toggleTheme() 215 break 216 case '6': // toggle line wrapping 217 contentZone.dataset.wrap = 1 - parseInt(contentZone.dataset.wrap) 218 if(activeContent.wrap != activeContent.nowrap) // only update contentZone if they differ 219 contentZone[activeContent.prop] = activeContent[parseInt(contentZone.dataset.wrap) ? 'wrap' : 'nowrap'] 220 break 221 case 'Call': // refresh 222 loadURL(currentUrl, true) 223 break 224 case '1': // back 225 case 'SoftRight': 226 historyIndex-- 227 if(historyIndex < 0) //we're already at the beginning, do nothing 228 historyIndex = 0 229 else 230 loadURL(history[historyIndex], true) 231 break 232 case '3': // forward 233 historyIndex++ 234 if(historyIndex >= history.length) //we're already at the end, do nothing 235 historyIndex = history.length - 1 236 else 237 loadURL(history[historyIndex], true) 238 break 239 case '2': // jump to previous link 240 focusLink(-1) 241 break 242 case '5': // jump to next link 243 focusLink(1) 244 break 245 case '7': // hor.scroll to the start 246 contentZone.scrollLeft = 0 247 break 248 case '8': // vert.scroll to the start 249 contentZone.scrollTop = 0 250 break 251 case '9': // hor.scroll to the end 252 contentZone.scrollLeft = contentZone.scrollLeftMax // FF only 253 break 254 case '0': // vert.scroll to the end 255 contentZone.scrollTop = contentZone.scrollTopMax // FF only 256 break 257 case '*1': // save bookmarks 258 case '*2': 259 case '*3': 260 case '*4': 261 case '*5': 262 case '*6': 263 case '*7': 264 case '*8': 265 case '*9': 266 case '*0': 267 bkNumber = cmd[1]|0 268 if(!bkNumber) bkNumber = 10 269 if(currentUrl && confirm('Save current URL to the bookmark #'+bkNumber+'?')) 270 setBookmark(bkNumber, currentUrl) 271 break 272 case '**': // save homepage 273 if(currentUrl && confirm('Save current URL as the homepage?')) 274 setBookmark(0, currentUrl) 275 break 276 case '*Call': // share current URL as text in messages/email or via Bluetooth in vCard format 277 var hgoph = hi2gopher(currentUrl), htype = 'text/vcard', 278 btext = 'BEGIN:VCARD\r\nVERSION:2.1\r\nN:'+hgoph+'\r\nURL:'+hgoph+'\r\nEND:VCARD', 279 hblob = new Blob([btext], {type: htype}), 280 hname = hgoph.replace(/[\/:]+/g, '_') + '.vcf' 281 hblob.name = hname 282 new MozActivity({ 283 name: 'share', 284 data: { 285 type: 'url', 286 number: 1, 287 url: hgoph, 288 blobs: [hblob], 289 filenames: [hname] 290 } 291 }) 292 break 293 case '#1': // load bookmark 294 case '#2': 295 case '#3': 296 case '#4': 297 case '#5': 298 case '#6': 299 case '#7': 300 case '#8': 301 case '#9': 302 case '#0': 303 bkNumber = cmd[1]|0 304 if(!bkNumber) bkNumber = 10 305 buf = getBookmark(bkNumber) 306 if(buf) loadURL(buf) 307 else alert('No bookmark available at #'+bkNumber) 308 break 309 case '#*': // load homepage 310 buf = getBookmark(0) 311 if(buf) loadURL(buf) 312 else loadURL('about:kopher') 313 break 314 case '##': // version info 315 navigator.mozApps.getSelf().then(function(app) { 316 var mfst = app.manifest 317 var aboutText = mfst.name + ' v' + mfst.version + ' by ' + mfst.developer.name 318 aboutText += '\nPage: ' + hi2gopher(currentUrl) 319 alert(aboutText) 320 }) 321 break 322 } 323 } 324 325 // setup the input 326 addEventListener('keydown', function(e) { 327 var k = e.key 328 if(keycmd.length > 0) { // command is already buffering 329 execKeyCmd(keycmd+k) 330 keycmd = '' 331 } 332 else if(k === '*' || k === '#') // start buffering 333 keycmd = k 334 else // single-key command 335 execKeyCmd(k) 336 }, false) 337 338 //setup the theme 339 updateTheme() 340 341 // load the default homepage 342 var hp = getBookmark(0) 343 loadURL(hp || 'about:kopher') 344 345 navigator.mozSetMessageHandler('activity', function(req) { 346 var act = req.source 347 if(act.name === 'view' && act.data.type === 'url' && act.data.url) 348 loadURL((''+act.data.url) || 'about:kopher') 349 }) 350 }, false)