Created
January 26, 2023 05:22
-
-
Save deanrad/af8224da0598add666741ffa9313db55 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html> | |
<head> | |
<title>TimeHop Your Inbox</title> | |
<meta charset="utf-8" /> | |
<!-- Load dependencies --> | |
<script src="https://unpkg.com/[email protected]/dist/bundles/rxjs.umd.js"></script> | |
<script src="https://unpkg.com/antares-protocol/dist/antares-protocol.js"></script> | |
<script src="https://unpkg.com/[email protected]/dist/redux.js"></script> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.4.2/umd/react.production.min.js"></script> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.4.2/umd/react-dom.production.min.js"></script> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-redux/5.0.7/react-redux.min.js"></script> | |
<script src="vendor/hyperx.js"></script> | |
<script> | |
const { Subject, Observable, of, from, empty, zip, fromEvent, interval } = | |
rxjs; | |
const { | |
toArray, | |
map, | |
pluck, | |
filter, | |
startWith, | |
delay, | |
debounceTime, | |
distinctUntilChanged, | |
} = rxjs.operators; | |
const { after } = AntaresProtocol; | |
const { hash } = document.location; | |
const extra = hash ? decodeURIComponent(hash.substring(1)) : ""; | |
const searchQuery = extra.includes(":") | |
? extra | |
: extra + " {filename:mp3}"; | |
// While you can't push values at a Promise, you can next/complete a Subject. | |
// When googleDidLoad is called by the google script, we pass our notfier into auth.. | |
// ..and ask for the Promise of that notification happening via a call to next() | |
const apiAuthorizations = new Subject(); | |
const apiAuthorized = apiAuthorizations.toPromise(); | |
function googleDidLoad() { | |
authAndNotify(apiAuthorizations); | |
} | |
// The agent, to which we will send actions for processing | |
let agent; | |
// The store, in which we'll remember/reduce actions we've seen | |
let store; | |
// Here's our 'main': | |
// We await the moment we're authorized - and follow it up with setting up our agent's async. | |
// Then we process its first action, which kicks off all further consequences. | |
(async function () { | |
try { | |
// Await the login Promise' resolution | |
let gapi = await apiAuthorized; | |
// Instantiate the agent | |
const { Agent } = AntaresProtocol; | |
agent = new Agent(); | |
// Set up action listeners on the agent | |
setUpAgent(agent); | |
initUI(); | |
// Create the first action, and process it, kicking it all off! | |
let searchAction = { | |
type: "messages/list", | |
payload: { | |
q: searchQuery, | |
userId: "me", | |
}, | |
}; | |
agent.process(searchAction); | |
} catch (ex) { | |
console.error(ex.message); | |
} | |
})(); | |
</script> | |
<script> | |
// This function will invoke .next() and .complete() on its argument at the appropriate time in | |
// the authentication process. Using Subjects and doing agent.process are both good ways to | |
// communicate out of a nested callback spaghetti tangle. | |
function authAndNotify(toNotify) { | |
gapi.load("client:auth2", function () { | |
const CLIENT_ID = | |
"343017099143-pv3qfvpuhscqq0b8ae45vnqsl833pc85.apps.googleusercontent.com"; | |
const API_KEY = "AIzaSyAs5-5i-q2vfVb7GbRaDNxW5Ro4DQIfDX4"; | |
const DISCOVERY_DOCS = [ | |
"https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest", | |
]; | |
const SCOPES = "https://www.googleapis.com/auth/gmail.readonly"; | |
gapi.client | |
.init({ | |
apiKey: API_KEY, | |
clientId: CLIENT_ID, | |
discoveryDocs: DISCOVERY_DOCS, | |
scope: SCOPES, | |
}) | |
.then(function () { | |
// Called when we detect a valid sign in | |
const finishUp = () => { | |
toNotify.next(gapi); | |
toNotify.complete(); | |
}; | |
// Expediate successive sign-ins | |
if (gapi.auth2.getAuthInstance().isSignedIn.get()) finishUp(); | |
// Notify of sign-in state changes. | |
gapi.auth2.getAuthInstance().isSignedIn.listen(() => { | |
const authState = gapi.auth2.getAuthInstance().isSignedIn.get(); | |
if (authState) { | |
finishUp(); | |
} | |
}); | |
// Kick it all off | |
gapi.auth2.getAuthInstance().signIn(); | |
}); | |
}); | |
} | |
// Function handlers to manipulate the current song/audioContext | |
let skipCurrent; | |
let togglePauseCurrent; | |
// Keyboard control niceties | |
document.addEventListener("keypress", (e) => { | |
(e.key === "s" || e.key === "n") && skipCurrent && skipCurrent(); | |
e.key === "p" && togglePauseCurrent && togglePauseCurrent(); | |
}); | |
// Primarily we want our agent to: | |
// 1. Do a search when given a messages/list action (as upon startup) | |
// 2. Look at search results to notify of attachments to be retrieved | |
// 3. Retrieve attachments serially - so that songs download in a queue of size 1 | |
// 4. Play the bytes of the attachments serially - so we don't hear multiple at once. | |
// | |
// We also pass all actions that go through agent.process through a Redux store | |
// filter to allow us to see the action going on in Redux DevTools. | |
// | |
// Lastly we provide a reducer to remember some of the actions seen so we can | |
// maintain a list of songs we have queued. | |
function setUpAgent(agent) { | |
// Heres where we set up the chain of async handlers, so the magic happens! | |
// Add renderers which listen for the action specification given, and run a function | |
// which calls agent.process with actions of the type(s) given | |
agent.on("messages/list", searchMessages("@@gapi/message/header")); | |
agent.on( | |
"@@gapi/message/header", | |
getMessageBody("@@gapi/message/body") | |
); | |
agent.on( | |
"@@gapi/message/body", | |
getAttachmentBodies("@@gapi/attachment/body"), | |
{ | |
concurrency: "serial", | |
} | |
); | |
// When its been 60 seconds since a message body, announce a completion | |
agent.on( | |
"@@gapi/message/body", | |
({ action }) => { | |
return after(60000, () => { | |
agent.process({ type: "messages/list/done", payload: "party!" }); | |
}); | |
}, | |
{ | |
concurrency: "cutoff", // make sure theres only one at a time by cutting off any previous | |
} | |
); | |
agent.on( | |
"@@gapi/attachment/body", | |
playAttachment("audio/play/start", "audio/play/stop"), | |
{ | |
concurrency: "serial", | |
} | |
); | |
// lets see what our bus is made of! | |
agent.addRenderer( | |
({ action }) => { | |
const { type, payload } = action; | |
console.log(type, payload); | |
}, | |
{ | |
actionsOfType: /.*/, | |
} | |
); | |
// A ticker | |
agent.addRenderer( | |
({ action }) => { | |
const { filename } = action.payload; | |
return action.type.includes("stop") || | |
action.type.includes("pause") || | |
document.location.search.includes("notick") | |
? empty() | |
: interval(1000).pipe( | |
map(() => ({ | |
type: "audio/play/tick", | |
payload: { filename }, | |
})) | |
); | |
}, | |
{ | |
processResults: true, | |
concurrency: "cutoff", | |
actionsOfType: /audio\/play\/(stop|start|pause)/, | |
} | |
); | |
// Redux will update our UI because of our store | |
const reducer = ( | |
state = { queue: [], playtimes: {}, stillSearching: false }, | |
{ type, payload } | |
) => { | |
const { | |
date, | |
snippet, | |
filename, | |
duration, | |
from, | |
to, | |
guid, | |
attachId, | |
} = payload || {}; | |
switch (type) { | |
case "messages/list": | |
return { | |
...state, | |
stillSearching: true, | |
}; | |
case "messages/list/done": | |
return { | |
...state, | |
stillSearching: false, | |
}; | |
case "audio/play/start": | |
const [toPlay, ...remaining] = state.queue; | |
return { | |
...state, | |
isPlaying: true, | |
currentSong: { | |
date, | |
snippet, | |
filename, | |
duration, | |
from, | |
to, | |
guid, | |
attachId, | |
}, | |
queue: | |
filename === (state.currentSong || {}).filename | |
? state.queue | |
: remaining, | |
}; | |
case "audio/play/pause": | |
return { | |
...state, | |
isPlaying: false, | |
}; | |
case "audio/play/stop": | |
return { | |
...state, | |
isPlaying: false, | |
}; | |
case "audio/play/tick": | |
const song = payload.filename; | |
const playtimes = state.playtimes; | |
const curTime = playtimes[song] || 0; | |
return { | |
...state, | |
playtimes: { ...playtimes, [song]: curTime + 1 }, | |
}; | |
case "@@gapi/attachment/body": | |
const queue = [...(state.queue || [])]; | |
return { | |
...state, | |
queue: [ | |
...queue, | |
{ | |
date, | |
snippet, | |
filename, | |
duration, | |
from, | |
to, | |
guid, | |
attachId, | |
}, | |
], | |
}; | |
default: | |
return state; | |
} | |
}; | |
store = Redux.createStore( | |
reducer, | |
window.__REDUX_DEVTOOLS_EXTENSION__ && | |
window.__REDUX_DEVTOOLS_EXTENSION__() | |
); | |
agent.addFilter(({ action }) => store.dispatch(action)); | |
} | |
window.agent = agent; | |
console.log("Antares agent v: " + agent.VERSION); | |
</script> | |
<script | |
async | |
defer | |
src="https://apis.google.com/js/api.js" | |
onload="this.onload=function(){};googleDidLoad()" | |
onreadystatechange="if (this.readyState === 'complete') this.onload()" | |
> | |
onerror = "document.write('Could not connect to Google - <a href=\"javascript:document.location.reload()\">Reload</a>')" | |
</script> | |
<style> | |
a { | |
text-decoration: none; | |
} | |
</style> | |
</head> | |
<body> | |
<div id="search"> | |
<b style="font-size: 150%">TimeHop Inbox!!</b><br /> | |
Play files matching: | |
<input | |
id="txtSearch" | |
style="min-width: 25em; font-size: larger" | |
value="" | |
/> | |
</div> | |
<hr /> | |
<div id="react-root"></div> | |
<hr /> | |
<div id="instructions"> | |
<br /> | |
Make sure pop-ups are allowed for the OAuth connection! ( | |
<a | |
href="https://support.google.com/mail/answer/7190?hl=en" | |
target="_blank" | |
>Google's Rules</a | |
>) <br /> | |
Try editing the search term. <br /> | |
Read more about | |
<a | |
target="_blank" | |
href="https://gist.github.com/deanius/0230d80350b94fb30b87e26fc80c5847" | |
>how it works</a | |
>, and why <b>it's safe</b>. Enjoy time-traveling! | |
</div> | |
<script> | |
// If our search changes, we dont want old attachments playing - reload the page. | |
const txtSearch = document.getElementById("txtSearch"); | |
txtSearch.value = searchQuery; | |
const searches = fromEvent(txtSearch, "keyup").pipe( | |
map((e) => e.target.value), | |
distinctUntilChanged(), | |
debounceTime(700) | |
); | |
searches.subscribe((search) => { | |
document.location.hash = search.replace(/\s*{[^}]*}\s*/g, ""); | |
document.location.reload(); | |
}); | |
// ----- Handler/renderer implementations follow ---- | |
// Emits: '@@gapi/message/header' for each search result | |
function searchMessages(headerType = "@@gapi/message/header") { | |
return function searchRenderer({ action: { payload } }) { | |
const origQuery = payload; | |
const getPageOfMessages = function (request) { | |
request.execute(function (resp) { | |
if (!resp.messages) return; | |
// delicious randomness (within each result page, at least) | |
// resp.messages.sort((a, b) => 0.5 - Math.random()); | |
resp.messages.forEach((m) => { | |
// Our rendering is to emit actions of the messages retrieved | |
agent.process({ | |
type: headerType, | |
payload: { messageId: m.id }, | |
}); | |
}); | |
// Get the next page, or bailout | |
const nextPageToken = resp.nextPageToken; | |
if (!nextPageToken) return; | |
// Include the token and the fields of the original request as we recur | |
const query = Object.assign({}, origQuery, { | |
pageToken: nextPageToken, | |
}); | |
request = gapi.client.gmail.users.messages.list(query); | |
getPageOfMessages(request); | |
}); | |
}; | |
const initialRequest = | |
gapi.client.gmail.users.messages.list(origQuery); | |
getPageOfMessages(initialRequest); | |
}; | |
} | |
// Creates follow up actions for each audio attachment in the message | |
// identified in the action's payload.messageId field. | |
function getMessageBody(bodyType = "@@gapi/message/body") { | |
return function ({ action }) { | |
const { messageId } = action.payload; | |
const req = gapi.client.gmail.users.messages.get({ | |
userId: "me", | |
id: messageId, | |
}); | |
req.execute((md) => { | |
if (!md.payload) return; // no attachments we care about | |
const { snippet, internalDate } = md; | |
const { headers, parts } = md.payload; | |
const from = headers | |
.filter((h) => h.name === "From") | |
.map((h) => h.value)[0]; | |
const to = headers | |
.filter((h) => h.name === "To") | |
.map((h) => h.value)[0]; | |
const guid = headers | |
.filter((h) => h.name === "Message-ID") | |
.map((h) => h.value)[0]; | |
const audioAttachments = parts.filter((p) => | |
p.mimeType.match(/^audio/) | |
); | |
const date = new Date( | |
parseInt(md.internalDate, 10) | |
).toLocaleDateString(); | |
// Process follow up actions with enough info on each attachment and where it came from | |
audioAttachments.forEach((part) => { | |
const { filename, mimeType } = part; | |
const attachId = part.body.attachmentId; | |
// Communicate back to the agent | |
agent.process({ | |
type: bodyType, | |
payload: { | |
guid, | |
messageId, | |
from, | |
to, | |
snippet, | |
date, | |
filename, | |
mimeType, | |
attachId, | |
}, | |
}); | |
}); | |
}); | |
}; | |
} | |
// Creates @@gapi/message/body actions for the attachmentId/messageId given. | |
function getAttachmentBodies(type) { | |
return function ({ action }) { | |
// Return an Observable to be able to do concurrency: serial | |
return new Observable((o) => { | |
const { guid, messageId, snippet, attachments, date, from, to } = | |
action.payload; | |
const { filename, mimeType, attachId } = action.payload; | |
const request = gapi.client.gmail.users.messages.attachments.get({ | |
id: attachId, | |
messageId, | |
userId: "me", | |
}); | |
request.execute(function (attachment) { | |
const { size, data } = attachment; | |
const bData = atob(urlDec(data)); | |
const rawBytes = Uint8Array.from(bData, (c) => | |
c.charCodeAt(0) | |
).buffer; | |
agent.process({ | |
type, | |
payload: { | |
guid, | |
messageId, | |
snippet, | |
attachId, | |
filename, | |
mimeType, | |
size, | |
rawBytes, | |
date, | |
from, | |
to, | |
}, | |
}); | |
o.complete(); | |
}); | |
}).pipe(delay(Math.random() * 1000)); | |
}; | |
// Utility function to replace non-url compatible chars with base64 standard chars | |
function urlDec(input) { | |
return input.replace(/-/g, "+").replace(/_/g, "/"); | |
} | |
} | |
// Creates an Observable wrapping the beginning and concluding of the audio of an attachment. | |
function playAttachment( | |
startType, | |
stopType, | |
pauseType = "audio/play/pause" | |
) { | |
return function ({ action }) { | |
return new Observable((notify) => { | |
const { | |
payload: { | |
guid, | |
messageId, | |
attachId, | |
from, | |
to, | |
rawBytes, | |
snippet, | |
filename, | |
mimeType, | |
size, | |
date, | |
}, | |
} = action; | |
if (!rawBytes) return; | |
const audioCtx = new (window.AudioContext || | |
window.webkitAudioContext)(); | |
let songInfo; // the metadata | |
try { | |
const source = audioCtx.createBufferSource(); | |
audioCtx.decodeAudioData(rawBytes).then((audioBuffer) => { | |
source.buffer = audioBuffer; | |
source.connect(audioCtx.destination); | |
const { duration } = audioBuffer; | |
songInfo = { | |
messageId, | |
attachId, | |
guid, | |
date, | |
snippet, | |
from, | |
to, | |
filename, | |
mimeType, | |
size, | |
duration, | |
}; | |
agent.process({ type: startType, payload: songInfo }); | |
// When we are stopped mark our Observable as complete so the next downloaded | |
// attachment will begin downloading immediately | |
source.onended = () => { | |
agent.process({ type: stopType, payload: songInfo }); | |
notify && notify.complete(); | |
}; | |
source.start(0); | |
}); | |
// Setup up cancellation functions (we really should use actions for this) | |
skipCurrent = () => source.stop(); | |
togglePauseCurrent = () => { | |
if (audioCtx.state === "running") { | |
agent.process({ type: pauseType, payload: songInfo }); | |
audioCtx.suspend(); | |
} else if (audioCtx.state === "suspended") { | |
agent.process({ type: startType, payload: songInfo }); | |
audioCtx.resume(); | |
} | |
}; | |
} catch (ex) { | |
notify.error(ex); | |
console.error("Decoding error for ", { messageId, ex }); | |
} | |
}); | |
}; | |
} | |
</script> | |
<script> | |
function initUI() { | |
const h = React.createElement; | |
const hx = hyperx(h); | |
const { render } = ReactDOM; | |
const { connect, Provider } = ReactRedux; | |
const App = ({ | |
queue, | |
isPlaying, | |
stillSearching, | |
elapsed, | |
currentSong, | |
}) => { | |
let { dateDigits, filename, duration, snippet, from, to, guid } = | |
currentSong; | |
let dateDiv; | |
let fromDiv; | |
let toDiv; | |
let playerDiv; | |
let link = guid | |
? hx`<a target="_blank" href=${ | |
"https://mail.google.com/mail/#search/" + | |
encodeURIComponent("rfc822msgid:" + guid) | |
} title="link">📨</a>` | |
: ""; | |
if (dateDigits) { | |
dateDiv = hx`<div><i>Dated: </i>${dateDigits | |
.join("") | |
.replace(/(\d\d\d\d)(\d\d)(\d\d)/, "$1-$2-$3")} </div>`; | |
} | |
if (from) { | |
fromDiv = hx`<div><i>From: </i>${from}</div>`; | |
} | |
if (to) { | |
toDiv = hx`<div><i>To: </i>${to}</div>`; | |
} | |
if (filename) { | |
playerDiv = hx` | |
<div> | |
${isPlaying ? "Playing:" : "Paused:"} ${filename} (${ | |
Math.ceil(duration / 60) || "?" | |
} min) ${link} | |
<p/> | |
<input type="range" min="0" max=${Math.ceil( | |
duration | |
)} value=${elapsed} /> | |
<br/> | |
<button onClick=${skipCurrent}>Skip to Next (s)</button> | |
<button onClick=${togglePauseCurrent}>Pause/Resume (p)</button> | |
</div> | |
`; | |
} | |
return hx` | |
<div> | |
${playerDiv} | |
<p></p> | |
${fromDiv} | |
${toDiv} | |
${dateDiv} | |
<div>${snippet}</div> | |
<hr/> | |
<div><h4>Queue</h4> | |
<ul> | |
${queue.map((song) => { | |
let link = song.guid | |
? hx`<a target="_blank" href=${ | |
"https://mail.google.com/mail/#search/" + | |
encodeURIComponent("rfc822msgid:" + song.guid) | |
} title="link">📨</a>` | |
: ""; | |
return hx` | |
<li key=${song.attachId}> | |
${song.filename} | |
${link} | |
</li>`; | |
})} | |
</ul> | |
</div> | |
</div> | |
`; | |
}; | |
const mapStateToProps = ({ | |
playtimes, | |
queue, | |
isPlaying, | |
stillSearching, | |
currentSong = {}, | |
}) => { | |
let dateDigits = | |
currentSong.date && | |
new Date(currentSong.date) | |
.toISOString() | |
.substring(0, 10) | |
.replace(/-/g, "") | |
.split(""); | |
let elapsed = playtimes[currentSong.filename] || 0; | |
return { | |
isPlaying, | |
stillSearching, | |
elapsed, | |
currentSong: Object.assign({}, currentSong, { dateDigits }), | |
queue, | |
}; | |
}; | |
const ConnectedApp = connect(mapStateToProps)(App); | |
render( | |
h(Provider, { store }, h(ConnectedApp)), | |
document.getElementById("react-root") | |
); | |
} | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment