// Kopher UI logic function saveBlob(blob, fname, successCb, errorCb) { var storage = navigator.getDeviceStorage(blob.type.startsWith('image/') ? 'pictures' : 'sdcard'), freeSpaceReq = storage.freeSpace() freeSpaceReq.onsuccess = function() { var freeSize = freeSpaceReq.result if(freeSize > blob.size) { var req = storage.addNamed(blob, fname) req.onsuccess = successCb req.onerror = errorCb } else errorCb(new Error('No free space available')) } freeSpaceReq.onerror = errorCb } function hi2gopher(hiurl) { if(!hiurl.startsWith('hi:')) return hiurl var parts = hiurl.split(':')[1].split('|') return 'gopher://'+parts[2]+':'+parts[3]+'/'+parts[0]+parts[1] } function openURL(url, successCb, errorCb) { if(url.startsWith('about:')) { // handle about: pages first var pageName = url.split(':')[1], allowedPageNames = ['kopher', 'blank', 'help'] if(allowedPageNames.indexOf(pageName) > -1) { if(pageName === 'blank') successCb({content: '', serviceMsg: ''}) else { var req = new XMLHttpRequest() req.addEventListener('load', function() { successCb({content: Hi01379.render(req.responseText), serviceMsg: ''}) }) req.open('GET', '/pages/' + pageName + '.txt') req.send() } } return } // then handle everything else if(url.startsWith('gopher://')) { // convert the external URL format to the internal one var parts = new URL(url.replace('gopher://', 'http://')) var type = parts.pathname[2] || '1', sel = parts.pathname.substr(2) url = 'hi:' + [type, sel, parts.hostname, (parts.port|0) || 70].join('|') } if(url.startsWith('hi:')) { // Internal Gopher URL format var resource = url.split(':').slice(1).join(':').split('|'), input = null if(resource.length > 4) input = resource[4] Hi01379.load(resource, input, function(res) { if(res.content && res.content instanceof Blob) { // handle the download here if(res.contentType.startsWith('image/') && confirm('View the image? (press Cancel to save it)')) { // try to open it in an external image viewer window.open(URL.createObjectURL(res.content)) successCb({content: null, serviceMsg: 'Viewed ' + res.contentName, updateAddr: false}) } else // proceed with storing the file saveBlob(res.content, res.contentName, function() { successCb({content: null, serviceMsg: 'Saved ' + res.contentName, updateAddr: false}) navigator.mozNotification.createNotification('File saved', res.contentName).show() }, errorCb) } else successCb(res) // proceed to UI with non-downloads }, errorCb) } else { // handle non-Gopher URLs with the Web Activities var opener = new MozActivity({name: 'view', data: {type: 'url', url: url}}) successCb({content: null, serviceMsg: 'Opening an external resource ' + url, updateAddr: false}) } } // App entry point addEventListener('DOMContentLoaded', function() { var keycmd = '', currentUrl = '', addrBar = document.querySelector('header div.addr'), logoStatus = document.querySelector('div.logo'), logoDefaultChar = '\u{1f310}', logoLoadingChar = '\u23f3', contentZone = document.querySelector('main div.content'), statusBar = document.querySelector('footer div.status'), themeKey = 'uitheme', bookmarkKey = 'bookmarks', bkNumber, history = [], historyIndex = 0, linkIndex = 0, verticalScrollStep = 40, horizontalScrollStep = 20, buf = '', emptyBk = '["","","","","","","","","","",""]' function getTheme() { return (localStorage.getItem(themeKey) == 'dark') ? 'dark' : 'light' } function updateTheme() { document.body.dataset.theme = getTheme() } function toggleTheme() { var theme = getTheme() localStorage.setItem(themeKey, (theme == 'dark') ? 'light' : 'dark') updateTheme() } function setBookmark(index, url) { // update a bookmark or a homepage (which is just bookmark #0) var bmArray = JSON.parse(localStorage.getItem(bookmarkKey) || emptyBk) bmArray[index] = url localStorage.setItem(bookmarkKey, JSON.stringify(bmArray)) } function getBookmark(index) { //retrieve a bookmark or a homepage var bmArray = JSON.parse(localStorage.getItem(bookmarkKey) || emptyBk) return bmArray[index] } var activeContent={'prop': 'textContent', 'nowrap': '', 'wrap': ''} // placeholder to hold the wrapped and non-wrapped content function loadURL(url, noNavUpdate) { // wrapper to openURL that actually updates all the UI and history logoStatus.textContent = logoLoadingChar statusBar.textContent = 'Loading ' + hi2gopher(url) openURL(url, function(res) { // success callback logoStatus.textContent = logoDefaultChar statusBar.textContent = '' var updateHistory = !('updateAddr' in res && res.updateAddr === false), // true by default updateContent = !(res.content === null) // true by default if(updateContent) { // update the content zone activeContent.nowrap = res.content activeContent.wrap = res.contentWf || res.content contentZone.innerHTML = '' contentZone.textContent = '' contentZone.dataset.wrap = 0 // reset line wrapping if(res.contentType === 'text/plain') { activeContent.prop = 'textContent' contentZone.dataset.format='plain' contentZone.textContent = activeContent.nowrap } else { contentZone.dataset.format='native' activeContent.prop='innerHTML' contentZone.innerHTML = res.content var contentLinks = contentZone.querySelectorAll('a'), l = contentLinks.length, i for(i=0;i= l) nextIndex = 0 var nextLink = contentLinks[nextIndex] curLink.dataset.focused = 0 nextLink.dataset.focused = 1 // try to center the link contentZone.scrollTop = nextLink.offsetTop - (contentZone.offsetHeight >> 1) + (nextLink.offsetHeight >> 1) } } function clickSelectedLink() { var curLink = contentZone.querySelector('a[data-focused="1"]') if(curLink) { var input = null, href = decodeURI(curLink.href) if(href.startsWith('hi:7')) { // internal format href = href.split(':')[1].split('|') // type|sel|host|port input = encodeURIComponent(prompt('Input data for ' + href[2])) href.push(input) href = 'hi:'+href.join('|') } loadURL(href) } } function execKeyCmd(cmd) { //cmd can be a digit, digit combo, or key symbolic name switch(cmd) { case 'ArrowUp': // scroll up contentZone.scrollTop -= verticalScrollStep break case 'ArrowDown': // scroll down contentZone.scrollTop += verticalScrollStep break case 'ArrowLeft': // scroll left contentZone.scrollLeft -= horizontalScrollStep break case 'ArrowRight': // scroll right contentZone.scrollLeft += horizontalScrollStep break case 'SoftLeft': // go to the address (no relative URLs in this case) buf = prompt('Enter address').trim() if(buf) { // only proceed if the address was entered if(buf.indexOf('://') == -1) buf = 'gopher://' + buf loadURL(buf) } break case 'Enter': // click on the current link clickSelectedLink() break case '4': // toggle the UI theme toggleTheme() break case '6': // toggle line wrapping contentZone.dataset.wrap = 1 - parseInt(contentZone.dataset.wrap) if(activeContent.wrap != activeContent.nowrap) // only update contentZone if they differ contentZone[activeContent.prop] = activeContent[parseInt(contentZone.dataset.wrap) ? 'wrap' : 'nowrap'] break case 'Call': // refresh loadURL(currentUrl, true) break case '1': // back case 'SoftRight': historyIndex-- if(historyIndex < 0) //we're already at the beginning, do nothing historyIndex = 0 else loadURL(history[historyIndex], true) break case '3': // forward historyIndex++ if(historyIndex >= history.length) //we're already at the end, do nothing historyIndex = history.length - 1 else loadURL(history[historyIndex], true) break case '2': // jump to previous link focusLink(-1) break case '5': // jump to next link focusLink(1) break case '7': // hor.scroll to the start contentZone.scrollLeft = 0 break case '8': // vert.scroll to the start contentZone.scrollTop = 0 break case '9': // hor.scroll to the end contentZone.scrollLeft = contentZone.scrollLeftMax // FF only break case '0': // vert.scroll to the end contentZone.scrollTop = contentZone.scrollTopMax // FF only break case '*1': // save bookmarks case '*2': case '*3': case '*4': case '*5': case '*6': case '*7': case '*8': case '*9': case '*0': bkNumber = cmd[1]|0 if(!bkNumber) bkNumber = 10 if(currentUrl && confirm('Save current URL to the bookmark #'+bkNumber+'?')) setBookmark(bkNumber, currentUrl) break case '**': // save homepage if(currentUrl && confirm('Save current URL as the homepage?')) setBookmark(0, currentUrl) break case '*Call': // share current URL as text in messages/email or via Bluetooth in vCard format var hgoph = hi2gopher(currentUrl), htype = 'text/vcard', btext = 'BEGIN:VCARD\r\nVERSION:2.1\r\nN:'+hgoph+'\r\nURL:'+hgoph+'\r\nEND:VCARD', hblob = new Blob([btext], {type: htype}), hname = hgoph.replace(/[\/:]+/g, '_') + '.vcf' hblob.name = hname new MozActivity({ name: 'share', data: { type: 'url', number: 1, url: hgoph, blobs: [hblob], filenames: [hname] } }) break case '#1': // load bookmark case '#2': case '#3': case '#4': case '#5': case '#6': case '#7': case '#8': case '#9': case '#0': bkNumber = cmd[1]|0 if(!bkNumber) bkNumber = 10 buf = getBookmark(bkNumber) if(buf) loadURL(buf) else alert('No bookmark available at #'+bkNumber) break case '#*': // load homepage buf = getBookmark(0) if(buf) loadURL(buf) else loadURL('about:kopher') break case '##': // version info navigator.mozApps.getSelf().then(function(app) { var mfst = app.manifest var aboutText = mfst.name + ' v' + mfst.version + ' by ' + mfst.developer.name aboutText += '\nPage: ' + hi2gopher(currentUrl) alert(aboutText) }) break } } // setup the input addEventListener('keydown', function(e) { var k = e.key if(keycmd.length > 0) { // command is already buffering execKeyCmd(keycmd+k) keycmd = '' } else if(k === '*' || k === '#') // start buffering keycmd = k else // single-key command execKeyCmd(k) }, false) //setup the theme updateTheme() // load the default homepage var hp = getBookmark(0) loadURL(hp || 'about:kopher') navigator.mozSetMessageHandler('activity', function(req) { var act = req.source if(act.name === 'view' && act.data.type === 'url' && act.data.url) loadURL((''+act.data.url) || 'about:kopher') }) }, false)