Skip to content

Instantly share code, notes, and snippets.

@donaldguy
Last active February 1, 2024 06:55
Show Gist options
  • Save donaldguy/8b56909e704f2977914ac61ec586c735 to your computer and use it in GitHub Desktop.
Save donaldguy/8b56909e704f2977914ac61ec586c735 to your computer and use it in GitHub Desktop.
app.hey.com: add unread counts and auto-advance to the "Imbox" - very much a work in progress
// ==UserScript==
// @name Add counts to Hey.com Imbox (and contents)
// @run-at document-end
// @match https://app.hey.com/*
// @grant GM_getValue
// @grant GM_setValue
const BASE_URL_PATTERN = 'https://app\\.hey\\.com'
let UnreadCount = -1;
let SeenCount = -999;
let CachedUnreadURLs = new Set()
let ShouldAutoAdvance = false
function countsText() {
if (UnreadCount < 0) {
return ""
}
if (SeenCount < 0) {
return ` (${UnreadCount})`
}
return ` (${UnreadCount} / ${UnreadCount + SeenCount})`
}
class Page {
static url_pattern = new RegExp(`${BASE_URL_PATTERN}/.+`);
loaded() { return; }
}
const Imbox = new (class Imbox extends Page {
url_pattern = new RegExp(`^${BASE_URL_PATTERN}/$|^${BASE_URL_PATTERN}/imbox`);
get(_url) { return this }
loaded() {
this.postings = document.getElementById("postings")
this.selectUnread()
if (UnreadCount < 0) {
this.insertCountButton("Count Unread", this.scrollToLoadUnread.bind(this))
} else if (SeenCount < 0) {
this.insertCountButton("Count Total", this.scrollToLoadAll.bind(this))
}
this.processUnread()
if (ShouldAutoAdvance) {
this.auto_advance()
} else {
const titleWithCounts = `Imbox${countsText()}`
document.getElementsByTagName('h1')[0].innerText = titleWithCounts
document.title = titleWithCounts
}
}
auto_advance() {
ShouldAutoAdvance = false;
this.postings.querySelector("a.permalink").click()
}
selectUnread() {
this.unreadEmailArticles = this.postings.querySelectorAll('article[data-new-for-you="true"]');
}
insertCountButton(text, handler) {
const sheetActions = document.getElementsByClassName('sheet-actions')[0];
const firstButton = sheetActions.querySelector('a.btn');
this.countButton = document.createElement('a')
this.countButton.href = "#"
this.countButton.classList.add('btn')
this.countButton.classList.add('btn--secondary')
this.countButton.innerText = text
this.countButton.addEventListener('click', (e) => {
e.preventDefault();
handler()
});
sheetActions.insertBefore(this.countButton, firstButton)
}
scrollToLoadUnread() {
if (!this.scrollPoller) {
this.scrollPoller = setInterval(this.scrollToLoadUnread.bind(this), 300)
return
}
const readEmailLoaded = this.postings.querySelector('article[data-seen="true"]')
if (!readEmailLoaded) {
const oldestNewEmail = this.unreadEmailArticles[this.unreadEmailArticles.length - 1]
oldestNewEmail.scrollIntoView(true)
this.selectUnread()
return
} else {
readEmailLoaded.scrollIntoView(true)
this.selectUnread()
}
// if we made it to here, we have an old email in view and fetching
// _should_ be done
clearInterval(this.scrollPoller)
this.scrollPoller = false
UnreadCount = this.unreadEmailArticles.length
CachedUnreadURLs = new Set()
this.countButton.remove()
window.scrollTo(0, 0)
this.loaded()
}
scrollToLoadAll() {
// XXX: only works if cover is not active; state is in localStorage.peeking
// but how to mutate best?
if (!this.scrollPoller) {
this.scrollPoller = setInterval(this.scrollToLoadAll.bind(this), 800)
return
}
if (UnreadCount > 0) {
this.unreadEmailArticles[this.unreadEmailArticles.length - 1].scrollIntoView(true)
}
const readEmailLoaded = this.postings.querySelectorAll('article[data-seen="true"]')
if (!readEmailLoaded) {
return
}
const paginationLinkExists = this.postings.querySelector('.pagination-link')
while (paginationLinkExists) {
const oldestEmail = readEmailLoaded[readEmailLoaded.length - 1]
oldestEmail.scrollIntoView(true)
return
}
// if we made it to here, we have an old email in view and fetching
// _should_ be done
clearInterval(this.scrollPoller)
this.scrollPoller = false
SeenCount = readEmailLoaded.length
this.countButton.remove()
window.scrollTo(0, 0)
this.loaded()
}
processUnread() {
for (const article of this.unreadEmailArticles) {
CachedUnreadURLs.add(article.querySelector('a.permalink').href)
}
}
})()
class Thread extends Page {
static url_pattern = new RegExp(`^${BASE_URL_PATTERN}/topics/[^/]+$`);
constructor(url) {
super()
this.url = url
this.was_unread = CachedUnreadURLs && CachedUnreadURLs.has(this.url)
}
static get(url) { return new Thread(url); }
loaded() {
document.title = `${document.title}${countsText()}`
}
}
// ---------
class GMHeyNavigation {
constructor(from_page, to_page, why) {
this.from_page = from_page
this.to_page = to_page
this.why = why
console.log(this);
if (!from_page) {
return;
}
if (to_page.constructor === Thread && to_page.was_unread) {
UnreadCount -= 1
SeenCount += 1
CachedUnreadURLs.delete(from_page.url)
}
if (from_page.constructor === Thread && why == 'unseen') {
UnreadCount += 1
SeenCount -= 1
CachedUnreadURLs.add(from_page.url)
}
if (from_page.constructor === Thread && why == 'status/trashed') {
SeenCount -= 1
}
//unless its being moved _to_ the Imbox
if (from_page.constructor === Thread && why.startsWith('moves')) {
SeenCount -= 1
}
if (from_page.constructor === Thread && to_page === Imbox && why != 'back' && why != 'unseen') {
ShouldAutoAdvance = true
}
}
}
// --------
const detectPage = (url) => {
for (page of [Imbox, Thread]) {
if (page.url_pattern.test(url)) {
return page.get(url)
}
}
}
let CurrentPage = detectPage(window.location.href);
(function() {
new GMHeyNavigation(
undefined,
CurrentPage,
'load'
);
document.addEventListener('turbo:visit', function(event) {
var from = CurrentPage
var to = detectPage(event.detail.url)
var why = 'visit';
if (from.exit_reason) {
why = from.exit_reason
} else if (to === Imbox && event.detail.action == 'restore') {
why = 'back'
}
new GMHeyNavigation(from, to, why);
CurrentPage = to;
return true;
});
document.addEventListener('turbo:load', function() {
CurrentPage.loaded();
});
document.addEventListener('turbo:submit-end', function(event) {
if (!event.detail.success) {
return true;
}
if (CurrentPage.constructor === Thread && event.target.action.startsWith(CurrentPage.url)) {
CurrentPage.exit_reason = event.target.action.substr(CurrentPage.url.length + 1)
}
return true;
});
})();
return
@donaldguy
Copy link
Author

For the record, I'm not really sure how I feel about 37signals, in view of e.g. their (then temporarily dba Basecamp Inc) early 2021 "no politics at work" and COVID-safety bullshit.

I've definitely been, as such, a little embarrassed by my @hey.com email address very nearly as long as I've head it. But haven't as yet taken the time/energy to figure out a better plan

But the $99 I've given them a year so far is probably not exactly my least ethical consumption under capitalism

--

I do kinda like some of the product innovations/ideas in Hey - but I find deeply irritating (each of and especially the combination of) their failure to build ~table stakes workflow functionality AND arbitrary1 refusal to work with any external clients, nor offer an API

Anyway, this is my limited scratch-own-itch attempt to re-inject some of the most glaring omissions

It is neither done nor perfect, but it certainly has helped me catch up after getting behind on my email on several occasions since I wrote it

And this gist seems like a better pin for my Github profile than the ancient gists and ruby that was there til an hour ago.

Footnotes

  1. I simply do not believe there is neither IMAP nor e.g. JMAP in their backends already ; and if there truly isn't, I still think they should build a standards-compliant bridge to whatever data backends are there - if they really want it can be read-only (or for 90% of what I want from it like ... mark-as-read and delete only).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment