2023-03-25 16:51:49 +02:00
|
|
|
// Kopher UI logic
|
|
|
|
|
|
2023-03-26 15:44:43 +03:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2023-04-02 23:00:47 +03:00
|
|
|
function hi2gopher(hiurl) {
|
2023-04-03 13:16:08 +03:00
|
|
|
if(!hiurl.startsWith('hi:')) return hiurl
|
2023-04-02 23:00:47 +03:00
|
|
|
var parts = hiurl.split(':')[1].split('|')
|
|
|
|
|
return 'gopher://'+parts[2]+':'+parts[3]+'/'+parts[0]+parts[1]
|
|
|
|
|
}
|
|
|
|
|
|
2023-03-25 16:51:49 +02:00
|
|
|
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
|
2023-03-26 15:44:43 +03:00
|
|
|
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)
|
2023-03-25 16:51:49 +02:00
|
|
|
}
|
|
|
|
|
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}})
|
2023-03-25 18:52:38 +02:00
|
|
|
successCb({content: null, serviceMsg: 'Opening an external resource ' + url, updateAddr: false})
|
2023-03-25 16:51:49 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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,
|
2023-04-03 20:03:08 +03:00
|
|
|
verticalScrollStep = 40, horizontalScrollStep = 20,
|
2023-03-25 16:51:49 +02:00
|
|
|
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]
|
|
|
|
|
}
|
|
|
|
|
|
2023-04-01 15:08:55 +03:00
|
|
|
var activeContent={'prop': 'textContent', 'nowrap': '', 'wrap': ''} // placeholder to hold the wrapped and non-wrapped content
|
|
|
|
|
|
2023-03-25 16:51:49 +02:00
|
|
|
function loadURL(url, noNavUpdate) { // wrapper to openURL that actually updates all the UI and history
|
|
|
|
|
logoStatus.textContent = logoLoadingChar
|
2023-04-03 13:16:08 +03:00
|
|
|
statusBar.textContent = 'Loading ' + hi2gopher(url)
|
2023-03-25 16:51:49 +02:00
|
|
|
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
|
2023-04-01 15:08:55 +03:00
|
|
|
activeContent.nowrap = res.content
|
|
|
|
|
activeContent.wrap = res.contentWf || res.content
|
2023-03-25 16:51:49 +02:00
|
|
|
contentZone.innerHTML = ''
|
|
|
|
|
contentZone.textContent = ''
|
|
|
|
|
contentZone.dataset.wrap = 0 // reset line wrapping
|
|
|
|
|
if(res.contentType === 'text/plain') {
|
2023-04-01 15:08:55 +03:00
|
|
|
activeContent.prop = 'textContent'
|
2023-03-25 16:51:49 +02:00
|
|
|
contentZone.dataset.format='plain'
|
2023-04-01 15:08:55 +03:00
|
|
|
contentZone.textContent = activeContent.nowrap
|
2023-03-25 16:51:49 +02:00
|
|
|
} else {
|
|
|
|
|
contentZone.dataset.format='native'
|
2023-04-01 15:08:55 +03:00
|
|
|
activeContent.prop='innerHTML'
|
2023-03-25 16:51:49 +02:00
|
|
|
contentZone.innerHTML = res.content
|
|
|
|
|
var contentLinks = contentZone.querySelectorAll('a'), l = contentLinks.length, i
|
|
|
|
|
for(i=0;i<l;i++) { // dynamically index all the links in the rendered content
|
|
|
|
|
contentLinks[i].dataset.index = i
|
|
|
|
|
contentLinks[i].dataset.focused = (i === 0) ? 1 : 0 // automatically focus the first link
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
contentZone.scrollTop = 0 // reset vert. scrolling
|
|
|
|
|
contentZone.scrollLeft = 0 // reset horiz. scrolling
|
|
|
|
|
}
|
|
|
|
|
if(updateHistory && !noNavUpdate) { // first, trim the history to the current history index, then push the new URL
|
|
|
|
|
history = history.slice(0, historyIndex+1)
|
|
|
|
|
history.push(url)
|
|
|
|
|
historyIndex = history.length - 1
|
|
|
|
|
currentUrl = url
|
|
|
|
|
}
|
|
|
|
|
if(url.startsWith('about:'))
|
|
|
|
|
addrBar.textContent = url
|
|
|
|
|
else
|
|
|
|
|
addrBar.textContent = url.startsWith('hi:') ? url.split(':')[1].split('|')[2] : url.split('/')[2] // display the hostname
|
|
|
|
|
statusBar.textContent = res.serviceMsg // update statusbar
|
|
|
|
|
}, function(errObj) { // error callback
|
|
|
|
|
logoStatus.textContent = logoDefaultChar
|
|
|
|
|
statusBar.textContent = 'Error: ' + errObj.message
|
|
|
|
|
alert('Error: ' + errObj.message)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function focusLink(linkDelta) {
|
|
|
|
|
var contentLinks = contentZone.querySelectorAll('a'), l = contentLinks.length
|
|
|
|
|
if(l) { // ignore the logic if there are no links
|
|
|
|
|
var curLink = contentZone.querySelector('a[data-focused="1"]'),
|
|
|
|
|
curIndex = curLink.dataset.index|0,
|
|
|
|
|
nextIndex = curIndex + linkDelta
|
|
|
|
|
if(nextIndex < 0) nextIndex = l - 1
|
|
|
|
|
else if(nextIndex >= 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)
|
2023-04-01 15:08:55 +03:00
|
|
|
if(activeContent.wrap != activeContent.nowrap) // only update contentZone if they differ
|
|
|
|
|
contentZone[activeContent.prop] = activeContent[parseInt(contentZone.dataset.wrap) ? 'wrap' : 'nowrap']
|
2023-03-25 16:51:49 +02:00
|
|
|
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
|
2023-04-02 23:00:47 +03:00
|
|
|
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
|
2023-03-25 16:51:49 +02:00
|
|
|
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
|
2023-04-03 13:16:08 +03:00
|
|
|
aboutText += '\nPage: ' + hi2gopher(currentUrl)
|
2023-03-25 16:51:49 +02:00
|
|
|
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()
|
|
|
|
|
|
2023-04-03 20:03:08 +03:00
|
|
|
// load the default homepage
|
|
|
|
|
var hp = getBookmark(0)
|
|
|
|
|
loadURL(hp || 'about:kopher')
|
2023-04-03 15:24:05 +03:00
|
|
|
|
|
|
|
|
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')
|
2023-04-03 20:03:08 +03:00
|
|
|
})
|
2023-03-25 16:51:49 +02:00
|
|
|
}, false)
|