Skip to content

Instantly share code, notes, and snippets.

@deanrad
Created January 26, 2023 05:22
Show Gist options
  • Save deanrad/af8224da0598add666741ffa9313db55 to your computer and use it in GitHub Desktop.
Save deanrad/af8224da0598add666741ffa9313db55 to your computer and use it in GitHub Desktop.
<!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