kopher

A simple Gopher client for KaiOS
git clone git://git.luxferre.top/kopher.git
Log | Files | Refs | README | LICENSE

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)